From dddf1f39c5e8b3387503fc147e47afb0a6c4691b Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 28 Feb 2026 10:56:58 -0600 Subject: [PATCH 01/16] Refactor photo/menu form components --- app/about/edit/page.tsx | 54 +++++++++++++++---- 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 | 11 ++++ src/admin/AdminComponentPageClient.tsx | 4 +- src/components/more/MoreMenu.tsx | 20 ++----- .../primitives/TooltipPrimitive.tsx | 8 +-- .../ComponentSurface.tsx} | 2 +- src/components/primitives/surface/index.ts | 16 ++++++ .../shared-hover/SharedHoverProvider.tsx | 6 +-- src/components/shared-hover/state.ts | 4 +- src/feed/index.ts | 6 ++- src/photo/{ => form}/FieldsetPhotoChooser.tsx | 24 +++------ src/photo/{ => form}/FieldsetPhotoQuery.tsx | 8 +-- 17 files changed, 110 insertions(+), 73 deletions(-) rename src/components/primitives/{MenuSurface.tsx => surface/ComponentSurface.tsx} (94%) create mode 100644 src/components/primitives/surface/index.ts rename src/photo/{ => form}/FieldsetPhotoChooser.tsx (71%) rename src/photo/{ => form}/FieldsetPhotoQuery.tsx (91%) diff --git a/app/about/edit/page.tsx b/app/about/edit/page.tsx index 62475055..90018f6e 100644 --- a/app/about/edit/page.tsx +++ b/app/about/edit/page.tsx @@ -1,26 +1,60 @@ 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'; + +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, + photosHidden, + ] = 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, + }; + }), + getPhotosCached(PHOTO_CHOOSER_QUERY_OPTIONS), + getPhotosMetaCached(PHOTO_CHOOSER_QUERY_OPTIONS) + .then(({ count }) => count), + getPhotosCached({ hidden: 'only', limit: 1000 }), + ]); 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..2ddadefd 100644 --- a/src/about/AdminAboutEditPage.tsx +++ b/src/about/AdminAboutEditPage.tsx @@ -14,6 +14,7 @@ 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, @@ -23,6 +24,9 @@ export default function AdminAboutEditPage({ about?: About photoAvatar?: Photo photoHero?: Photo + photos?: Photo[] + photosCount?: number + photosHidden?: Photo[] shouldResizeImages?: boolean }) { const appText = useAppText(); @@ -58,6 +62,13 @@ export default function AdminAboutEditPage({ action={updateAboutAction} >
+ setAboutForm(form => + ({ ...form, photoIdAvatar: convertUrlToPhotoId(photoIdAvatar) }))} + photo={photoAvatar} + /> 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 { menuSurfaceStyles } from '../primitives/surface'; export type MoreMenuSection = { label?: string @@ -115,7 +101,7 @@ export default function MoreMenu({ onCloseAutoFocus={e => e.preventDefault()} align={align} sideOffset={sideOffset} - className={surfaceStyles(className)} + className={menuSurfaceStyles(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..b086f784 --- /dev/null +++ b/src/components/primitives/surface/index.ts @@ -0,0 +1,16 @@ +import clsx from 'clsx/lite'; + +export const menuSurfaceStyles = (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, +); 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/form/FieldsetPhotoChooser.tsx similarity index 71% rename from src/photo/FieldsetPhotoChooser.tsx rename to src/photo/form/FieldsetPhotoChooser.tsx index e841db54..0ee6c141 100644 --- a/src/photo/FieldsetPhotoChooser.tsx +++ b/src/photo/form/FieldsetPhotoChooser.tsx @@ -1,9 +1,10 @@ import FieldsetWithStatus from '@/components/FieldsetWithStatus'; -import { altTextForPhoto, doesPhotoNeedBlurCompatibility, Photo } from '.'; +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'; +import PhotoGridInfinite from '../PhotoGridInfinite'; +import { menuSurfaceStyles } from '@/components/primitives/surface'; export default function FieldsetPhotoChooser({ label, @@ -24,7 +25,7 @@ export default function FieldsetPhotoChooser({ @@ -55,16 +66,46 @@ export default function FieldsetPhotoChooser({ >
- {photos.length > 0 && - } - {(!photosCount || photosCount > photos.length) && - } +
+
+ Choose photo +
+ +
+ setQuery(e.target.value)} + /> +
+ {photos.map(photo => ( + + onChange(photo.id)} + /> + + ))} +
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) { From 7baedd070057d4e8dadfca8f1230634f33f6471f Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 28 Feb 2026 14:38:54 -0600 Subject: [PATCH 05/16] Extract photo query logic to hook --- app/admin/components/page.tsx | 2 +- src/cmdk/CommandKClient.tsx | 109 ++++++++++++---------------------- src/photo/usePhotoQuery.ts | 58 ++++++++++++++++++ 3 files changed, 98 insertions(+), 71 deletions(-) create mode 100644 src/photo/usePhotoQuery.ts diff --git a/app/admin/components/page.tsx b/app/admin/components/page.tsx index 90a17f40..5dcd9ad5 100644 --- a/app/admin/components/page.tsx +++ b/app/admin/components/page.tsx @@ -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); diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx index 1fedb886..326b90b9 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, !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 &&
+ 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([]); + + 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, + }; +} From 3694fd061defd2a46392ed2ee2b7e3d819cf1d0c Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 28 Feb 2026 14:42:25 -0600 Subject: [PATCH 06/16] Make photo chooser searchable --- src/photo/form/FieldsetPhotoChooser.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/photo/form/FieldsetPhotoChooser.tsx b/src/photo/form/FieldsetPhotoChooser.tsx index 2f2cb2c5..e3e003c6 100644 --- a/src/photo/form/FieldsetPhotoChooser.tsx +++ b/src/photo/form/FieldsetPhotoChooser.tsx @@ -8,6 +8,8 @@ import { GRID_SPACE_CLASSNAME } from '@/components'; import useDynamicPhoto from '../useDynamicPhoto'; import { IoSearch } from 'react-icons/io5'; import { useState } from 'react'; +import usePhotoQuery from '../usePhotoQuery'; +import Spinner from '@/components/Spinner'; export default function FieldsetPhotoChooser({ label, @@ -25,6 +27,10 @@ export default function FieldsetPhotoChooser({ photosHidden?: Photo[] }) { const [query, setQuery] = useState(''); + const { + photos: photosQuery, + isLoading, + } = usePhotoQuery(query); const { photo: photoAvatar, @@ -76,17 +82,19 @@ export default function FieldsetPhotoChooser({
Choose photo
- + {isLoading ? : }
- setQuery(e.target.value)} + onChange={setQuery} + hideLabel />
- {photos.map(photo => ( + {(photosQuery.length > 0 ? photosQuery : photos).map(photo => ( onChange(photo.id)} /> From bb7c393021b84a76966f2f5385b524a193ad7920 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 28 Feb 2026 15:17:16 -0600 Subject: [PATCH 07/16] Refine photo chooser --- src/photo/form/FieldsetPhotoChooser.tsx | 57 +++++++++++++++++++------ 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/src/photo/form/FieldsetPhotoChooser.tsx b/src/photo/form/FieldsetPhotoChooser.tsx index e3e003c6..2d5bcae7 100644 --- a/src/photo/form/FieldsetPhotoChooser.tsx +++ b/src/photo/form/FieldsetPhotoChooser.tsx @@ -6,8 +6,8 @@ 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 { useState } from 'react'; +import { IoClose, IoSearch } from 'react-icons/io5'; +import { useEffect, useRef, useState } from 'react'; import usePhotoQuery from '../usePhotoQuery'; import Spinner from '@/components/Spinner'; @@ -26,10 +26,15 @@ export default function FieldsetPhotoChooser({ photosCount?: number photosHidden?: Photo[] }) { + const inputRef = useRef(null); + + const [showQuery, setShowQuery] = useState(true); + // TODO: Move query into hook const [query, setQuery] = useState(''); const { photos: photosQuery, isLoading, + reset, } = usePhotoQuery(query); const { @@ -40,6 +45,16 @@ export default function FieldsetPhotoChooser({ photoId: value, }); + useEffect(() => { + if (showQuery) { + inputRef.current?.focus(); + } else { + reset(); + // eslint-disable-next-line react-hooks/set-state-in-effect + setQuery(''); + } + }, [showQuery, reset]); + return ( <> @@ -82,19 +97,33 @@ export default function FieldsetPhotoChooser({
Choose photo
- {isLoading ? : } + {isLoading + ? + : showQuery + ? setShowQuery(false)} + /> + : setShowQuery(true)} + />}
- -
- {(photosQuery.length > 0 ? photosQuery : photos).map(photo => ( + {showQuery && + setQuery(e.target.value)} + />} +
+ {(showQuery && query ? photosQuery : photos).map(photo => ( Date: Sat, 28 Feb 2026 21:20:54 -0600 Subject: [PATCH 08/16] Create custom photo chooser menu --- src/components/SegmentMenu.tsx | 52 +++++++++++ src/photo/form/FieldsetPhotoChooser.tsx | 117 ++++++++++++++---------- 2 files changed, 123 insertions(+), 46 deletions(-) create mode 100644 src/components/SegmentMenu.tsx diff --git a/src/components/SegmentMenu.tsx b/src/components/SegmentMenu.tsx new file mode 100644 index 00000000..2e027bb5 --- /dev/null +++ b/src/components/SegmentMenu.tsx @@ -0,0 +1,52 @@ +import clsx from 'clsx/lite'; +import { ReactNode } from 'react'; + +export default function SegmentMenu({ + items, + selected, + onChange, + className, +}: { + items: { + value: T + icon?: ReactNode + iconSelected?: ReactNode + }[] + selected: T + onChange: (value: T) => void + className?: string +}) { + return ( +
+ {items.map(({ value, icon, iconSelected }) => ( + + ))} +
+ ); +} diff --git a/src/photo/form/FieldsetPhotoChooser.tsx b/src/photo/form/FieldsetPhotoChooser.tsx index 2d5bcae7..aae1b602 100644 --- a/src/photo/form/FieldsetPhotoChooser.tsx +++ b/src/photo/form/FieldsetPhotoChooser.tsx @@ -6,10 +6,34 @@ import ImageMedium from '@/components/image/ImageMedium'; import { menuSurfaceStyles } from '@/components/primitives/surface'; import { GRID_SPACE_CLASSNAME } from '@/components'; import useDynamicPhoto from '../useDynamicPhoto'; -import { IoClose, IoSearch } from 'react-icons/io5'; +import { IoSearch } from 'react-icons/io5'; import { 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'; + +type Mode = 'all' | 'favs' | 'hidden' | 'search'; + +const renderPhoto = ({ + photo, + className, + onClick, +}: { + photo: Photo, + className?: string, + onClick?: () => void, +}) => + ; export default function FieldsetPhotoChooser({ label, @@ -26,10 +50,12 @@ export default function FieldsetPhotoChooser({ photosCount?: number photosHidden?: Photo[] }) { + const [mode, setMode] = useState('all'); + + const showQuery = mode === 'search'; + const inputRef = useRef(null); - const [showQuery, setShowQuery] = useState(true); - // TODO: Move query into hook const [query, setQuery] = useState(''); const { photos: photosQuery, @@ -60,21 +86,27 @@ export default function FieldsetPhotoChooser({ - @@ -83,34 +115,32 @@ export default function FieldsetPhotoChooser({ onCloseAutoFocus={e => e.preventDefault()} align="start" sideOffset={10} - className={menuSurfaceStyles('z-20 px-1.5 py-1.5')} + className={menuSurfaceStyles('z-20 px-1.5 py-1.5 rounded-2xl')} >
-
-
- Choose photo -
- {isLoading - ? - : showQuery - ? setShowQuery(false)} - /> - : setShowQuery(true)} - />} -
+ , + iconSelected: , + }, { + value: 'hidden', + icon: , + }, { + value: 'search', + icon: isLoading + ? + : , + }]} + selected={mode} + onChange={setMode} + /> {showQuery && - onChange(photo.id)} - /> + {renderPhoto({ + photo, + onClick: () => onChange(photo.id), + })} ))}
From 38f724762edd55a3223ece085b3c6c0ea85ce8da Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 1 Mar 2026 13:23:25 -0600 Subject: [PATCH 09/16] Refactor core photo chooser behavior --- app/admin/components/page.tsx | 3 +- src/about/AdminAboutEditPage.tsx | 7 +- src/admin/AdminComponentPageClient.tsx | 2 +- src/cmdk/CommandKClient.tsx | 2 +- src/components/SegmentMenu.tsx | 24 +-- src/photo/InfinitePhotoScroll.tsx | 4 +- src/photo/actions.ts | 2 +- src/photo/form/FieldsetPhotoChooser.tsx | 211 +++++++++++++----------- src/photo/usePhotoQuery.ts | 19 ++- 9 files changed, 154 insertions(+), 120 deletions(-) diff --git a/app/admin/components/page.tsx b/app/admin/components/page.tsx index 5dcd9ad5..55d79bad 100644 --- a/app/admin/components/page.tsx +++ b/app/admin/components/page.tsx @@ -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); diff --git a/src/about/AdminAboutEditPage.tsx b/src/about/AdminAboutEditPage.tsx index 0e79b3b2..ef54fba3 100644 --- a/src/about/AdminAboutEditPage.tsx +++ b/src/about/AdminAboutEditPage.tsx @@ -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} /> ({ items, @@ -11,6 +12,7 @@ export default function SegmentMenu({ value: T icon?: ReactNode iconSelected?: ReactNode + isLoading?: boolean }[] selected: T onChange: (value: T) => void @@ -21,7 +23,7 @@ export default function SegmentMenu({ 'flex justify-center gap-1', className, )}> - {items.map(({ value, icon, iconSelected }) => ( + {items.map(({ value, icon, iconSelected, isLoading }) => ( ))}
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 index aae1b602..08012186 100644 --- a/src/photo/form/FieldsetPhotoChooser.tsx +++ b/src/photo/form/FieldsetPhotoChooser.tsx @@ -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) => ; 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('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, - }); + const reset = useCallback((resetMenu?: boolean) => { + resetPhotoQuery(); + setQuery(''); + if (resetMenu) { setMode('all'); } + }, [resetPhotoQuery]); + // Focus input on query mode useEffect(() => { - if (showQuery) { - inputRef.current?.focus(); - } else { - reset(); - // eslint-disable-next-line react-hooks/set-state-in-effect - setQuery(''); - } - }, [showQuery, reset]); + 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)} + ; return ( <> - + - @@ -115,59 +130,65 @@ 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')} > + , + iconSelected: , + }, { + value: 'search', + icon: , + isLoading: isLoadingPhotoQuery, + }]} + selected={mode} + onChange={mode => { + setMode(mode); + if (mode !== 'search') { + reset(); + } + }} + />
- , - iconSelected: , - }, { - value: 'hidden', - icon: , - }, { - value: 'search', - icon: isLoading - ? - : , - }]} - selected={mode} - onChange={setMode} + setQuery(e.target.value)} /> - {showQuery && - setQuery(e.target.value)} - />} -
- {(showQuery && query ? photosQuery : photos).map(photo => ( - - {renderPhoto({ - photo, - onClick: () => onChange(photo.id), - })} - - ))} +
+
+
+ {(showQuery && query ? photosQuery : photos) + .map(photo => renderPhotoButton(photo))}
+ {!(showQuery && query) && photosCount > photos.length && + + {({ key, photos }) => ( +
+ {photos.map(photo => renderPhotoButton(photo))} +
+ )} +
}
diff --git a/src/photo/usePhotoQuery.ts b/src/photo/usePhotoQuery.ts index eadfba60..eeb425bd 100644 --- a/src/photo/usePhotoQuery.ts +++ b/src/photo/usePhotoQuery.ts @@ -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(() => { From 915b19bb540175be3efbaebc4f2ab746e4ebf29d Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 1 Mar 2026 13:27:46 -0600 Subject: [PATCH 10/16] Remove --- src/admin/AdminComponentPageClient.tsx | 10 ---- src/photo/form/FieldsetPhotoQuery.tsx | 69 -------------------------- 2 files changed, 79 deletions(-) delete mode 100644 src/photo/form/FieldsetPhotoQuery.tsx diff --git a/src/admin/AdminComponentPageClient.tsx b/src/admin/AdminComponentPageClient.tsx index 9ba7f18d..507008d0 100644 --- a/src/admin/AdminComponentPageClient.tsx +++ b/src/admin/AdminComponentPageClient.tsx @@ -10,7 +10,6 @@ import StatusIcon from '@/components/StatusIcon'; import clsx from 'clsx/lite'; import { useState } from 'react'; import { Photo } from '@/photo'; -import FieldsetPhotoQuery from '@/photo/form/FieldsetPhotoQuery'; import FieldsetPhotoChooser from '@/photo/form/FieldsetPhotoChooser'; export default function AdminComponentPageClient({ @@ -23,7 +22,6 @@ export default function AdminComponentPageClient({ photosCount: number }) { const [valuePhoto, setValuePhoto] = useState(photo?.id ?? ''); - const [valuePhotoChooser, setValuePhotoChooser] = useState(photo?.id ?? ''); const [value, setValue] = useState('visible'); @@ -45,14 +43,6 @@ export default function AdminComponentPageClient({ photo={photo} photos={photos} photosCount={photosCount} - value={valuePhotoChooser} - onChange={setValuePhotoChooser} - /> -
-
- diff --git a/src/photo/form/FieldsetPhotoQuery.tsx b/src/photo/form/FieldsetPhotoQuery.tsx deleted file mode 100644 index f3b6b64f..00000000 --- a/src/photo/form/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} - /> - ); -} From bd2dd6a03084fdf1c89f3729847559379ed5d67f Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 1 Mar 2026 13:33:44 -0600 Subject: [PATCH 11/16] Make /about/edit safely pre-render --- app/about/edit/page.tsx | 36 +++++++++++++++++++------------- src/about/AdminAboutEditPage.tsx | 2 +- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/app/about/edit/page.tsx b/app/about/edit/page.tsx index 212ace2b..a0d1e9c2 100644 --- a/app/about/edit/page.tsx +++ b/app/about/edit/page.tsx @@ -24,23 +24,29 @@ export default async function AboutEditPage() { photosCount, photosHidden, ] = await Promise.all([ - getAbout().then(async about => { - const photoAvatar = about?.photoIdAvatar - ? await getPhoto(about?.photoIdAvatar ?? '', true) - .catch(() => undefined) - : undefined; + getAbout() + .then(async about => { + const photoAvatar = about?.photoIdAvatar + ? await getPhoto(about?.photoIdAvatar ?? '', true) + .catch(() => undefined) + : undefined; - const photoHero = about?.photoIdHero - ? await getPhoto(about?.photoIdHero ?? '', true) - .catch(() => undefined) - : undefined; + const photoHero = about?.photoIdHero + ? await getPhoto(about?.photoIdHero ?? '', true) + .catch(() => undefined) + : undefined; - return { - about, - photoAvatar, - photoHero, - }; - }), + return { + about, + photoAvatar, + photoHero, + }; + }) + .catch(() => ({ + about: undefined, + photoAvatar: undefined, + photoHero: undefined, + })), getPhotosCached(PHOTO_CHOOSER_QUERY_OPTIONS) .catch(() => []), getPhotosMetaCached(PHOTO_CHOOSER_QUERY_OPTIONS) diff --git a/src/about/AdminAboutEditPage.tsx b/src/about/AdminAboutEditPage.tsx index ef54fba3..eded8785 100644 --- a/src/about/AdminAboutEditPage.tsx +++ b/src/about/AdminAboutEditPage.tsx @@ -67,7 +67,7 @@ export default function AdminAboutEditPage({ label="Avatar Photo" value={aboutForm?.photoIdAvatar ?? ''} onChange={photoIdAvatar => setAboutForm(form => - ({ ...form, photoIdAvatar: convertUrlToPhotoId(photoIdAvatar) }))} + ({ ...form, photoIdAvatar }))} photo={photoAvatar} photos={photos} photosCount={photosCount} From 017869fda9ea9643c337b58ca7692f1b636df7c4 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 1 Mar 2026 13:50:01 -0600 Subject: [PATCH 12/16] Animate query menu, add favs to chooser --- app/about/edit/page.tsx | 7 +-- app/admin/components/page.tsx | 3 ++ src/about/AdminAboutEditPage.tsx | 3 ++ src/admin/AdminComponentPageClient.tsx | 3 ++ src/photo/form/FieldsetPhotoChooser.tsx | 61 ++++++++++++++----------- 5 files changed, 48 insertions(+), 29 deletions(-) diff --git a/app/about/edit/page.tsx b/app/about/edit/page.tsx index a0d1e9c2..c02bbdd6 100644 --- a/app/about/edit/page.tsx +++ b/app/about/edit/page.tsx @@ -7,6 +7,7 @@ import { getPhotosMetaCached, } from '@/photo/cache'; import { getPhoto } from '@/photo/query'; +import { TAG_FAVS } from '@/tag'; const PHOTO_CHOOSER_QUERY_OPTIONS = feedQueryOptions({ isGrid: true, @@ -22,7 +23,7 @@ export default async function AboutEditPage() { }, photos, photosCount, - photosHidden, + photosFavs, ] = await Promise.all([ getAbout() .then(async about => { @@ -52,7 +53,7 @@ export default async function AboutEditPage() { getPhotosMetaCached(PHOTO_CHOOSER_QUERY_OPTIONS) .then(({ count }) => count) .catch(() => 0), - getPhotosCached({ hidden: 'only', limit: 1000 }) + getPhotosCached({ tag: TAG_FAVS }) .catch(() => []), ]); @@ -63,7 +64,7 @@ export default async function AboutEditPage() { photoHero, photos, photosCount, - photosHidden, + photosFavs, shouldResizeImages: !PRESERVE_ORIGINAL_UPLOADS, }} /> ); diff --git a/app/admin/components/page.tsx b/app/admin/components/page.tsx index 55d79bad..a395c543 100644 --- a/app/admin/components/page.tsx +++ b/app/admin/components/page.tsx @@ -1,17 +1,20 @@ import AdminComponentPageClient from '@/admin/AdminComponentPageClient'; 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: INFINITE_SCROLL_GRID_INITIAL }); const photosCount = await getPhotosMetaCached() .then(({ count }) => count); + const photosFavs = await getPhotosCached({ tag: TAG_FAVS }); return ( ); } diff --git a/src/about/AdminAboutEditPage.tsx b/src/about/AdminAboutEditPage.tsx index eded8785..739c3f3f 100644 --- a/src/about/AdminAboutEditPage.tsx +++ b/src/about/AdminAboutEditPage.tsx @@ -22,12 +22,14 @@ export default function AdminAboutEditPage({ photoHero: _photoHero, photos, photosCount, + photosFavs, }: { about?: About photoAvatar?: Photo photoHero?: Photo photos: Photo[] photosCount: number + photosFavs: Photo[] shouldResizeImages?: boolean }) { const appText = useAppText(); @@ -71,6 +73,7 @@ export default function AdminAboutEditPage({ photo={photoAvatar} photos={photos} photosCount={photosCount} + photosFavs={photosFavs} /> diff --git a/src/photo/form/FieldsetPhotoChooser.tsx b/src/photo/form/FieldsetPhotoChooser.tsx index 08012186..b96b699d 100644 --- a/src/photo/form/FieldsetPhotoChooser.tsx +++ b/src/photo/form/FieldsetPhotoChooser.tsx @@ -38,6 +38,7 @@ export default function FieldsetPhotoChooser({ photo: _photo, photos = [], photosCount, + photosFavs, }: { label: string value: string @@ -45,6 +46,7 @@ export default function FieldsetPhotoChooser({ photo?: Photo photos: Photo[] photosCount: number + photosFavs: Photo[] }) { const [isOpen, setIsOpen] = useState(false); @@ -154,41 +156,48 @@ export default function FieldsetPhotoChooser({ }} />
- setQuery(e.target.value)} - /> +
+ setQuery(e.target.value)} + /> +
- {(showQuery && query ? photosQuery : photos) + {(showQuery && query + ? photosQuery + : mode === 'favs' + ? photosFavs : photos) .map(photo => renderPhotoButton(photo))}
- {!(showQuery && query) && photosCount > photos.length && - - {({ key, photos }) => ( -
- {photos.map(photo => renderPhotoButton(photo))} -
- )} -
} + { + !(showQuery && query) && + photosCount > photos.length && + mode !== 'favs' && + + {({ key, photos }) => ( +
+ {photos.map(photo => renderPhotoButton(photo))} +
+ )} +
}
From f244b8ce94182c70061a8d73e380bfa0a0ade6c2 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 1 Mar 2026 14:00:02 -0600 Subject: [PATCH 13/16] Refactor photo chooser logic --- src/photo/form/FieldsetPhotoChooser.tsx | 48 ++++++++++++++----------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/src/photo/form/FieldsetPhotoChooser.tsx b/src/photo/form/FieldsetPhotoChooser.tsx index b96b699d..02fc4cca 100644 --- a/src/photo/form/FieldsetPhotoChooser.tsx +++ b/src/photo/form/FieldsetPhotoChooser.tsx @@ -18,6 +18,9 @@ import SegmentMenu from '@/components/SegmentMenu'; import IconFavs from '@/components/icons/IconFavs'; import InfinitePhotoScroll from '../InfinitePhotoScroll'; +// TODO: +// Create empty state for all modes, including no search results + type Mode = 'all' | 'favs' | 'search'; const CLASSNAME_GRID = 'grid grid-cols-3 gap-0.5'; @@ -98,6 +101,16 @@ export default function FieldsetPhotoChooser({ {renderPhoto(photo)} ; + const photosToShow = showQuery && query + ? photosQuery + : mode === 'favs' + ? photosFavs : photos; + + const shouldPaginate = + !(showQuery && query) && + photosCount > photos.length && + mode !== 'favs'; + return ( <> @@ -176,28 +189,21 @@ export default function FieldsetPhotoChooser({ 'space-y-0.5', )}>
- {(showQuery && query - ? photosQuery - : mode === 'favs' - ? photosFavs : photos) - .map(photo => renderPhotoButton(photo))} + {photosToShow.map(photo => renderPhotoButton(photo))}
- { - !(showQuery && query) && - photosCount > photos.length && - mode !== 'favs' && - - {({ key, photos }) => ( -
- {photos.map(photo => renderPhotoButton(photo))} -
- )} -
} + {shouldPaginate && + + {({ key, photos }) => ( +
+ {photos.map(photo => renderPhotoButton(photo))} +
+ )} +
}
From 8a6131d539060bcf6ec00e6b5669da33704681aa Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 1 Mar 2026 19:56:07 -0600 Subject: [PATCH 14/16] Add photo chooser empty states --- src/components/more/MoreMenu.tsx | 9 ++- src/components/primitives/surface/index.ts | 3 +- src/photo/form/FieldsetPhotoChooser.tsx | 80 +++++++++++++++------- src/photo/usePhotoQuery.ts | 6 ++ 4 files changed, 68 insertions(+), 30 deletions(-) diff --git a/src/components/more/MoreMenu.tsx b/src/components/more/MoreMenu.tsx index 0ab0926f..e02e4056 100644 --- a/src/components/more/MoreMenu.tsx +++ b/src/components/more/MoreMenu.tsx @@ -11,7 +11,7 @@ import { FiMoreHorizontal } from 'react-icons/fi'; import MoreMenuItem from './MoreMenuItem'; import { clearGlobalFocus } from '@/utility/dom'; import { FaChevronRight } from 'react-icons/fa6'; -import { menuSurfaceStyles } from '../primitives/surface'; +import { MENU_SURFACE_STYLES } from '../primitives/surface'; export type MoreMenuSection = { label?: string @@ -101,7 +101,10 @@ export default function MoreMenu({ onCloseAutoFocus={e => e.preventDefault()} align={align} sideOffset={sideOffset} - className={menuSurfaceStyles(className)} + className={clsx( + MENU_SURFACE_STYLES, + className, + )} > {header &&
{item.items.map(item =>
diff --git a/src/components/primitives/surface/index.ts b/src/components/primitives/surface/index.ts index b086f784..183083e1 100644 --- a/src/components/primitives/surface/index.ts +++ b/src/components/primitives/surface/index.ts @@ -1,6 +1,6 @@ import clsx from 'clsx/lite'; -export const menuSurfaceStyles = (className?: string) => clsx( +export const MENU_SURFACE_STYLES = clsx( 'z-10', 'min-w-[8rem]', 'component-surface', @@ -12,5 +12,4 @@ export const menuSurfaceStyles = (className?: string) => clsx( '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, ); diff --git a/src/photo/form/FieldsetPhotoChooser.tsx b/src/photo/form/FieldsetPhotoChooser.tsx index 02fc4cca..c4154924 100644 --- a/src/photo/form/FieldsetPhotoChooser.tsx +++ b/src/photo/form/FieldsetPhotoChooser.tsx @@ -9,17 +9,16 @@ import { 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 { 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 { BiChevronDown } from 'react-icons/bi'; +import { BiChevronRight } from 'react-icons/bi'; import SegmentMenu from '@/components/SegmentMenu'; import IconFavs from '@/components/icons/IconFavs'; import InfinitePhotoScroll from '../InfinitePhotoScroll'; - -// TODO: -// Create empty state for all modes, including no search results +import AdminEmptyState from '@/admin/AdminEmptyState'; +import { TbPhotoSearch } from 'react-icons/tb'; type Mode = 'all' | 'favs' | 'search'; @@ -66,6 +65,7 @@ export default function FieldsetPhotoChooser({ photos: photosQuery, isLoading: isLoadingPhotoQuery, reset: resetPhotoQuery, + resultsNotFound, } = usePhotoQuery({ query, isPrivate: true }); const reset = useCallback((resetMenu?: boolean) => { @@ -125,11 +125,18 @@ export default function FieldsetPhotoChooser({ 'font-sans', 'text-xs text-medium font-medium uppercase tracking-wider', 'py-1', + 'select-none', )}> {label} - + e.preventDefault()} align="start" - sideOffset={10} - className={menuSurfaceStyles('z-20 rounded-2xl pb-0 overflow-auto')} + sideOffset={-80} + className={clsx( + MENU_SURFACE_STYLES, + 'z-20 rounded-2xl pb-0 overflow-auto', + )} >
-
- setQuery(e.target.value)} - /> -
-
-
+
+
+ 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))}
diff --git a/src/photo/usePhotoQuery.ts b/src/photo/usePhotoQuery.ts index eeb425bd..52091c16 100644 --- a/src/photo/usePhotoQuery.ts +++ b/src/photo/usePhotoQuery.ts @@ -28,6 +28,11 @@ export default function usePhotoQuery({ const [photos, setPhotos] = useState([]); + const resultsNotFound = + queryDebounced.length >= minimumQueryLength && + !isLoading && + photos.length === 0; + const reset = useCallback(() => { setPhotos([]); setIsLoading(false); @@ -62,6 +67,7 @@ export default function usePhotoQuery({ queryFormatted, photos, isLoading, + resultsNotFound, reset, }; } From 669d471dc07ab39c50046580afdee59081d36ea7 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 1 Mar 2026 20:00:22 -0600 Subject: [PATCH 15/16] Push back chooser icon color --- src/photo/form/FieldsetPhotoChooser.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/photo/form/FieldsetPhotoChooser.tsx b/src/photo/form/FieldsetPhotoChooser.tsx index c4154924..fbffbcb0 100644 --- a/src/photo/form/FieldsetPhotoChooser.tsx +++ b/src/photo/form/FieldsetPhotoChooser.tsx @@ -133,6 +133,7 @@ export default function FieldsetPhotoChooser({ Date: Sun, 1 Mar 2026 20:48:32 -0600 Subject: [PATCH 16/16] Refine photo chooser behavior --- src/about/AdminAboutEditPage.tsx | 54 +++++------------------ src/photo/form/FieldsetPhotoChooser.tsx | 58 +++++++++++++++---------- 2 files changed, 45 insertions(+), 67 deletions(-) diff --git a/src/about/AdminAboutEditPage.tsx b/src/about/AdminAboutEditPage.tsx index 739c3f3f..fde27874 100644 --- a/src/about/AdminAboutEditPage.tsx +++ b/src/about/AdminAboutEditPage.tsx @@ -9,17 +9,13 @@ import AdminChildPage from '@/components/AdminChildPage'; import { updateAboutAction } from './actions'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import { Photo } from '@/photo'; -import PhotoAvatar from '@/photo/PhotoAvatar'; -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, + photoAvatar, + photoHero, photos, photosCount, photosFavs, @@ -36,22 +32,6 @@ export default function AdminAboutEditPage({ const [aboutForm, setAboutForm] = useState>(about ?? {}); - const { - photo: photoAvatar, - isLoading: isLoadingPhotoAvatar, - } = useDynamicPhoto({ - initialPhoto: _photoAvatar, - photoId: aboutForm?.photoIdAvatar, - }); - - const { - photo: photoHero, - isLoading: isLoadingPhotoHero, - } = useDynamicPhoto({ - initialPhoto: _photoHero, - photoId: aboutForm?.photoIdHero, - }); - const convertUrlToPhotoId = (url?: string) => url?.split('/').pop(); return ( @@ -66,7 +46,8 @@ export default function AdminAboutEditPage({ >
setAboutForm(form => ({ ...form, photoIdAvatar }))} @@ -75,16 +56,6 @@ export default function AdminAboutEditPage({ photosCount={photosCount} photosFavs={photosFavs} /> - - setAboutForm(form => - ({ ...form, photoIdAvatar: convertUrlToPhotoId(photoIdAvatar) }))} - loading={isLoadingPhotoAvatar} - /> setAboutForm(form => ({ ...form, description }))} /> - setAboutForm(form => ({ ...form, photoIdHero: convertUrlToPhotoId(photoIdHero) }))} - loading={isLoadingPhotoHero} + photo={photoHero} + photos={photos} + photosCount={photosCount} + photosFavs={photosFavs} /> - {photoHero && -
- -
}
/>; export default function FieldsetPhotoChooser({ - label, - value, - onChange, photo: _photo, photos = [], photosCount, photosFavs, + ...props }: { - label: string - value: string - onChange: (photoId: string) => void photo?: Photo photos: Photo[] photosCount: number photosFavs: Photo[] -}) { +} & ComponentProps) { const [isOpen, setIsOpen] = useState(false); const [photo, setPhoto] = useState(_photo); @@ -58,7 +60,8 @@ export default function FieldsetPhotoChooser({ const showQuery = mode === 'search'; - const inputRef = useRef(null); + const refContainer = useRef(null); + const refInput = useRef(null); const [query, setQuery] = useState(''); const { @@ -74,11 +77,6 @@ export default function FieldsetPhotoChooser({ 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); } @@ -94,7 +92,7 @@ export default function FieldsetPhotoChooser({ )} onClick={() => { setPhoto(photo); - onChange(photo.id); + props.onChange?.(photo.id); setIsOpen(false); }} > @@ -113,7 +111,7 @@ export default function FieldsetPhotoChooser({ return ( <> - + @@ -176,13 +182,19 @@ export default function FieldsetPhotoChooser({ setMode(mode); if (mode !== 'search') { reset(); + } else { + refContainer.current?.scrollTo({ top: 0 }); + refInput.current?.focus(); } }} /> -
+