From 7baedd070057d4e8dadfca8f1230634f33f6471f Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 28 Feb 2026 14:38:54 -0600 Subject: [PATCH] Extract photo query logic to hook --- app/admin/components/page.tsx | 2 +- src/cmdk/CommandKClient.tsx | 109 ++++++++++++---------------------- src/photo/usePhotoQuery.ts | 58 ++++++++++++++++++ 3 files changed, 98 insertions(+), 71 deletions(-) create mode 100644 src/photo/usePhotoQuery.ts diff --git a/app/admin/components/page.tsx b/app/admin/components/page.tsx index 90a17f40..5dcd9ad5 100644 --- a/app/admin/components/page.tsx +++ b/app/admin/components/page.tsx @@ -2,7 +2,7 @@ import AdminComponentPageClient from '@/admin/AdminComponentPageClient'; import { getPhotosCached, getPhotosMetaCached } from '@/photo/cache'; export default async function ComponentsPage() { - const photos = await getPhotosCached(); + const photos = await getPhotosCached({ limit: 50 }); const photosCount = await getPhotosMetaCached() .then(({ count }) => count); diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx index 1fedb886..326b90b9 100644 --- a/src/cmdk/CommandKClient.tsx +++ b/src/cmdk/CommandKClient.tsx @@ -37,14 +37,12 @@ import { } from '../app/path'; import Modal from '../components/Modal'; import { clsx } from 'clsx/lite'; -import { useDebounce } from 'use-debounce'; import Spinner from '../components/Spinner'; import { usePathname, useRouter } from 'next/navigation'; import { useTheme } from 'next-themes'; import { BiDesktop, BiLockAlt, BiMoon, BiSun } from 'react-icons/bi'; import { IoClose, IoInvertModeSharp } from 'react-icons/io5'; import { useAppState } from '@/app/AppState'; -import { searchPhotosAction } from '@/photo/actions'; import { RiToolsFill } from 'react-icons/ri'; import { signOutAction } from '@/auth/actions'; import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; @@ -98,12 +96,12 @@ import { getSortStateFromPath } from '@/photo/sort/path'; import IconSort from '@/components/icons/IconSort'; import { useSelectPhotosState } from '@/admin/select/SelectPhotosState'; import IconAlbum from '@/components/icons/IconAlbum'; +import usePhotoQuery from '@/photo/usePhotoQuery'; const DIALOG_TITLE = 'Global Command-K Menu'; const DIALOG_DESCRIPTION = 'For searching photos, views, and settings'; const LISTENER_KEYDOWN = 'keydown'; -const MINIMUM_QUERY_LENGTH = 2; const MAX_HEIGHT = '20rem'; @@ -246,19 +244,13 @@ export default function CommandKClient({ } }, [isWaiting, setIsOpen]); - // Raw query values - const [queryLiveRaw, setQueryLiveRaw] = useState(''); - const [queryDebouncedRaw] = - useDebounce(queryLiveRaw, 500, { trailing: true }); - - // Parameterized query values - const queryLive = useMemo(() => - queryLiveRaw.trim().toLocaleLowerCase(), [queryLiveRaw]); - const queryDebounced = useMemo(() => - queryDebouncedRaw.trim().toLocaleLowerCase(), [queryDebouncedRaw]); - - const [isLoading, setIsLoading] = useState(false); - const [queriedSections, setQueriedSections] = useState([]); + const [query, setQuery] = useState(''); + const { + queryFormatted, + photos, + isLoading, + reset, + } = usePhotoQuery(query, !isPending); const { setTheme } = useTheme(); @@ -283,55 +275,32 @@ export default function CommandKClient({ return () => document.removeEventListener(LISTENER_KEYDOWN, down); }, [setIsOpen]); - useEffect(() => { - if (queryDebounced.length >= MINIMUM_QUERY_LENGTH && !isPending) { - setIsLoading(true); - searchPhotosAction(queryDebounced) - .then(photos => { - if (isOpenRef.current) { - setQueriedSections(photos.length > 0 - ? [{ - heading: 'Photos', - accessory: , - items: photos.map(photo => ({ - label: titleForPhoto(photo), - keywords: getKeywordsForPhoto(photo), - annotation: , - accessory: , - path: pathForPhoto({ photo }), - })), - }] - : []); - } else { - // Ignore stale requests that come in after dialog is closed - setQueriedSections([]); - } - setIsLoading(false); - }) - .catch(e => { - console.error(e); - setQueriedSections([]); - setIsLoading(false); - }); + const queriedSections = useMemo(() => { + if (isOpenRef.current && photos.length > 0) { + return [{ + heading: 'Photos', + accessory: , + items: photos.map(photo => ({ + label: titleForPhoto(photo), + keywords: getKeywordsForPhoto(photo), + annotation: , + accessory: , + path: pathForPhoto({ photo }), + })), + }]; + } else { + return []; } - }, [queryDebounced, isPending, appText]); - - useEffect(() => { - if (queryLive === '') { - setQueriedSections([]); - setIsLoading(false); - } else if (queryLive.length >= MINIMUM_QUERY_LENGTH) { - setIsLoading(true); - } - }, [queryLive]); + }, + [photos], + ); useEffect(() => { if (!isOpen) { - setQueryLiveRaw(''); - setQueriedSections([]); - setIsLoading(false); + setQuery(''); + reset(); } - }, [isOpen]); + }, [isOpen, reset]); const recent = recents[0]; const recentsStatus = useMemo(() => { @@ -345,17 +314,17 @@ export default function CommandKClient({ // Years only accessible by search const years = useMemo(() => - _years.filter(({ year }) => queryLive && year.includes(queryLive)) - , [_years, queryLive]); + _years.filter(({ year }) => queryFormatted && year.includes(queryFormatted)) + , [_years, queryFormatted]); const tags = useMemo(() => { const tagsIncludingPrivate = photosCountHidden > 0 ? addPrivateToTags(_tags, photosCountHidden) : _tags; return HIDE_TAGS_WITH_ONE_PHOTO - ? limitTagsByCount(tagsIncludingPrivate, 2, queryLive) + ? limitTagsByCount(tagsIncludingPrivate, 2, queryFormatted) : tagsIncludingPrivate; - }, [_tags, photosCountHidden, queryLive]); + }, [_tags, photosCountHidden, queryFormatted]); const categorySections: CommandKSection[] = useMemo(() => CATEGORY_VISIBILITY @@ -751,9 +720,9 @@ export default function CommandKClient({ )}> { - setQueryLiveRaw(value); + setQuery(value); updateMask(); }} className={clsx( @@ -782,15 +751,15 @@ export default function CommandKClient({ 'text-gray-400/90 dark:text-gray-700', )} onClick={() => { - if (queryLiveRaw) { - setQueryLiveRaw(''); + if (query) { + setQuery(''); updateMask(); } else { setIsOpen?.(false); } }} > - {queryLiveRaw + {query ? : <> @@ -889,7 +858,7 @@ export default function CommandKClient({ />; })} )} - {footer && !queryLive && + {footer && !queryFormatted &&
+ query.trim().toLocaleLowerCase(); + +export default function usePhotoQuery( + query: string, + isEnabled = true, + minimumQueryLength = 2, +) { + const [isLoading, setIsLoading] = useState(false); + + const queryFormatted = useMemo(() => + formatQuery(query), [query]); + const [_queryDebounced] = useDebounce(query, 500, { leading: true }); + const queryDebounced = useMemo(() => + formatQuery(_queryDebounced), [_queryDebounced]); + + const [photos, setPhotos] = useState([]); + + const reset = useCallback(() => { + setPhotos([]); + setIsLoading(false); + }, []); + + useEffect(() => { + if (queryDebounced.length >= minimumQueryLength && isEnabled) { + setIsLoading(true); + searchPhotosAction(queryDebounced) + .then(setPhotos) + .finally(() => setIsLoading(false)); + } + }, [ + queryDebounced, + minimumQueryLength, + isEnabled, + ]); + + useEffect(() => { + if (queryFormatted.length >= minimumQueryLength) { + setIsLoading(true); + } else { + setPhotos([]); + setIsLoading(false); + } + }, [minimumQueryLength, queryFormatted]); + + return { + queryFormatted, + photos, + isLoading, + reset, + }; +}