From 5940bee86a00192f8224fefe5b4c5939a85f1a90 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 1 Mar 2026 20:55:46 -0600 Subject: [PATCH] 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 --- app/about/edit/page.tsx | 64 ++++- app/admin/components/page.tsx | 16 +- app/full/[sortType]/[sortOrder]/page.tsx | 4 +- app/full/page.tsx | 4 +- app/grid/[sortType]/[sortOrder]/page.tsx | 4 +- app/grid/page.tsx | 4 +- app/page.tsx | 4 +- src/about/AdminAboutEditPage.tsx | 17 ++ src/admin/AdminComponentPageClient.tsx | 23 +- src/cmdk/CommandKClient.tsx | 109 +++----- src/components/SegmentMenu.tsx | 56 ++++ src/components/more/MoreMenu.tsx | 23 +- .../primitives/TooltipPrimitive.tsx | 8 +- .../ComponentSurface.tsx} | 2 +- src/components/primitives/surface/index.ts | 15 ++ .../shared-hover/SharedHoverProvider.tsx | 6 +- src/components/shared-hover/state.ts | 4 +- src/feed/index.ts | 6 +- src/photo/FieldsetPhotoChooser.tsx | 76 ------ src/photo/FieldsetPhotoQuery.tsx | 69 ----- src/photo/InfinitePhotoScroll.tsx | 4 +- src/photo/actions.ts | 2 +- src/photo/form/FieldsetPhotoChooser.tsx | 243 ++++++++++++++++++ src/photo/useDynamicPhoto.ts | 2 +- src/photo/usePhotoQuery.ts | 73 ++++++ 25 files changed, 556 insertions(+), 282 deletions(-) create mode 100644 src/components/SegmentMenu.tsx rename src/components/primitives/{MenuSurface.tsx => surface/ComponentSurface.tsx} (94%) create mode 100644 src/components/primitives/surface/index.ts delete mode 100644 src/photo/FieldsetPhotoChooser.tsx delete mode 100644 src/photo/FieldsetPhotoQuery.tsx create mode 100644 src/photo/form/FieldsetPhotoChooser.tsx create mode 100644 src/photo/usePhotoQuery.ts diff --git a/app/about/edit/page.tsx b/app/about/edit/page.tsx index 62475055..c02bbdd6 100644 --- a/app/about/edit/page.tsx +++ b/app/about/edit/page.tsx @@ -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 ( ); diff --git a/app/admin/components/page.tsx b/app/admin/components/page.tsx index 9869f3ff..a395c543 100644 --- a/app/admin/components/page.tsx +++ b/app/admin/components/page.tsx @@ -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 ( - + ); } diff --git a/app/full/[sortType]/[sortOrder]/page.tsx b/app/full/[sortType]/[sortOrder]/page.tsx index ec60ea4d..245ccaaa 100644 --- a/app/full/[sortType]/[sortOrder]/page.tsx +++ b/app/full/[sortType]/[sortOrder]/page.tsx @@ -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, }))); diff --git a/app/full/page.tsx b/app/full/page.tsx index c3e17380..5dca8262 100644 --- a/app/full/page.tsx +++ b/app/full/page.tsx @@ -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, }))); diff --git a/app/grid/[sortType]/[sortOrder]/page.tsx b/app/grid/[sortType]/[sortOrder]/page.tsx index e332d7ef..041e7794 100644 --- a/app/grid/[sortType]/[sortOrder]/page.tsx +++ b/app/grid/[sortType]/[sortOrder]/page.tsx @@ -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, }))); diff --git a/app/grid/page.tsx b/app/grid/page.tsx index ba6fe55f..b64512ef 100644 --- a/app/grid/page.tsx +++ b/app/grid/page.tsx @@ -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, }))); diff --git a/app/page.tsx b/app/page.tsx index f954497d..5c062cd7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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, }))); diff --git a/src/about/AdminAboutEditPage.tsx b/src/about/AdminAboutEditPage.tsx index 79ba4d56..739c3f3f 100644 --- a/src/about/AdminAboutEditPage.tsx +++ b/src/about/AdminAboutEditPage.tsx @@ -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} >
+ setAboutForm(form => + ({ ...form, photoIdAvatar }))} + photo={photoAvatar} + photos={photos} + photosCount={photosCount} + photosFavs={photosFavs} + /> -
-
- diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx index 1fedb886..7abbd5d8 100644 --- a/src/cmdk/CommandKClient.tsx +++ b/src/cmdk/CommandKClient.tsx @@ -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([]); + 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: , - items: photos.map(photo => ({ - label: titleForPhoto(photo), - keywords: getKeywordsForPhoto(photo), - annotation: , - accessory: , - 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(() => { + if (isOpenRef.current && photos.length > 0) { + return [{ + heading: 'Photos', + accessory: , + items: photos.map(photo => ({ + label: titleForPhoto(photo), + keywords: getKeywordsForPhoto(photo), + annotation: , + accessory: , + 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({ )}> { - 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 ? : <> @@ -889,7 +858,7 @@ export default function CommandKClient({ />; })} )} - {footer && !queryLive && + {footer && !queryFormatted &&
({ + items, + selected, + onChange, + className, +}: { + items: { + value: T + icon?: ReactNode + iconSelected?: ReactNode + isLoading?: boolean + }[] + selected: T + onChange: (value: T) => void + className?: string +}) { + return ( +
+ {items.map(({ value, icon, iconSelected, isLoading }) => ( + + ))} +
+ ); +} diff --git a/src/components/more/MoreMenu.tsx b/src/components/more/MoreMenu.tsx index b1dd6f79..e02e4056 100644 --- a/src/components/more/MoreMenu.tsx +++ b/src/components/more/MoreMenu.tsx @@ -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 &&
{item.items.map(item =>
diff --git a/src/components/primitives/TooltipPrimitive.tsx b/src/components/primitives/TooltipPrimitive.tsx index 4d7c652f..f090331a 100644 --- a/src/components/primitives/TooltipPrimitive.tsx +++ b/src/components/primitives/TooltipPrimitive.tsx @@ -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['color'] + color?: ComponentProps['color'] keyCommand?: string keyCommandModifier?: ComponentProps['modifier'] supportMobile?: boolean @@ -126,9 +126,9 @@ export default function TooltipPrimitive({ )} > {content && - + {content} - } + } diff --git a/src/components/primitives/MenuSurface.tsx b/src/components/primitives/surface/ComponentSurface.tsx similarity index 94% rename from src/components/primitives/MenuSurface.tsx rename to src/components/primitives/surface/ComponentSurface.tsx index f34480f1..5c339d06 100644 --- a/src/components/primitives/MenuSurface.tsx +++ b/src/components/primitives/surface/ComponentSurface.tsx @@ -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, diff --git a/src/components/primitives/surface/index.ts b/src/components/primitives/surface/index.ts new file mode 100644 index 00000000..183083e1 --- /dev/null +++ b/src/components/primitives/surface/index.ts @@ -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', +); diff --git a/src/components/shared-hover/SharedHoverProvider.tsx b/src/components/shared-hover/SharedHoverProvider.tsx index 837eb526..d089e7d6 100644 --- a/src/components/shared-hover/SharedHoverProvider.tsx +++ b/src/components/shared-hover/SharedHoverProvider.tsx @@ -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} > - @@ -158,7 +158,7 @@ export default function SharedHoverProvider({ : 'border-medium', )} />
- + }
diff --git a/src/components/shared-hover/state.ts b/src/components/shared-hover/state.ts index 50fa08cb..b9d95778 100644 --- a/src/components/shared-hover/state.ts +++ b/src/components/shared-hover/state.ts @@ -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['color'] + color?: ComponentProps['color'] } export type SharedHoverState = { diff --git a/src/feed/index.ts b/src/feed/index.ts index 059ad6d8..1c5f662e 100644 --- a/src/feed/index.ts +++ b/src/feed/index.ts @@ -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 = { diff --git a/src/photo/FieldsetPhotoChooser.tsx b/src/photo/FieldsetPhotoChooser.tsx deleted file mode 100644 index e841db54..00000000 --- a/src/photo/FieldsetPhotoChooser.tsx +++ /dev/null @@ -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 ( - <> - - - - - - - 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', - )}> -
- -
-
-
-
- - ); -} \ No newline at end of file diff --git a/src/photo/FieldsetPhotoQuery.tsx b/src/photo/FieldsetPhotoQuery.tsx deleted file mode 100644 index 80138cf9..00000000 --- a/src/photo/FieldsetPhotoQuery.tsx +++ /dev/null @@ -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:
- -
, -}); - -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(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 ( - - photoOptions.find(option => option.value === value)?.label} - tagOptionsAllowNewValues={false} - tagOptionsShouldParameterize={false} - tagOptionsLimit={1} - loading={isQuerying} - /> - ); -} diff --git a/src/photo/InfinitePhotoScroll.tsx b/src/photo/InfinitePhotoScroll.tsx index e1063487..36d854d0 100644 --- a/src/photo/InfinitePhotoScroll.tsx +++ b/src/photo/InfinitePhotoScroll.tsx @@ -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 &&
+ {!isFinished &&
{wrapMoreButtonInGrid ? : renderMoreButton} diff --git a/src/photo/actions.ts b/src/photo/actions.ts index e4051f56..1c9d3d35 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -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); diff --git a/src/photo/form/FieldsetPhotoChooser.tsx b/src/photo/form/FieldsetPhotoChooser.tsx new file mode 100644 index 00000000..c4154924 --- /dev/null +++ b/src/photo/form/FieldsetPhotoChooser.tsx @@ -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) => + ; + +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('all'); + + const showQuery = mode === 'search'; + + const inputRef = useRef(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) => + { + setPhoto(photo); + onChange(photo.id); + setIsOpen(false); + }} + > + {renderPhoto(photo)} + ; + + const photosToShow = showQuery && query + ? photosQuery + : mode === 'favs' + ? photosFavs : photos; + + const shouldPaginate = + !(showQuery && query) && + photosCount > photos.length && + mode !== 'favs'; + + return ( + <> + + + + + + + e.preventDefault()} + align="start" + sideOffset={-80} + className={clsx( + MENU_SURFACE_STYLES, + 'z-20 rounded-2xl pb-0 overflow-auto', + )} + > + , + iconSelected: , + }, { + value: 'search', + icon: , + isLoading: isLoadingPhotoQuery, + }]} + selected={mode} + onChange={mode => { + setMode(mode); + if (mode !== 'search') { + reset(); + } + }} + /> +
+
+
+ setQuery(e.target.value)} + /> +
+
+ {showQuery && resultsNotFound && + } + className="translate-y-8" + includeContainer={false} + > + No photos found + } + {!showQuery && photosToShow.length === 0 && + } + className="translate-y-16" + includeContainer={false} + > + No photos + } +
+ {photosToShow.map(photo => renderPhotoButton(photo))} +
+ {shouldPaginate && + + {({ key, photos }) => ( +
+ {photos.map(photo => renderPhotoButton(photo))} +
+ )} +
} +
+
+
+
+ + ); +} diff --git a/src/photo/useDynamicPhoto.ts b/src/photo/useDynamicPhoto.ts index ea4963ce..b3f36a8a 100644 --- a/src/photo/useDynamicPhoto.ts +++ b/src/photo/useDynamicPhoto.ts @@ -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) { diff --git a/src/photo/usePhotoQuery.ts b/src/photo/usePhotoQuery.ts new file mode 100644 index 00000000..52091c16 --- /dev/null +++ b/src/photo/usePhotoQuery.ts @@ -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([]); + + 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, + }; +}