Refactor core photo chooser behavior

This commit is contained in:
Sam Becker 2026-03-01 13:23:25 -06:00
parent 7182e6db0e
commit 38f724762e
9 changed files with 154 additions and 120 deletions

View File

@ -1,8 +1,9 @@
import AdminComponentPageClient from '@/admin/AdminComponentPageClient'; import AdminComponentPageClient from '@/admin/AdminComponentPageClient';
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
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({ limit: 50 }); const photos = await getPhotosCached({ limit: INFINITE_SCROLL_GRID_INITIAL });
const photosCount = await getPhotosMetaCached() const photosCount = await getPhotosMetaCached()
.then(({ count }) => count); .then(({ count }) => count);

View File

@ -22,14 +22,12 @@ export default function AdminAboutEditPage({
photoHero: _photoHero, photoHero: _photoHero,
photos, photos,
photosCount, photosCount,
photosHidden,
}: { }: {
about?: About about?: About
photoAvatar?: Photo photoAvatar?: Photo
photoHero?: Photo photoHero?: Photo
photos?: Photo[] photos: Photo[]
photosCount?: number photosCount: number
photosHidden?: Photo[]
shouldResizeImages?: boolean shouldResizeImages?: boolean
}) { }) {
const appText = useAppText(); const appText = useAppText();
@ -73,7 +71,6 @@ export default function AdminAboutEditPage({
photo={photoAvatar} photo={photoAvatar}
photos={photos} photos={photos}
photosCount={photosCount} photosCount={photosCount}
photosHidden={photosHidden}
/> />
<PhotoAvatar photo={photoAvatar} /> <PhotoAvatar photo={photoAvatar} />
<FieldsetWithStatus <FieldsetWithStatus

View File

@ -13,7 +13,7 @@ import { Photo } from '@/photo';
import FieldsetPhotoQuery from '@/photo/form/FieldsetPhotoQuery'; import FieldsetPhotoQuery from '@/photo/form/FieldsetPhotoQuery';
import FieldsetPhotoChooser from '@/photo/form/FieldsetPhotoChooser'; import FieldsetPhotoChooser from '@/photo/form/FieldsetPhotoChooser';
export default function ComponentsPageClient({ export default function AdminComponentPageClient({
photo, photo,
photos, photos,
photosCount, photosCount,

View File

@ -250,7 +250,7 @@ export default function CommandKClient({
photos, photos,
isLoading, isLoading,
reset, reset,
} = usePhotoQuery(query, !isPending); } = usePhotoQuery({ query, isEnabled: !isPending });
const { setTheme } = useTheme(); const { setTheme } = useTheme();

View File

@ -1,5 +1,6 @@
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import Spinner from './Spinner';
export default function SegmentMenu<T extends string>({ export default function SegmentMenu<T extends string>({
items, items,
@ -11,6 +12,7 @@ export default function SegmentMenu<T extends string>({
value: T value: T
icon?: ReactNode icon?: ReactNode
iconSelected?: ReactNode iconSelected?: ReactNode
isLoading?: boolean
}[] }[]
selected: T selected: T
onChange: (value: T) => void onChange: (value: T) => void
@ -21,7 +23,7 @@ export default function SegmentMenu<T extends string>({
'flex justify-center gap-1', 'flex justify-center gap-1',
className, className,
)}> )}>
{items.map(({ value, icon, iconSelected }) => ( {items.map(({ value, icon, iconSelected, isLoading }) => (
<button <button
key={value} key={value}
onClick={() => onChange(value)} onClick={() => onChange(value)}
@ -36,7 +38,9 @@ export default function SegmentMenu<T extends string>({
'active:bg-extra-dim', 'active:bg-extra-dim',
)} )}
> >
{icon {isLoading
? <Spinner />
: icon
? selected === value && iconSelected ? selected === value && iconSelected
? iconSelected ? iconSelected
: icon : icon

View File

@ -38,6 +38,7 @@ export default function InfinitePhotoScroll({
recipe, recipe,
film, film,
focal, focal,
moreButtonClassName = 'mt-4',
wrapMoreButtonInGrid, wrapMoreButtonInGrid,
useCachedPhotos = true, useCachedPhotos = true,
includeHiddenPhotos, includeHiddenPhotos,
@ -49,6 +50,7 @@ export default function InfinitePhotoScroll({
sortWithPriority?: boolean sortWithPriority?: boolean
excludeFromFeeds?: boolean excludeFromFeeds?: boolean
cacheKey: string cacheKey: string
moreButtonClassName?: string
wrapMoreButtonInGrid?: boolean wrapMoreButtonInGrid?: boolean
useCachedPhotos?: boolean useCachedPhotos?: boolean
includeHiddenPhotos?: boolean includeHiddenPhotos?: boolean
@ -178,7 +180,7 @@ export default function InfinitePhotoScroll({
revalidatePhoto, revalidatePhoto,
}) })
))} ))}
{!isFinished && <div className="mt-4"> {!isFinished && <div className={moreButtonClassName}>
{wrapMoreButtonInGrid {wrapMoreButtonInGrid
? <AppGrid contentMain={renderMoreButton} /> ? <AppGrid contentMain={renderMoreButton} />
: renderMoreButton} : renderMoreButton}

View File

@ -798,7 +798,7 @@ export const getPhotosCachedAction = async (
// Public actions // Public actions
export const searchPhotosAction = async (query: string) => export const searchPhotosPublicAction = async (query: string) =>
getPhotos({ query, limit: 10 }) getPhotos({ query, limit: 10 })
.catch(e => { .catch(e => {
console.error('Could not query photos', e); console.error('Could not query photos', e);

View File

@ -1,55 +1,55 @@
/* eslint-disable react-hooks/set-state-in-effect */
import FieldsetWithStatus from '@/components/FieldsetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { altTextForPhoto, doesPhotoNeedBlurCompatibility, Photo } from '..'; import {
altTextForPhoto,
doesPhotoNeedBlurCompatibility,
INFINITE_SCROLL_GRID_MULTIPLE,
Photo,
} from '..';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import ImageMedium from '@/components/image/ImageMedium'; import ImageMedium from '@/components/image/ImageMedium';
import { menuSurfaceStyles } from '@/components/primitives/surface'; import { menuSurfaceStyles } from '@/components/primitives/surface';
import { GRID_SPACE_CLASSNAME } from '@/components';
import useDynamicPhoto from '../useDynamicPhoto';
import { IoSearch } from 'react-icons/io5'; import { IoSearch } from 'react-icons/io5';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import usePhotoQuery from '../usePhotoQuery'; import usePhotoQuery from '../usePhotoQuery';
import Spinner from '@/components/Spinner';
import { BiChevronDown } from 'react-icons/bi'; import { BiChevronDown } from 'react-icons/bi';
import SegmentMenu from '@/components/SegmentMenu'; import SegmentMenu from '@/components/SegmentMenu';
import IconFavs from '@/components/icons/IconFavs'; import IconFavs from '@/components/icons/IconFavs';
import IconLock from '@/components/icons/IconLock'; import InfinitePhotoScroll from '../InfinitePhotoScroll';
type Mode = 'all' | 'favs' | 'hidden' | 'search'; type Mode = 'all' | 'favs' | 'search';
const renderPhoto = ({ const CLASSNAME_GRID = 'grid grid-cols-3 gap-0.5';
photo,
className, const renderPhoto = (photo: Photo) =>
onClick,
}: {
photo: Photo,
className?: string,
onClick?: () => void,
}) =>
<ImageMedium <ImageMedium
src={photo.url} src={photo.url}
alt={altTextForPhoto(photo)} alt={altTextForPhoto(photo)}
aspectRatio={photo.aspectRatio} aspectRatio={photo.aspectRatio}
blurDataURL={photo.blurData} blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)} blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
{...{ className, onClick }}
/>; />;
export default function FieldsetPhotoChooser({ export default function FieldsetPhotoChooser({
label, label,
value, value,
onChange, onChange,
photo, photo: _photo,
photos = [], photos = [],
photosCount,
}: { }: {
label: string label: string
value: string value: string
onChange: (value: string) => void onChange: (photoId: string) => void
photo?: Photo photo?: Photo
photos?: Photo[] photos: Photo[]
photosCount?: number photosCount: number
photosHidden?: Photo[]
}) { }) {
const [isOpen, setIsOpen] = useState(false);
const [photo, setPhoto] = useState(_photo);
const [mode, setMode] = useState<Mode>('all'); const [mode, setMode] = useState<Mode>('all');
const showQuery = mode === 'search'; const showQuery = mode === 'search';
@ -59,42 +59,60 @@ export default function FieldsetPhotoChooser({
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const { const {
photos: photosQuery, photos: photosQuery,
isLoading, isLoading: isLoadingPhotoQuery,
reset, reset: resetPhotoQuery,
} = usePhotoQuery(query); } = usePhotoQuery({ query, isPrivate: true });
const { const reset = useCallback((resetMenu?: boolean) => {
photo: photoAvatar, resetPhotoQuery();
isLoading: isLoadingPhotoAvatar,
} = useDynamicPhoto({
initialPhoto: photo,
photoId: value,
});
useEffect(() => {
if (showQuery) {
inputRef.current?.focus();
} else {
reset();
// eslint-disable-next-line react-hooks/set-state-in-effect
setQuery(''); setQuery('');
} if (resetMenu) { setMode('all'); }
}, [showQuery, reset]); }, [resetPhotoQuery]);
// Focus input on query mode
useEffect(() => {
if (showQuery) { inputRef.current?.focus(); }
}, [showQuery]);
// Reset menu when closed
useEffect(() => {
if (!isOpen) { reset(true); }
}, [isOpen, reset]);
const renderPhotoButton = (photo: Photo) =>
<span
key={photo.id}
className={clsx(
'flex w-full aspect-square object-cover',
'overflow-hidden select-none active:opacity-75',
'cursor-pointer',
)}
onClick={() => {
setPhoto(photo);
onChange(photo.id);
setIsOpen(false);
}}
>
{renderPhoto(photo)}
</span>;
return ( return (
<> <>
<FieldsetWithStatus {...{ label, value, onChange, type: 'hidden' }} /> <FieldsetWithStatus {...{ label, value, onChange, type: 'hidden' }} />
<DropdownMenu.Root> <DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<button type="button" className="inline-flex flex-col p-1.5"> <button type="button" className={clsx(
'inline-flex flex-col p-1.5 pt-0 gap-0',
)}>
<span className={clsx( <span className={clsx(
'w-full', 'w-full',
'flex items-center gap-1', 'flex items-center gap-1',
'font-sans', 'font-sans',
'text-xs text-medium font-medium uppercase tracking-wider', 'text-xs text-medium font-medium uppercase tracking-wider',
'py-1',
)}> )}>
<span className="grow truncate text-left"> <span className="grow truncate text-left">
Avatar {label}
</span> </span>
<BiChevronDown size={18} /> <BiChevronDown size={18} />
</span> </span>
@ -103,10 +121,7 @@ export default function FieldsetPhotoChooser({
'border border-medium rounded-[4px]', 'border border-medium rounded-[4px]',
'overflow-hidden select-none active:opacity-75', 'overflow-hidden select-none active:opacity-75',
)}> )}>
{photoAvatar && renderPhoto({ {photo && renderPhoto(photo)}
photo: photoAvatar,
className: clsx(isLoadingPhotoAvatar && 'opacity-50'),
})}
</span> </span>
</button> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@ -115,12 +130,8 @@ export default function FieldsetPhotoChooser({
onCloseAutoFocus={e => e.preventDefault()} onCloseAutoFocus={e => e.preventDefault()}
align="start" align="start"
sideOffset={10} sideOffset={10}
className={menuSurfaceStyles('z-20 px-1.5 py-1.5 rounded-2xl')} className={menuSurfaceStyles('z-20 rounded-2xl pb-0 overflow-auto')}
> >
<div className={clsx(
GRID_SPACE_CLASSNAME,
'w-[18rem] max-h-[20rem] rounded-xl overflow-y-auto',
)}>
<SegmentMenu <SegmentMenu
className="pt-1 pb-2 px-1.5" className="pt-1 pb-2 px-1.5"
items={[{ items={[{
@ -129,45 +140,55 @@ export default function FieldsetPhotoChooser({
value: 'favs', value: 'favs',
icon: <IconFavs size={16} />, icon: <IconFavs size={16} />,
iconSelected: <IconFavs size={16} highlight />, iconSelected: <IconFavs size={16} highlight />,
}, {
value: 'hidden',
icon: <IconLock size={15} />,
}, { }, {
value: 'search', value: 'search',
icon: isLoading icon: <IoSearch size={16} />,
? <Spinner /> isLoading: isLoadingPhotoQuery,
: <IoSearch size={16} />,
}]} }]}
selected={mode} selected={mode}
onChange={setMode} onChange={mode => {
setMode(mode);
if (mode !== 'search') {
reset();
}
}}
/> />
{showQuery && <div className={clsx(
'border-t border-medium',
'p-1',
!showQuery && 'hidden',
)}>
<input <input
id="query"
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder="Search for a photo" placeholder="Search for a photo"
className="block w-full m-0" className="block w-full m-0 border-none outline-none"
value={query} value={query}
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
/>} />
<div className={clsx(
'grid grid-cols-3 gap-0.5',
)}>
{(showQuery && query ? photosQuery : photos).map(photo => (
<span
key={photo.id}
className={clsx(
'flex w-full aspect-square object-cover',
'overflow-hidden select-none active:opacity-75',
)}
>
{renderPhoto({
photo,
onClick: () => onChange(photo.id),
})}
</span>
))}
</div> </div>
<div className={clsx(
'w-[18rem] max-h-[20rem] overflow-y-auto',
'space-y-0.5',
)}>
<div className={CLASSNAME_GRID}>
{(showQuery && query ? photosQuery : photos)
.map(photo => renderPhotoButton(photo))}
</div>
{!(showQuery && query) && photosCount > photos.length &&
<InfinitePhotoScroll
cacheKey="photo-chooser"
initialOffset={photos.length}
itemsPerPage={INFINITE_SCROLL_GRID_MULTIPLE}
moreButtonClassName="mt-2"
>
{({ key, photos }) => (
<div key={key} className={CLASSNAME_GRID}>
{photos.map(photo => renderPhotoButton(photo))}
</div>
)}
</InfinitePhotoScroll>}
</div> </div>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>

View File

@ -2,16 +2,22 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Photo } from '.'; import { Photo } from '.';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { searchPhotosAction } from './actions'; import { getPhotosAction, searchPhotosPublicAction } from './actions';
const formatQuery = (query: string) => const formatQuery = (query: string) =>
query.trim().toLocaleLowerCase(); query.trim().toLocaleLowerCase();
export default function usePhotoQuery( export default function usePhotoQuery({
query: string, query,
isEnabled = true, isEnabled = true,
minimumQueryLength = 2, minimumQueryLength = 2,
) { isPrivate,
}: {
query: string
isEnabled?: boolean
minimumQueryLength?: number
isPrivate?: boolean
}) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const queryFormatted = useMemo(() => const queryFormatted = useMemo(() =>
@ -30,7 +36,9 @@ export default function usePhotoQuery(
useEffect(() => { useEffect(() => {
if (queryDebounced.length >= minimumQueryLength && isEnabled) { if (queryDebounced.length >= minimumQueryLength && isEnabled) {
setIsLoading(true); setIsLoading(true);
searchPhotosAction(queryDebounced) (isPrivate
? getPhotosAction({ query: queryDebounced })
: searchPhotosPublicAction(queryDebounced))
.then(setPhotos) .then(setPhotos)
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
} }
@ -38,6 +46,7 @@ export default function usePhotoQuery(
queryDebounced, queryDebounced,
minimumQueryLength, minimumQueryLength,
isEnabled, isEnabled,
isPrivate,
]); ]);
useEffect(() => { useEffect(() => {