Extract photo query logic to hook

This commit is contained in:
Sam Becker 2026-02-28 14:38:54 -06:00
parent 0465b51427
commit 7baedd0700
3 changed files with 98 additions and 71 deletions

View File

@ -2,7 +2,7 @@ import AdminComponentPageClient from '@/admin/AdminComponentPageClient';
import { getPhotosCached, getPhotosMetaCached } from '@/photo/cache'; import { getPhotosCached, getPhotosMetaCached } from '@/photo/cache';
export default async function ComponentsPage() { export default async function ComponentsPage() {
const photos = await getPhotosCached(); const photos = await getPhotosCached({ limit: 50 });
const photosCount = await getPhotosMetaCached() const photosCount = await getPhotosMetaCached()
.then(({ count }) => count); .then(({ count }) => count);

View File

@ -37,14 +37,12 @@ import {
} from '../app/path'; } from '../app/path';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { useDebounce } from 'use-debounce';
import Spinner from '../components/Spinner'; import Spinner from '../components/Spinner';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { BiDesktop, BiLockAlt, BiMoon, BiSun } from 'react-icons/bi'; import { BiDesktop, BiLockAlt, BiMoon, BiSun } from 'react-icons/bi';
import { IoClose, IoInvertModeSharp } from 'react-icons/io5'; import { IoClose, IoInvertModeSharp } from 'react-icons/io5';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import { searchPhotosAction } from '@/photo/actions';
import { RiToolsFill } from 'react-icons/ri'; import { RiToolsFill } from 'react-icons/ri';
import { signOutAction } from '@/auth/actions'; import { signOutAction } from '@/auth/actions';
import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
@ -98,12 +96,12 @@ import { getSortStateFromPath } from '@/photo/sort/path';
import IconSort from '@/components/icons/IconSort'; import IconSort from '@/components/icons/IconSort';
import { useSelectPhotosState } from '@/admin/select/SelectPhotosState'; import { useSelectPhotosState } from '@/admin/select/SelectPhotosState';
import IconAlbum from '@/components/icons/IconAlbum'; import IconAlbum from '@/components/icons/IconAlbum';
import usePhotoQuery from '@/photo/usePhotoQuery';
const DIALOG_TITLE = 'Global Command-K Menu'; const DIALOG_TITLE = 'Global Command-K Menu';
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings'; const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
const LISTENER_KEYDOWN = 'keydown'; const LISTENER_KEYDOWN = 'keydown';
const MINIMUM_QUERY_LENGTH = 2;
const MAX_HEIGHT = '20rem'; const MAX_HEIGHT = '20rem';
@ -246,19 +244,13 @@ export default function CommandKClient({
} }
}, [isWaiting, setIsOpen]); }, [isWaiting, setIsOpen]);
// Raw query values const [query, setQuery] = useState('');
const [queryLiveRaw, setQueryLiveRaw] = useState(''); const {
const [queryDebouncedRaw] = queryFormatted,
useDebounce(queryLiveRaw, 500, { trailing: true }); photos,
isLoading,
// Parameterized query values reset,
const queryLive = useMemo(() => } = usePhotoQuery(query, !isPending);
queryLiveRaw.trim().toLocaleLowerCase(), [queryLiveRaw]);
const queryDebounced = useMemo(() =>
queryDebouncedRaw.trim().toLocaleLowerCase(), [queryDebouncedRaw]);
const [isLoading, setIsLoading] = useState(false);
const [queriedSections, setQueriedSections] = useState<CommandKSection[]>([]);
const { setTheme } = useTheme(); const { setTheme } = useTheme();
@ -283,55 +275,32 @@ export default function CommandKClient({
return () => document.removeEventListener(LISTENER_KEYDOWN, down); return () => document.removeEventListener(LISTENER_KEYDOWN, down);
}, [setIsOpen]); }, [setIsOpen]);
useEffect(() => { const queriedSections = useMemo<CommandKSection[]>(() => {
if (queryDebounced.length >= MINIMUM_QUERY_LENGTH && !isPending) { if (isOpenRef.current && photos.length > 0) {
setIsLoading(true); return [{
searchPhotosAction(queryDebounced) heading: 'Photos',
.then(photos => { accessory: <IconPhoto size={14} />,
if (isOpenRef.current) { items: photos.map(photo => ({
setQueriedSections(photos.length > 0 label: titleForPhoto(photo),
? [{ keywords: getKeywordsForPhoto(photo),
heading: 'Photos', annotation: <PhotoDate {...{ photo, timezone: undefined }} />,
accessory: <IconPhoto size={14} />, accessory: <PhotoSmall photo={photo} />,
items: photos.map(photo => ({ path: pathForPhoto({ photo }),
label: titleForPhoto(photo), })),
keywords: getKeywordsForPhoto(photo), }];
annotation: <PhotoDate {...{ photo, timezone: undefined }} />, } else {
accessory: <PhotoSmall photo={photo} />, return [];
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);
});
} }
}, [queryDebounced, isPending, appText]); },
[photos],
useEffect(() => { );
if (queryLive === '') {
setQueriedSections([]);
setIsLoading(false);
} else if (queryLive.length >= MINIMUM_QUERY_LENGTH) {
setIsLoading(true);
}
}, [queryLive]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setQueryLiveRaw(''); setQuery('');
setQueriedSections([]); reset();
setIsLoading(false);
} }
}, [isOpen]); }, [isOpen, reset]);
const recent = recents[0]; const recent = recents[0];
const recentsStatus = useMemo(() => { const recentsStatus = useMemo(() => {
@ -345,17 +314,17 @@ export default function CommandKClient({
// Years only accessible by search // Years only accessible by search
const years = useMemo(() => const years = useMemo(() =>
_years.filter(({ year }) => queryLive && year.includes(queryLive)) _years.filter(({ year }) => queryFormatted && year.includes(queryFormatted))
, [_years, queryLive]); , [_years, queryFormatted]);
const tags = useMemo(() => { const tags = useMemo(() => {
const tagsIncludingPrivate = photosCountHidden > 0 const tagsIncludingPrivate = photosCountHidden > 0
? addPrivateToTags(_tags, photosCountHidden) ? addPrivateToTags(_tags, photosCountHidden)
: _tags; : _tags;
return HIDE_TAGS_WITH_ONE_PHOTO return HIDE_TAGS_WITH_ONE_PHOTO
? limitTagsByCount(tagsIncludingPrivate, 2, queryLive) ? limitTagsByCount(tagsIncludingPrivate, 2, queryFormatted)
: tagsIncludingPrivate; : tagsIncludingPrivate;
}, [_tags, photosCountHidden, queryLive]); }, [_tags, photosCountHidden, queryFormatted]);
const categorySections: CommandKSection[] = useMemo(() => const categorySections: CommandKSection[] = useMemo(() =>
CATEGORY_VISIBILITY CATEGORY_VISIBILITY
@ -751,9 +720,9 @@ export default function CommandKClient({
)}> )}>
<Command.Input <Command.Input
ref={refInput} ref={refInput}
value={queryLiveRaw} value={query}
onValueChange={value => { onValueChange={value => {
setQueryLiveRaw(value); setQuery(value);
updateMask(); updateMask();
}} }}
className={clsx( className={clsx(
@ -782,15 +751,15 @@ export default function CommandKClient({
'text-gray-400/90 dark:text-gray-700', 'text-gray-400/90 dark:text-gray-700',
)} )}
onClick={() => { onClick={() => {
if (queryLiveRaw) { if (query) {
setQueryLiveRaw(''); setQuery('');
updateMask(); updateMask();
} else { } else {
setIsOpen?.(false); setIsOpen?.(false);
} }
}} }}
> >
{queryLiveRaw {query
? <IoClose size={17} className="text-dim" /> ? <IoClose size={17} className="text-dim" />
: <> : <>
<span className="sm:hidden"> <span className="sm:hidden">
@ -889,7 +858,7 @@ export default function CommandKClient({
/>; />;
})} })}
</Command.Group>)} </Command.Group>)}
{footer && !queryLive && {footer && !queryFormatted &&
<div className={clsx( <div className={clsx(
'text-center text-base text-dim pt-1', 'text-center text-base text-dim pt-1',
'pb-2', 'pb-2',

View File

@ -0,0 +1,58 @@
/* eslint-disable react-hooks/set-state-in-effect */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Photo } from '.';
import { useDebounce } from 'use-debounce';
import { searchPhotosAction } from './actions';
const formatQuery = (query: string) =>
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<Photo[]>([]);
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,
};
}