Improve UI

This commit is contained in:
Andras Schmelczer 2026-02-05 21:19:19 +00:00
parent 5fe192d25a
commit ae29662c92
14 changed files with 221 additions and 313 deletions

View file

@ -1,81 +0,0 @@
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,
};
}

View file

@ -1,27 +0,0 @@
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,
};
}

View file

@ -1,55 +0,0 @@
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 };
}