Photo Chooser (#383)

* Refactor photo/menu form components

* Fix pre-rendering error

* Incorporate server-side photo chooser data

* Create custom photo chooser grid

* Extract photo query logic to hook

* Make photo chooser searchable

* Create custom photo chooser menu

* Animate query menu, add favs to chooser

* Add photo chooser empty states
This commit is contained in:
Sam Becker 2026-03-01 20:55:46 -06:00 committed by GitHub
parent 741bcf32f7
commit 5940bee86a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 556 additions and 282 deletions

View File

@ -1,26 +1,70 @@
import AdminAboutEditPage from '@/about/AdminAboutEditPage';
import { getAbout } from '@/about/query';
import { PRESERVE_ORIGINAL_UPLOADS } from '@/app/config';
import { getPhotoNoStore } from '@/photo/cache';
import { feedQueryOptions } from '@/feed';
import {
getPhotosCached,
getPhotosMetaCached,
} from '@/photo/cache';
import { getPhoto } from '@/photo/query';
import { TAG_FAVS } from '@/tag';
const PHOTO_CHOOSER_QUERY_OPTIONS = feedQueryOptions({
isGrid: true,
excludeFromFeeds: false,
});
export default async function AboutEditPage() {
const about = await getAbout().catch(() => undefined);
const [
{
about,
photoAvatar,
photoHero,
},
photos,
photosCount,
photosFavs,
] = await Promise.all([
getAbout()
.then(async about => {
const photoAvatar = about?.photoIdAvatar
? await getPhoto(about?.photoIdAvatar ?? '', true)
.catch(() => undefined)
: undefined;
const photoAvatar = about?.photoIdAvatar
? await getPhotoNoStore(about?.photoIdAvatar ?? '', true)
.catch(() => undefined)
: undefined;
const photoHero = about?.photoIdHero
? await getPhoto(about?.photoIdHero ?? '', true)
.catch(() => undefined)
: undefined;
const photoHero = about?.photoIdHero
? await getPhotoNoStore(about?.photoIdHero ?? '', true)
.catch(() => undefined)
: undefined;
return {
about,
photoAvatar,
photoHero,
};
})
.catch(() => ({
about: undefined,
photoAvatar: undefined,
photoHero: undefined,
})),
getPhotosCached(PHOTO_CHOOSER_QUERY_OPTIONS)
.catch(() => []),
getPhotosMetaCached(PHOTO_CHOOSER_QUERY_OPTIONS)
.then(({ count }) => count)
.catch(() => 0),
getPhotosCached({ tag: TAG_FAVS })
.catch(() => []),
]);
return (
<AdminAboutEditPage {...{
about,
photoAvatar,
photoHero,
photos,
photosCount,
photosFavs,
shouldResizeImages: !PRESERVE_ORIGINAL_UPLOADS,
}} />
);

View File

@ -1,10 +1,20 @@
import AdminComponentPageClient from '@/admin/AdminComponentPageClient';
import { getPhotosCached } from '@/photo/cache';
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getPhotosCached, getPhotosMetaCached } from '@/photo/cache';
import { TAG_FAVS } from '@/tag';
export default async function ComponentsPage() {
const photos = await getPhotosCached({ limit: 1});
const photos = await getPhotosCached({ limit: INFINITE_SCROLL_GRID_INITIAL });
const photosCount = await getPhotosMetaCached()
.then(({ count }) => count);
const photosFavs = await getPhotosCached({ tag: TAG_FAVS });
return (
<AdminComponentPageClient photo={photos[0]} />
<AdminComponentPageClient
photo={photos[0]}
photos={photos}
photosCount={photosCount}
photosFavs={photosFavs}
/>
);
}

View File

@ -8,12 +8,12 @@ import { getPhotosMetaCached } from '@/photo/cache';
import { SortProps } from '@/photo/sort';
import { getSortOptionsFromParams } from '@/photo/sort/path';
import { PhotoQueryOptions } from '@/db';
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
import { FEED_META_QUERY_OPTIONS, feedQueryOptions } from '@/feed';
export const maxDuration = 60;
const getPhotosCached = cache((options: PhotoQueryOptions) =>
getPhotos(getFeedQueryOptions({
getPhotos(feedQueryOptions({
isGrid: false,
...options,
})));

View File

@ -6,12 +6,12 @@ import { getPhotos } from '@/photo/query';
import PhotoFullPage from '@/photo/PhotoFullPage';
import { getPhotosMetaCached } from '@/photo/cache';
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
import { FEED_META_QUERY_OPTIONS, feedQueryOptions } from '@/feed';
export const dynamic = 'force-static';
export const maxDuration = 60;
const getPhotosCached = cache(() => getPhotos(getFeedQueryOptions({
const getPhotosCached = cache(() => getPhotos(feedQueryOptions({
isGrid: false,
})));

View File

@ -8,13 +8,13 @@ import { getDataForCategoriesCached } from '@/category/cache';
import { getPhotosMetaCached } from '@/photo/cache';
import { SortProps } from '@/photo/sort';
import { getSortOptionsFromParams } from '@/photo/sort/path';
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
import { FEED_META_QUERY_OPTIONS, feedQueryOptions } from '@/feed';
import { PhotoQueryOptions } from '@/db';
export const maxDuration = 60;
const getPhotosCached = cache((options: PhotoQueryOptions) =>
getPhotos(getFeedQueryOptions({
getPhotos(feedQueryOptions({
isGrid: true,
...options,
})));

View File

@ -7,12 +7,12 @@ import PhotoGridPage from '@/photo/PhotoGridPage';
import { getDataForCategoriesCached } from '@/category/cache';
import { getPhotosMetaCached } from '@/photo/cache';
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
import { FEED_META_QUERY_OPTIONS, feedQueryOptions } from '@/feed';
export const dynamic = 'force-static';
export const maxDuration = 60;
const getPhotosCached = cache(() => getPhotos(getFeedQueryOptions({
const getPhotosCached = cache(() => getPhotos(feedQueryOptions({
isGrid: true,
})));

View File

@ -9,12 +9,12 @@ import PhotoFullPage from '@/photo/PhotoFullPage';
import PhotoGridPage from '@/photo/PhotoGridPage';
import { getDataForCategoriesCached } from '@/category/cache';
import { getPhotosMetaCached } from '@/photo/cache';
import { FEED_META_QUERY_OPTIONS, getFeedQueryOptions } from '@/feed';
import { FEED_META_QUERY_OPTIONS, feedQueryOptions } from '@/feed';
export const dynamic = 'force-static';
export const maxDuration = 60;
const getPhotosCached = cache(() => getPhotos(getFeedQueryOptions({
const getPhotosCached = cache(() => getPhotos(feedQueryOptions({
isGrid: GRID_HOMEPAGE_ENABLED,
})));

View File

@ -14,15 +14,22 @@ import PhotoMedium from '@/photo/PhotoMedium';
import clsx from 'clsx/lite';
import useDynamicPhoto from '@/photo/useDynamicPhoto';
import { useAppText } from '@/i18n/state/client';
import FieldsetPhotoChooser from '@/photo/form/FieldsetPhotoChooser';
export default function AdminAboutEditPage({
about,
photoAvatar: _photoAvatar,
photoHero: _photoHero,
photos,
photosCount,
photosFavs,
}: {
about?: About
photoAvatar?: Photo
photoHero?: Photo
photos: Photo[]
photosCount: number
photosFavs: Photo[]
shouldResizeImages?: boolean
}) {
const appText = useAppText();
@ -58,6 +65,16 @@ export default function AdminAboutEditPage({
action={updateAboutAction}
>
<div className="space-y-4">
<FieldsetPhotoChooser
label="Avatar Photo"
value={aboutForm?.photoIdAvatar ?? ''}
onChange={photoIdAvatar => setAboutForm(form =>
({ ...form, photoIdAvatar }))}
photo={photoAvatar}
photos={photos}
photosCount={photosCount}
photosFavs={photosFavs}
/>
<PhotoAvatar photo={photoAvatar} />
<FieldsetWithStatus
id="photoIdAvatar"

View File

@ -10,16 +10,20 @@ import StatusIcon from '@/components/StatusIcon';
import clsx from 'clsx/lite';
import { useState } from 'react';
import { Photo } from '@/photo';
import FieldsetPhotoQuery from '@/photo/FieldsetPhotoQuery';
import FieldsetPhotoChooser from '@/photo/FieldsetPhotoChooser';
import FieldsetPhotoChooser from '@/photo/form/FieldsetPhotoChooser';
export default function ComponentsPageClient({
export default function AdminComponentPageClient({
photo,
photos,
photosCount,
photosFavs,
}: {
photo: Photo
photos: Photo[]
photosCount: number
photosFavs: Photo[]
}) {
const [valuePhoto, setValuePhoto] = useState(photo?.id ?? '');
const [valuePhotoChooser, setValuePhotoChooser] = useState(photo?.id ?? '');
const [value, setValue] = useState('visible');
@ -39,14 +43,9 @@ export default function ComponentsPageClient({
<FieldsetPhotoChooser
label="Photo"
photo={photo}
value={valuePhotoChooser}
onChange={setValuePhotoChooser}
/>
</div>
<div className="z-13">
<FieldsetPhotoQuery
label="Photo"
photos={[photo]}
photos={photos}
photosCount={photosCount}
photosFavs={photosFavs}
value={valuePhoto}
onChange={setValuePhoto}
/>

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, isEnabled: !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,56 @@
import clsx from 'clsx/lite';
import { ReactNode } from 'react';
import Spinner from './Spinner';
export default function SegmentMenu<T extends string>({
items,
selected,
onChange,
className,
}: {
items: {
value: T
icon?: ReactNode
iconSelected?: ReactNode
isLoading?: boolean
}[]
selected: T
onChange: (value: T) => void
className?: string
}) {
return (
<div className={clsx(
'flex justify-center gap-1',
className,
)}>
{items.map(({ value, icon, iconSelected, isLoading }) => (
<button
key={value}
onClick={() => onChange(value)}
className={clsx(
'link',
'rounded-full',
value === selected
? 'bg-dim text-main'
: 'bg-transparent text-medium',
'flex items-center justify-center',
'h-7 min-w-14',
'active:bg-extra-dim',
)}
>
{isLoading
? <Spinner />
: icon
? selected === value && iconSelected
? iconSelected
: icon
: <span className={clsx(
'text-sm font-medium uppercase tracking-wider',
)}>
{value}
</span>}
</button>
))}
</div>
);
}

View File

@ -11,21 +11,7 @@ import { FiMoreHorizontal } from 'react-icons/fi';
import MoreMenuItem from './MoreMenuItem';
import { clearGlobalFocus } from '@/utility/dom';
import { FaChevronRight } from 'react-icons/fa6';
const surfaceStyles = (className?: string) => clsx(
'z-10',
'min-w-[8rem]',
'component-surface',
'py-1',
'not-dark:shadow-lg not-dark:shadow-gray-900/10',
'data-[side=top]:dark:shadow-[0_0px_40px_rgba(0,0,0,0.6)]',
'data-[side=bottom]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
'data-[side=right]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
'data-[side=top]:animate-fade-in-from-bottom',
'data-[side=bottom]:animate-fade-in-from-top',
'data-[side=right]:animate-fade-in-from-top',
className,
);
import { MENU_SURFACE_STYLES } from '../primitives/surface';
export type MoreMenuSection = {
label?: string
@ -115,7 +101,10 @@ export default function MoreMenu({
onCloseAutoFocus={e => e.preventDefault()}
align={align}
sideOffset={sideOffset}
className={surfaceStyles(className)}
className={clsx(
MENU_SURFACE_STYLES,
className,
)}
>
{header && <div className={clsx(
'px-3 pt-3 pb-2 text-dim uppercase',
@ -171,7 +160,7 @@ export default function MoreMenu({
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent
className={surfaceStyles()}
className={MENU_SURFACE_STYLES}
>
{item.items.map(item =>
<div key={item.label} className="px-1">

View File

@ -2,7 +2,7 @@
import { ReactNode, useRef, useState, ComponentProps } from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';
import MenuSurface from './MenuSurface';
import ComponentSurface from './surface/ComponentSurface';
import clsx from 'clsx/lite';
import useClickInsideOutside from '@/utility/useClickInsideOutside';
import KeyCommand from './KeyCommand';
@ -31,7 +31,7 @@ export default function TooltipPrimitive({
children: ReactNode
className?: string
classNameTrigger?: string
color?: ComponentProps<typeof MenuSurface>['color']
color?: ComponentProps<typeof ComponentSurface>['color']
keyCommand?: string
keyCommandModifier?: ComponentProps<typeof KeyCommand>['modifier']
supportMobile?: boolean
@ -126,9 +126,9 @@ export default function TooltipPrimitive({
)}
>
{content &&
<MenuSurface {...{ color, className }}>
<ComponentSurface {...{ color, className }}>
{content}
</MenuSurface>}
</ComponentSurface>}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>

View File

@ -1,7 +1,7 @@
import { ReactNode, RefObject } from 'react';
import clsx from 'clsx/lite';
export default function MenuSurface({
export default function ComponentSurface({
ref,
children,
className,

View File

@ -0,0 +1,15 @@
import clsx from 'clsx/lite';
export const MENU_SURFACE_STYLES = clsx(
'z-10',
'min-w-[8rem]',
'component-surface',
'py-1',
'not-dark:shadow-lg not-dark:shadow-gray-900/10',
'data-[side=top]:dark:shadow-[0_0px_40px_rgba(0,0,0,0.6)]',
'data-[side=bottom]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
'data-[side=right]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
'data-[side=top]:animate-fade-in-from-bottom',
'data-[side=bottom]:animate-fade-in-from-top',
'data-[side=right]:animate-fade-in-from-top',
);

View File

@ -10,7 +10,7 @@ import {
} from 'react';
import { SharedHoverContext, SharedHoverProps } from './state';
import { AnimatePresence, motion } from 'framer-motion';
import MenuSurface from '../primitives/MenuSurface';
import ComponentSurface from '../primitives/surface/ComponentSurface';
import clsx from 'clsx/lite';
const WINDOW_CHANGE_EVENTS = ['mouseup', 'mousewheel', 'resize'];
@ -133,7 +133,7 @@ export default function SharedHoverProvider({
className="fixed"
style={hoverStyle}
>
<MenuSurface
<ComponentSurface
className="max-w-none p-1!"
color={hoverProps.color}
>
@ -158,7 +158,7 @@ export default function SharedHoverProvider({
: 'border-medium',
)} />
</div>
</MenuSurface>
</ComponentSurface>
</motion.div>}
</AnimatePresence>
</div>

View File

@ -6,7 +6,7 @@ import {
SetStateAction,
use,
} from 'react';
import MenuSurface from '../primitives/MenuSurface';
import ComponentSurface from '../primitives/surface/ComponentSurface';
export type SharedHoverProps = {
key: string
@ -14,7 +14,7 @@ export type SharedHoverProps = {
height: number
offsetAbove: number
offsetBelow: number
color?: ComponentProps<typeof MenuSurface>['color']
color?: ComponentProps<typeof ComponentSurface>['color']
}
export type SharedHoverState = {

View File

@ -13,21 +13,23 @@ const FEED_BASE_QUERY_OPTIONS: PhotoQueryOptions = {
// PAGE FEED QUERY OPTIONS
export const getFeedQueryOptions = ({
export const feedQueryOptions = ({
isGrid,
sortBy = USER_DEFAULT_SORT_OPTIONS.sortBy,
sortWithPriority = USER_DEFAULT_SORT_OPTIONS.sortWithPriority,
...options
}: {
isGrid: boolean,
sortBy?: SortBy,
sortWithPriority?: boolean,
}): PhotoQueryOptions => ({
} & PhotoQueryOptions): PhotoQueryOptions => ({
...FEED_BASE_QUERY_OPTIONS,
sortBy,
sortWithPriority,
limit: isGrid
? INFINITE_SCROLL_GRID_INITIAL
: INFINITE_SCROLL_FULL_INITIAL,
...options,
});
export const FEED_META_QUERY_OPTIONS: PhotoQueryOptions = {

View File

@ -1,76 +0,0 @@
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { altTextForPhoto, doesPhotoNeedBlurCompatibility, Photo } from '.';
import clsx from 'clsx/lite';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import ImageMedium from '@/components/image/ImageMedium';
import PhotoGridInfinite from './PhotoGridInfinite';
export default function FieldsetPhotoChooser({
label,
value,
onChange,
photo,
}: {
label: string
value: string
onChange: (value: string) => void
photo?: Photo
}) {
return (
<>
<FieldsetWithStatus {...{ label, value, onChange, type: 'hidden' }} />
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button type="button" className="p-1.5">
{photo &&
<span className={clsx(
'flex w-[8rem]',
'border border-medium rounded-[4px]',
'overflow-hidden select-none active:opacity-75',
)}>
<ImageMedium
src={photo.url}
alt={altTextForPhoto(photo)}
aspectRatio={photo.aspectRatio}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
/>
</span>}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
onCloseAutoFocus={e => e.preventDefault()}
align="start"
sideOffset={10}
// alignOffset={-10}
className={clsx(
'z-20',
'min-w-[8rem]',
'component-surface',
'p-1.5',
'not-dark:shadow-lg not-dark:shadow-gray-900/10',
'data-[side=top]:dark:shadow-[0_0px_40px_rgba(0,0,0,0.6)]',
'data-[side=bottom]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
'data-[side=right]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
'data-[side=top]:animate-fade-in-from-bottom',
'data-[side=bottom]:animate-fade-in-from-top',
'data-[side=right]:animate-fade-in-from-top',
)}>
<div className={clsx(
'w-[14rem] max-h-[20rem] rounded-[3px] overflow-y-auto',
'space-y-1',
)}>
<PhotoGridInfinite
cacheKey="photo-chooser-menu"
initialOffset={0}
sortBy="takenAt"
animate={false}
/>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</>
);
}

View File

@ -1,69 +0,0 @@
'use client';
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { Photo } from '.';
import { useEffect, useState } from 'react';
import { AnnotatedTag } from './form';
import { useDebounce } from 'use-debounce';
import PhotoSmall from './PhotoSmall';
import { getPhotosAction } from './actions';
const convertPhotoToAnnotatedTag = (photo: Photo): AnnotatedTag => ({
value: photo.id,
label: photo.title,
icon: <div className="w-[3rem] overflow-hidden rounded-[3px]">
<PhotoSmall photo={photo} />
</div>,
});
export default function FieldsetPhotoQuery({
label,
photos = [],
value,
onChange,
}: {
label: string
photos?: Photo[]
value: string
onChange: (value: string) => void
}) {
const [query, setQuery] = useState('');
const [queryDebounced] = useDebounce(query, 500);
const [isQuerying, setIsQuerying] = useState(false);
const [photoOptions, setPhotoOptions] = useState<AnnotatedTag[]>(photos
.map(convertPhotoToAnnotatedTag),
);
useEffect(() => {
if (queryDebounced) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsQuerying(true);
getPhotosAction({ query: queryDebounced })
.then(photos => {
setPhotoOptions(photos.map(convertPhotoToAnnotatedTag));
})
.finally(() => {
setIsQuerying(false);
});
} else {
setPhotoOptions([]);
}
}, [queryDebounced]);
return (
<FieldsetWithStatus
label={label}
value={value}
onChange={onChange}
tagOptions={photoOptions}
tagOptionsOnInputTextChange={setQuery}
tagOptionsLabelOverride={value =>
photoOptions.find(option => option.value === value)?.label}
tagOptionsAllowNewValues={false}
tagOptionsShouldParameterize={false}
tagOptionsLimit={1}
loading={isQuerying}
/>
);
}

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

@ -0,0 +1,243 @@
/* eslint-disable react-hooks/set-state-in-effect */
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
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 { MENU_SURFACE_STYLES } from '@/components/primitives/surface';
import { IoSearch } from 'react-icons/io5';
import { useCallback, useEffect, useRef, useState } from 'react';
import usePhotoQuery from '../usePhotoQuery';
import { BiChevronRight } from 'react-icons/bi';
import SegmentMenu from '@/components/SegmentMenu';
import IconFavs from '@/components/icons/IconFavs';
import InfinitePhotoScroll from '../InfinitePhotoScroll';
import AdminEmptyState from '@/admin/AdminEmptyState';
import { TbPhotoSearch } from 'react-icons/tb';
type Mode = 'all' | 'favs' | 'search';
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)}
/>;
export default function FieldsetPhotoChooser({
label,
value,
onChange,
photo: _photo,
photos = [],
photosCount,
photosFavs,
}: {
label: string
value: string
onChange: (photoId: string) => void
photo?: Photo
photos: Photo[]
photosCount: number
photosFavs: Photo[]
}) {
const [isOpen, setIsOpen] = useState(false);
const [photo, setPhoto] = useState(_photo);
const [mode, setMode] = useState<Mode>('all');
const showQuery = mode === 'search';
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const {
photos: photosQuery,
isLoading: isLoadingPhotoQuery,
reset: resetPhotoQuery,
resultsNotFound,
} = usePhotoQuery({ query, isPrivate: true });
const reset = useCallback((resetMenu?: boolean) => {
resetPhotoQuery();
setQuery('');
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>;
const photosToShow = showQuery && query
? photosQuery
: mode === 'favs'
? photosFavs : photos;
const shouldPaginate =
!(showQuery && query) &&
photosCount > photos.length &&
mode !== 'favs';
return (
<>
<FieldsetWithStatus {...{ label, value, onChange, type: 'hidden' }} />
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu.Trigger asChild>
<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',
'select-none',
)}>
<span className="grow truncate text-left">
{label}
</span>
<BiChevronRight
size={18}
className={clsx(
'transition-transform ',
isOpen && 'rotate-90',
)}
/>
</span>
<span className={clsx(
'flex size-[6rem]',
'border border-medium rounded-[4px]',
'overflow-hidden select-none active:opacity-75',
)}>
{photo && renderPhoto(photo)}
</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
onCloseAutoFocus={e => e.preventDefault()}
align="start"
sideOffset={-80}
className={clsx(
MENU_SURFACE_STYLES,
'z-20 rounded-2xl pb-0 overflow-auto',
)}
>
<SegmentMenu
className="pt-1 pb-2 px-1.5"
items={[{
value: 'all',
}, {
value: 'favs',
icon: <IconFavs size={16} />,
iconSelected: <IconFavs size={16} highlight />,
}, {
value: 'search',
icon: <IoSearch size={16} />,
isLoading: isLoadingPhotoQuery,
}]}
selected={mode}
onChange={mode => {
setMode(mode);
if (mode !== 'search') {
reset();
}
}}
/>
<div className={clsx(
'w-[18rem] h-[20rem] overflow-y-auto',
'space-y-0.5',
)}>
<div className={clsx(
'flex items-center transition-all overflow-hidden',
showQuery ? 'h-12 opacity-100' : 'h-0 opacity-0',
)}>
<div className="w-full px-1.5">
<input
id="query"
ref={inputRef}
type="text"
placeholder="Search for a photo"
className={clsx(
'block w-full m-0 outline-none',
'rounded-full border border-dim',
'mb-2',
)}
value={query}
onChange={e => setQuery(e.target.value)}
/>
</div>
</div>
{showQuery && resultsNotFound &&
<AdminEmptyState
icon={<IoSearch className="text-dim" />}
className="translate-y-8"
includeContainer={false}
>
No photos found
</AdminEmptyState>}
{!showQuery && photosToShow.length === 0 &&
<AdminEmptyState
icon={<TbPhotoSearch className="text-dim" />}
className="translate-y-16"
includeContainer={false}
>
No photos
</AdminEmptyState>}
<div className={CLASSNAME_GRID}>
{photosToShow.map(photo => renderPhotoButton(photo))}
</div>
{shouldPaginate &&
<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>
</DropdownMenu.Root>
</>
);
}

View File

@ -14,7 +14,7 @@ export default function useDynamicPhoto({
const [isLoading, setIsLoading] = useState(false);
const [photoIdDebounced] = useDebounce(photoId, 500);
const [photoIdDebounced] = useDebounce(photoId, 500, { leading: true });
useEffect(() => {
if (photoIdDebounced) {

View File

@ -0,0 +1,73 @@
/* eslint-disable react-hooks/set-state-in-effect */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Photo } from '.';
import { useDebounce } from 'use-debounce';
import { getPhotosAction, searchPhotosPublicAction } from './actions';
const formatQuery = (query: string) =>
query.trim().toLocaleLowerCase();
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(() =>
formatQuery(query), [query]);
const [_queryDebounced] = useDebounce(query, 500, { leading: true });
const queryDebounced = useMemo(() =>
formatQuery(_queryDebounced), [_queryDebounced]);
const [photos, setPhotos] = useState<Photo[]>([]);
const resultsNotFound =
queryDebounced.length >= minimumQueryLength &&
!isLoading &&
photos.length === 0;
const reset = useCallback(() => {
setPhotos([]);
setIsLoading(false);
}, []);
useEffect(() => {
if (queryDebounced.length >= minimumQueryLength && isEnabled) {
setIsLoading(true);
(isPrivate
? getPhotosAction({ query: queryDebounced })
: searchPhotosPublicAction(queryDebounced))
.then(setPhotos)
.finally(() => setIsLoading(false));
}
}, [
queryDebounced,
minimumQueryLength,
isEnabled,
isPrivate,
]);
useEffect(() => {
if (queryFormatted.length >= minimumQueryLength) {
setIsLoading(true);
} else {
setPhotos([]);
setIsLoading(false);
}
}, [minimumQueryLength, queryFormatted]);
return {
queryFormatted,
photos,
isLoading,
resultsNotFound,
reset,
};
}