main
ts 275 lines 7.42 KB
Raw
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 }