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';
export default async function ComponentsPage() {
const photos = await getPhotosCached();
const photos = await getPhotosCached({ limit: 50 });
const photosCount = await getPhotosMetaCached()
.then(({ count }) => count);

View File

@ -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<CommandKSection[]>([]);
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: <IconPhoto size={14} />,
items: photos.map(photo => ({
label: titleForPhoto(photo),
keywords: getKeywordsForPhoto(photo),
annotation: <PhotoDate {...{ photo, timezone: undefined }} />,
accessory: <PhotoSmall photo={photo} />,
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<CommandKSection[]>(() => {
if (isOpenRef.current && photos.length > 0) {
return [{
heading: 'Photos',
accessory: <IconPhoto size={14} />,
items: photos.map(photo => ({
label: titleForPhoto(photo),
keywords: getKeywordsForPhoto(photo),
annotation: <PhotoDate {...{ photo, timezone: undefined }} />,
accessory: <PhotoSmall photo={photo} />,
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({
)}>
<Command.Input
ref={refInput}
value={queryLiveRaw}
value={query}
onValueChange={value => {
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
? <IoClose size={17} className="text-dim" />
: <>
<span className="sm:hidden">
@ -889,7 +858,7 @@ export default function CommandKClient({
/>;
})}
</Command.Group>)}
{footer && !queryLive &&
{footer && !queryFormatted &&
<div className={clsx(
'text-center text-base text-dim pt-1',
'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,
};
}