| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import type { SortOption, StatusFilter } from "@/types/filters"; |
| 3 | |
| 4 | export interface BaseServer { |
| 5 | id: string; |
| 6 | name: string; |
| 7 | description: string; |
| 8 | tags: string[]; |
| 9 | thumbnail: string; |
| 10 | owner: string; |
| 11 | online: boolean; |
| 12 | dns: string; |
| 13 | link: string; |
| 14 | lastUpdated?: string; |
| 15 | firstSeen?: string; |
| 16 | paymentEnabled?: boolean; |
| 17 | paymentLabel?: string; |
| 18 | } |
| 19 | |
| 20 | export interface UseListOptions<T extends BaseServer> { |
| 21 | servers: T[]; |
| 22 | storageKey: string; |
| 23 | additionalFilter?: (server: T) => boolean; |
| 24 | } |
| 25 | |
| 26 | export interface UseListReturn<T extends BaseServer> { |
| 27 | searchQuery: string; |
| 28 | status: StatusFilter; |
| 29 | sortBy: SortOption; |
| 30 | selectedTags: string[]; |
| 31 | favorites: string[]; |
| 32 | availableTags: string[]; |
| 33 | filteredServers: T[]; |
| 34 | handleSearchChange: (value: string) => void; |
| 35 | handleStatusChange: (value: StatusFilter) => void; |
| 36 | handleSortByChange: (value: SortOption) => void; |
| 37 | handleTagToggle: (tag: string) => void; |
| 38 | handleToggleFavorite: (serverId: string) => void; |
| 39 | } |
| 40 | |
| 41 | function readStoredFavorites(storageKey: string): string[] { |
| 42 | let raw: string | null = null; |
| 43 | try { |
| 44 | raw = localStorage.getItem(storageKey); |
| 45 | } catch { |
| 46 | return []; |
| 47 | } |
| 48 | |
| 49 | if (!raw) { |
| 50 | return []; |
| 51 | } |
| 52 | |
| 53 | try { |
| 54 | const parsed = JSON.parse(raw); |
| 55 | if (!Array.isArray(parsed)) { |
| 56 | return []; |
| 57 | } |
| 58 | return [...new Set( |
| 59 | parsed.filter( |
| 60 | (value): value is string => typeof value === "string" && value.length > 0 |
| 61 | ) |
| 62 | )]; |
| 63 | } catch { |
| 64 | return []; |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | function parseTimestamp(value?: string, fallback = 0): number { |
| 69 | if (!value) { |
| 70 | return fallback; |
| 71 | } |
| 72 | |
| 73 | const parsed = Date.parse(value); |
| 74 | return Number.isNaN(parsed) ? fallback : parsed; |
| 75 | } |
| 76 | |
| 77 | function matchesStatus(online: boolean, status: StatusFilter): boolean { |
| 78 | switch (status) { |
| 79 | case "online": |
| 80 | return online; |
| 81 | case "offline": |
| 82 | return !online; |
| 83 | default: |
| 84 | return true; |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | export function useList<T extends BaseServer>({ |
| 89 | servers, |
| 90 | storageKey, |
| 91 | additionalFilter, |
| 92 | }: UseListOptions<T>): UseListReturn<T> { |
| 93 | const [searchQuery, setSearchQuery] = useState(""); |
| 94 | const [status, setStatus] = useState<StatusFilter>("all"); |
| 95 | const [sortBy, setSortBy] = useState<SortOption>("duration"); |
| 96 | const [selectedTags, setSelectedTags] = useState<string[]>([]); |
| 97 | const [favorites, setFavorites] = useState<string[]>(() => |
| 98 | readStoredFavorites(storageKey) |
| 99 | ); |
| 100 | const hasLoadedServersRef = useRef(false); |
| 101 | |
| 102 | useEffect(() => { |
| 103 | setFavorites(readStoredFavorites(storageKey)); |
| 104 | }, [storageKey]); |
| 105 | |
| 106 | useEffect(() => { |
| 107 | try { |
| 108 | localStorage.setItem(storageKey, JSON.stringify(favorites)); |
| 109 | } catch { |
| 110 | // Ignore storage write failures (quota/private browsing). |
| 111 | } |
| 112 | }, [favorites, storageKey]); |
| 113 | |
| 114 | const availableTags = useMemo(() => { |
| 115 | const counts = new Map<string, number>(); |
| 116 | servers.forEach((server) => { |
| 117 | server.tags.forEach((tag) => { |
| 118 | const normalizedTag = typeof tag === "string" ? tag.trim().toLowerCase() : ""; |
| 119 | if (!normalizedTag) { |
| 120 | return; |
| 121 | } |
| 122 | counts.set(normalizedTag, (counts.get(normalizedTag) || 0) + 1); |
| 123 | }); |
| 124 | }); |
| 125 | |
| 126 | return Array.from(counts.entries()) |
| 127 | .sort((a, b) => b[1] - a[1]) |
| 128 | .map(([tag]) => tag); |
| 129 | }, [servers]); |
| 130 | |
| 131 | useEffect(() => { |
| 132 | if (servers.length > 0) { |
| 133 | hasLoadedServersRef.current = true; |
| 134 | } |
| 135 | if (!hasLoadedServersRef.current && servers.length === 0) { |
| 136 | return; |
| 137 | } |
| 138 | const validIDs = new Set(servers.map((server) => server.id)); |
| 139 | setFavorites((prev) => { |
| 140 | const next = prev.filter((id) => validIDs.has(id)); |
| 141 | return next.length === prev.length ? prev : next; |
| 142 | }); |
| 143 | }, [servers]); |
| 144 | |
| 145 | useEffect(() => { |
| 146 | const availableTagSet = new Set(availableTags); |
| 147 | setSelectedTags((prev) => { |
| 148 | const next = prev.filter((tag) => availableTagSet.has(tag)); |
| 149 | return next.length === prev.length ? prev : next; |
| 150 | }); |
| 151 | }, [availableTags]); |
| 152 | |
| 153 | const filteredServers = useMemo(() => { |
| 154 | const query = searchQuery.trim().toLowerCase(); |
| 155 | const selectedTagSet = new Set(selectedTags); |
| 156 | const favoriteSet = new Set(favorites); |
| 157 | const now = Date.now(); |
| 158 | |
| 159 | const matchesTags = (server: T): boolean => { |
| 160 | if (selectedTagSet.size === 0) { |
| 161 | return true; |
| 162 | } |
| 163 | |
| 164 | return server.tags.some((tag) => selectedTagSet.has(tag.toLowerCase().trim())); |
| 165 | }; |
| 166 | |
| 167 | const matchesSearch = (server: T): boolean => { |
| 168 | if (query === "") { |
| 169 | return true; |
| 170 | } |
| 171 | |
| 172 | return ( |
| 173 | server.name.toLowerCase().includes(query) || |
| 174 | server.description.toLowerCase().includes(query) || |
| 175 | (server.paymentLabel || "").toLowerCase().includes(query) || |
| 176 | server.tags.some((tag) => tag.toLowerCase().includes(query)) |
| 177 | ); |
| 178 | }; |
| 179 | |
| 180 | const filtered = servers.filter((server) => { |
| 181 | const matchesAdditional = additionalFilter ? additionalFilter(server) : true; |
| 182 | |
| 183 | return ( |
| 184 | matchesSearch(server) && |
| 185 | matchesStatus(server.online, status) && |
| 186 | matchesTags(server) && |
| 187 | matchesAdditional |
| 188 | ); |
| 189 | }); |
| 190 | |
| 191 | const sortByField = (sortValue: SortOption) => { |
| 192 | switch (sortValue) { |
| 193 | case "name-asc": |
| 194 | return (a: T, b: T) => a.name.localeCompare(b.name); |
| 195 | case "name-desc": |
| 196 | return (a: T, b: T) => b.name.localeCompare(a.name); |
| 197 | case "updated": |
| 198 | return (a: T, b: T) => |
| 199 | parseTimestamp(b.lastUpdated, 0) - parseTimestamp(a.lastUpdated, 0); |
| 200 | case "duration": |
| 201 | return (a: T, b: T) => |
| 202 | parseTimestamp(a.firstSeen, now) - parseTimestamp(b.firstSeen, now); |
| 203 | case "description": |
| 204 | return (a: T, b: T) => a.description.localeCompare(b.description); |
| 205 | case "tags": |
| 206 | return (a: T, b: T) => (a.tags[0] || "").localeCompare(b.tags[0] || ""); |
| 207 | case "owner": |
| 208 | return (a: T, b: T) => a.owner.localeCompare(b.owner); |
| 209 | case "default": |
| 210 | return (a: T, b: T) => a.id.localeCompare(b.id); |
| 211 | default: |
| 212 | return (a: T, b: T) => a.id.localeCompare(b.id); |
| 213 | } |
| 214 | }; |
| 215 | |
| 216 | const sorted = [...filtered].sort(sortByField(sortBy)); |
| 217 | sorted.sort((a, b) => { |
| 218 | const aIsFav = favoriteSet.has(a.id); |
| 219 | const bIsFav = favoriteSet.has(b.id); |
| 220 | if (aIsFav && !bIsFav) return -1; |
| 221 | if (!aIsFav && bIsFav) return 1; |
| 222 | return 0; |
| 223 | }); |
| 224 | |
| 225 | return sorted; |
| 226 | }, [servers, searchQuery, status, sortBy, selectedTags, favorites, additionalFilter]); |
| 227 | |
| 228 | const handleSearchChange = useCallback((value: string) => { |
| 229 | setSearchQuery(value); |
| 230 | }, []); |
| 231 | |
| 232 | const handleStatusChange = useCallback((value: StatusFilter) => { |
| 233 | setStatus(value); |
| 234 | }, []); |
| 235 | |
| 236 | const handleSortByChange = useCallback((value: SortOption) => { |
| 237 | setSortBy(value); |
| 238 | }, []); |
| 239 | |
| 240 | const handleTagToggle = useCallback((tag: string) => { |
| 241 | const normalizedTag = tag.trim().toLowerCase(); |
| 242 | if (!normalizedTag) { |
| 243 | return; |
| 244 | } |
| 245 | |
| 246 | setSelectedTags((prev) => |
| 247 | prev.includes(normalizedTag) |
| 248 | ? prev.filter((candidate) => candidate !== normalizedTag) |
| 249 | : [...prev, normalizedTag] |
| 250 | ); |
| 251 | }, []); |
| 252 | |
| 253 | const handleToggleFavorite = useCallback((serverId: string) => { |
| 254 | setFavorites((prev) => |
| 255 | prev.includes(serverId) |
| 256 | ? prev.filter((id) => id !== serverId) |
| 257 | : [...prev, serverId] |
| 258 | ); |
| 259 | }, []); |
| 260 | |
| 261 | return { |
| 262 | searchQuery, |
| 263 | status, |
| 264 | sortBy, |
| 265 | selectedTags, |
| 266 | favorites, |
| 267 | availableTags, |
| 268 | filteredServers, |
| 269 | handleSearchChange, |
| 270 | handleStatusChange, |
| 271 | handleSortByChange, |
| 272 | handleTagToggle, |
| 273 | handleToggleFavorite, |
| 274 | }; |
| 275 | } |