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