Refactor UI
This commit is contained in:
parent
ce4c0cc08c
commit
34a4d0ba86
32 changed files with 1726 additions and 845 deletions
81
frontend/src/hooks/useDebouncedFetch.ts
Normal file
81
frontend/src/hooks/useDebouncedFetch.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { isAbortError, logNonAbortError } from '../lib/api';
|
||||
|
||||
const DEFAULT_DEBOUNCE_MS = 150;
|
||||
|
||||
interface UseDebouncedFetchOptions {
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
interface UseDebouncedFetchResult {
|
||||
fetch: (url: string, onSuccess: (data: unknown) => void) => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function useDebouncedFetch(
|
||||
options: UseDebouncedFetchOptions = {}
|
||||
): UseDebouncedFetchResult {
|
||||
const { debounceMs = DEFAULT_DEBOUNCE_MS } = options;
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFn = useCallback(
|
||||
(url: string, onSuccess: (data: unknown) => void) => {
|
||||
// Clear any pending debounce
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
// Abort any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: abortControllerRef.current.signal });
|
||||
const json = await res.json();
|
||||
onSuccess(json);
|
||||
} catch (err) {
|
||||
logNonAbortError(`Failed to fetch ${url}`, err);
|
||||
}
|
||||
}, debounceMs);
|
||||
},
|
||||
[debounceMs]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancel();
|
||||
};
|
||||
}, [cancel]);
|
||||
|
||||
return { fetch: fetchFn, cancel };
|
||||
}
|
||||
|
||||
// Typed version with generic
|
||||
export function useTypedDebouncedFetch<T>(
|
||||
options: UseDebouncedFetchOptions = {}
|
||||
): {
|
||||
fetch: (url: string, onSuccess: (data: T) => void) => void;
|
||||
cancel: () => void;
|
||||
} {
|
||||
const result = useDebouncedFetch(options);
|
||||
return {
|
||||
fetch: result.fetch as (url: string, onSuccess: (data: T) => void) => void,
|
||||
cancel: result.cancel,
|
||||
};
|
||||
}
|
||||
27
frontend/src/hooks/useInfoPopup.ts
Normal file
27
frontend/src/hooks/useInfoPopup.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface UseInfoPopupResult<T> {
|
||||
item: T | null;
|
||||
isOpen: boolean;
|
||||
open: (item: T) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function useInfoPopup<T>(): UseInfoPopupResult<T> {
|
||||
const [item, setItem] = useState<T | null>(null);
|
||||
|
||||
const open = useCallback((newItem: T) => {
|
||||
setItem(newItem);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setItem(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
item,
|
||||
isOpen: item !== null,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
}
|
||||
55
frontend/src/hooks/useSearch.ts
Normal file
55
frontend/src/hooks/useSearch.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface UseSearchResult<T> {
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
filtered: T[];
|
||||
}
|
||||
|
||||
export function useSearch<T>(
|
||||
items: T[],
|
||||
getSearchableText: (item: T) => string
|
||||
): UseSearchResult<T> {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return items;
|
||||
const lower = query.toLowerCase();
|
||||
return items.filter((item) => getSearchableText(item).toLowerCase().includes(lower));
|
||||
}, [items, query, getSearchableText]);
|
||||
|
||||
return { query, setQuery, filtered };
|
||||
}
|
||||
|
||||
// Variant for searching groups with nested items
|
||||
export function useGroupSearch<G extends { name: string }, T>(
|
||||
groups: G[],
|
||||
getItems: (group: G) => T[],
|
||||
getSearchableText: (item: T) => string,
|
||||
createGroup: (group: G, filteredItems: T[]) => G
|
||||
): { query: string; setQuery: (query: string) => void; filtered: G[] } {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return groups;
|
||||
const lower = query.toLowerCase();
|
||||
|
||||
return groups
|
||||
.map((group) => {
|
||||
// If group name matches, return whole group
|
||||
if (group.name.toLowerCase().includes(lower)) return group;
|
||||
|
||||
// Otherwise filter items
|
||||
const items = getItems(group);
|
||||
const matchingItems = items.filter((item) =>
|
||||
getSearchableText(item).toLowerCase().includes(lower)
|
||||
);
|
||||
|
||||
if (matchingItems.length === 0) return null;
|
||||
return createGroup(group, matchingItems);
|
||||
})
|
||||
.filter((g): g is G => g !== null);
|
||||
}, [groups, query, getItems, getSearchableText, createGroup]);
|
||||
|
||||
return { query, setQuery, filtered };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue