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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -798,7 +798,7 @@ export const getPhotosCachedAction = async (
// Public actions
export const searchPhotosAction = async (query: string) =>
export const searchPhotosPublicAction = async (query: string) =>
getPhotos({ query, limit: 10 })
.catch(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 { altTextForPhoto, doesPhotoNeedBlurCompatibility, Photo } from '..';
import {
altTextForPhoto,
doesPhotoNeedBlurCompatibility,
INFINITE_SCROLL_GRID_MULTIPLE,
Photo,
} from '..';
import clsx from 'clsx/lite';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import ImageMedium from '@/components/image/ImageMedium';
import { menuSurfaceStyles } from '@/components/primitives/surface';
import { GRID_SPACE_CLASSNAME } from '@/components';
import useDynamicPhoto from '../useDynamicPhoto';
import { IoSearch } from 'react-icons/io5';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import usePhotoQuery from '../usePhotoQuery';
import Spinner from '@/components/Spinner';
import { BiChevronDown } from 'react-icons/bi';
import SegmentMenu from '@/components/SegmentMenu';
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 = ({
photo,
className,
onClick,
}: {
photo: Photo,
className?: string,
onClick?: () => void,
}) =>
const CLASSNAME_GRID = 'grid grid-cols-3 gap-0.5';
const renderPhoto = (photo: Photo) =>
<ImageMedium
src={photo.url}
alt={altTextForPhoto(photo)}
aspectRatio={photo.aspectRatio}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
{...{ className, onClick }}
/>;
export default function FieldsetPhotoChooser({
label,
value,
onChange,
photo,
photo: _photo,
photos = [],
photosCount,
}: {
label: string
value: string
onChange: (value: string) => void
onChange: (photoId: string) => void
photo?: Photo
photos?: Photo[]
photosCount?: number
photosHidden?: Photo[]
photos: Photo[]
photosCount: number
}) {
const [isOpen, setIsOpen] = useState(false);
const [photo, setPhoto] = useState(_photo);
const [mode, setMode] = useState<Mode>('all');
const showQuery = mode === 'search';
@ -59,42 +59,60 @@ export default function FieldsetPhotoChooser({
const [query, setQuery] = useState('');
const {
photos: photosQuery,
isLoading,
reset,
} = usePhotoQuery(query);
isLoading: isLoadingPhotoQuery,
reset: resetPhotoQuery,
} = usePhotoQuery({ query, isPrivate: true });
const {
photo: photoAvatar,
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
const reset = useCallback((resetMenu?: boolean) => {
resetPhotoQuery();
setQuery('');
}
}, [showQuery, reset]);
if (resetMenu) { setMode('all'); }
}, [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 (
<>
<FieldsetWithStatus {...{ label, value, onChange, type: 'hidden' }} />
<DropdownMenu.Root>
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
<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(
'w-full',
'flex items-center gap-1',
'font-sans',
'text-xs text-medium font-medium uppercase tracking-wider',
'py-1',
)}>
<span className="grow truncate text-left">
Avatar
{label}
</span>
<BiChevronDown size={18} />
</span>
@ -103,10 +121,7 @@ export default function FieldsetPhotoChooser({
'border border-medium rounded-[4px]',
'overflow-hidden select-none active:opacity-75',
)}>
{photoAvatar && renderPhoto({
photo: photoAvatar,
className: clsx(isLoadingPhotoAvatar && 'opacity-50'),
})}
{photo && renderPhoto(photo)}
</span>
</button>
</DropdownMenu.Trigger>
@ -115,12 +130,8 @@ export default function FieldsetPhotoChooser({
onCloseAutoFocus={e => e.preventDefault()}
align="start"
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
className="pt-1 pb-2 px-1.5"
items={[{
@ -129,45 +140,55 @@ export default function FieldsetPhotoChooser({
value: 'favs',
icon: <IconFavs size={16} />,
iconSelected: <IconFavs size={16} highlight />,
}, {
value: 'hidden',
icon: <IconLock size={15} />,
}, {
value: 'search',
icon: isLoading
? <Spinner />
: <IoSearch size={16} />,
icon: <IoSearch size={16} />,
isLoading: isLoadingPhotoQuery,
}]}
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
id="query"
ref={inputRef}
type="text"
placeholder="Search for a photo"
className="block w-full m-0"
className="block w-full m-0 border-none outline-none"
value={query}
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 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>
</DropdownMenu.Content>
</DropdownMenu.Portal>

View File

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