Photo Chooser (#383)
* Refactor photo/menu form components * Fix pre-rendering error * Incorporate server-side photo chooser data * Create custom photo chooser grid * Extract photo query logic to hook * Make photo chooser searchable * Create custom photo chooser menu * Animate query menu, add favs to chooser * Add photo chooser empty states
This commit is contained in:
parent
741bcf32f7
commit
5940bee86a
@ -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 getPhotoNoStore(about?.photoIdAvatar ?? '', true)
|
||||
? await getPhoto(about?.photoIdAvatar ?? '', true)
|
||||
.catch(() => undefined)
|
||||
: undefined;
|
||||
|
||||
const photoHero = about?.photoIdHero
|
||||
? await getPhotoNoStore(about?.photoIdHero ?? '', true)
|
||||
? await getPhoto(about?.photoIdHero ?? '', true)
|
||||
.catch(() => undefined)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
about,
|
||||
photoAvatar,
|
||||
photoHero,
|
||||
};
|
||||
})
|
||||
.catch(() => ({
|
||||
about: undefined,
|
||||
photoAvatar: undefined,
|
||||
photoHero: undefined,
|
||||
})),
|
||||
getPhotosCached(PHOTO_CHOOSER_QUERY_OPTIONS)
|
||||
.catch(() => []),
|
||||
getPhotosMetaCached(PHOTO_CHOOSER_QUERY_OPTIONS)
|
||||
.then(({ count }) => count)
|
||||
.catch(() => 0),
|
||||
getPhotosCached({ tag: TAG_FAVS })
|
||||
.catch(() => []),
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminAboutEditPage {...{
|
||||
about,
|
||||
photoAvatar,
|
||||
photoHero,
|
||||
photos,
|
||||
photosCount,
|
||||
photosFavs,
|
||||
shouldResizeImages: !PRESERVE_ORIGINAL_UPLOADS,
|
||||
}} />
|
||||
);
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
import AdminComponentPageClient from '@/admin/AdminComponentPageClient';
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
||||
import { getPhotosCached, getPhotosMetaCached } from '@/photo/cache';
|
||||
import { TAG_FAVS } from '@/tag';
|
||||
|
||||
export default async function ComponentsPage() {
|
||||
const photos = await getPhotosCached({ limit: 1});
|
||||
const photos = await getPhotosCached({ limit: INFINITE_SCROLL_GRID_INITIAL });
|
||||
const photosCount = await getPhotosMetaCached()
|
||||
.then(({ count }) => count);
|
||||
const photosFavs = await getPhotosCached({ tag: TAG_FAVS });
|
||||
|
||||
return (
|
||||
<AdminComponentPageClient photo={photos[0]} />
|
||||
<AdminComponentPageClient
|
||||
photo={photos[0]}
|
||||
photos={photos}
|
||||
photosCount={photosCount}
|
||||
photosFavs={photosFavs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})));
|
||||
|
||||
@ -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,
|
||||
})));
|
||||
|
||||
|
||||
@ -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,
|
||||
})));
|
||||
|
||||
@ -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,
|
||||
})));
|
||||
|
||||
|
||||
@ -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,
|
||||
})));
|
||||
|
||||
|
||||
@ -14,15 +14,22 @@ import PhotoMedium from '@/photo/PhotoMedium';
|
||||
import clsx from 'clsx/lite';
|
||||
import useDynamicPhoto from '@/photo/useDynamicPhoto';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import FieldsetPhotoChooser from '@/photo/form/FieldsetPhotoChooser';
|
||||
|
||||
export default function AdminAboutEditPage({
|
||||
about,
|
||||
photoAvatar: _photoAvatar,
|
||||
photoHero: _photoHero,
|
||||
photos,
|
||||
photosCount,
|
||||
photosFavs,
|
||||
}: {
|
||||
about?: About
|
||||
photoAvatar?: Photo
|
||||
photoHero?: Photo
|
||||
photos: Photo[]
|
||||
photosCount: number
|
||||
photosFavs: Photo[]
|
||||
shouldResizeImages?: boolean
|
||||
}) {
|
||||
const appText = useAppText();
|
||||
@ -58,6 +65,16 @@ export default function AdminAboutEditPage({
|
||||
action={updateAboutAction}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FieldsetPhotoChooser
|
||||
label="Avatar Photo"
|
||||
value={aboutForm?.photoIdAvatar ?? ''}
|
||||
onChange={photoIdAvatar => setAboutForm(form =>
|
||||
({ ...form, photoIdAvatar }))}
|
||||
photo={photoAvatar}
|
||||
photos={photos}
|
||||
photosCount={photosCount}
|
||||
photosFavs={photosFavs}
|
||||
/>
|
||||
<PhotoAvatar photo={photoAvatar} />
|
||||
<FieldsetWithStatus
|
||||
id="photoIdAvatar"
|
||||
|
||||
@ -10,16 +10,20 @@ import StatusIcon from '@/components/StatusIcon';
|
||||
import clsx from 'clsx/lite';
|
||||
import { useState } from 'react';
|
||||
import { Photo } from '@/photo';
|
||||
import FieldsetPhotoQuery from '@/photo/FieldsetPhotoQuery';
|
||||
import FieldsetPhotoChooser from '@/photo/FieldsetPhotoChooser';
|
||||
import FieldsetPhotoChooser from '@/photo/form/FieldsetPhotoChooser';
|
||||
|
||||
export default function ComponentsPageClient({
|
||||
export default function AdminComponentPageClient({
|
||||
photo,
|
||||
photos,
|
||||
photosCount,
|
||||
photosFavs,
|
||||
}: {
|
||||
photo: Photo
|
||||
photos: Photo[]
|
||||
photosCount: number
|
||||
photosFavs: Photo[]
|
||||
}) {
|
||||
const [valuePhoto, setValuePhoto] = useState(photo?.id ?? '');
|
||||
const [valuePhotoChooser, setValuePhotoChooser] = useState(photo?.id ?? '');
|
||||
|
||||
const [value, setValue] = useState('visible');
|
||||
|
||||
@ -39,14 +43,9 @@ export default function ComponentsPageClient({
|
||||
<FieldsetPhotoChooser
|
||||
label="Photo"
|
||||
photo={photo}
|
||||
value={valuePhotoChooser}
|
||||
onChange={setValuePhotoChooser}
|
||||
/>
|
||||
</div>
|
||||
<div className="z-13">
|
||||
<FieldsetPhotoQuery
|
||||
label="Photo"
|
||||
photos={[photo]}
|
||||
photos={photos}
|
||||
photosCount={photosCount}
|
||||
photosFavs={photosFavs}
|
||||
value={valuePhoto}
|
||||
onChange={setValuePhoto}
|
||||
/>
|
||||
|
||||
@ -37,14 +37,12 @@ import {
|
||||
} from '../app/path';
|
||||
import Modal from '../components/Modal';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import Spinner from '../components/Spinner';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { BiDesktop, BiLockAlt, BiMoon, BiSun } from 'react-icons/bi';
|
||||
import { IoClose, IoInvertModeSharp } from 'react-icons/io5';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { searchPhotosAction } from '@/photo/actions';
|
||||
import { RiToolsFill } from 'react-icons/ri';
|
||||
import { signOutAction } from '@/auth/actions';
|
||||
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
|
||||
@ -98,12 +96,12 @@ import { getSortStateFromPath } from '@/photo/sort/path';
|
||||
import IconSort from '@/components/icons/IconSort';
|
||||
import { useSelectPhotosState } from '@/admin/select/SelectPhotosState';
|
||||
import IconAlbum from '@/components/icons/IconAlbum';
|
||||
import usePhotoQuery from '@/photo/usePhotoQuery';
|
||||
|
||||
const DIALOG_TITLE = 'Global Command-K Menu';
|
||||
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
|
||||
|
||||
const LISTENER_KEYDOWN = 'keydown';
|
||||
const MINIMUM_QUERY_LENGTH = 2;
|
||||
|
||||
const MAX_HEIGHT = '20rem';
|
||||
|
||||
@ -246,19 +244,13 @@ export default function CommandKClient({
|
||||
}
|
||||
}, [isWaiting, setIsOpen]);
|
||||
|
||||
// Raw query values
|
||||
const [queryLiveRaw, setQueryLiveRaw] = useState('');
|
||||
const [queryDebouncedRaw] =
|
||||
useDebounce(queryLiveRaw, 500, { trailing: true });
|
||||
|
||||
// Parameterized query values
|
||||
const queryLive = useMemo(() =>
|
||||
queryLiveRaw.trim().toLocaleLowerCase(), [queryLiveRaw]);
|
||||
const queryDebounced = useMemo(() =>
|
||||
queryDebouncedRaw.trim().toLocaleLowerCase(), [queryDebouncedRaw]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [queriedSections, setQueriedSections] = useState<CommandKSection[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const {
|
||||
queryFormatted,
|
||||
photos,
|
||||
isLoading,
|
||||
reset,
|
||||
} = usePhotoQuery({ query, isEnabled: !isPending });
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
@ -283,14 +275,9 @@ 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
|
||||
? [{
|
||||
const queriedSections = useMemo<CommandKSection[]>(() => {
|
||||
if (isOpenRef.current && photos.length > 0) {
|
||||
return [{
|
||||
heading: 'Photos',
|
||||
accessory: <IconPhoto size={14} />,
|
||||
items: photos.map(photo => ({
|
||||
@ -300,38 +287,20 @@ export default function CommandKClient({
|
||||
accessory: <PhotoSmall photo={photo} />,
|
||||
path: pathForPhoto({ photo }),
|
||||
})),
|
||||
}]
|
||||
: []);
|
||||
}];
|
||||
} else {
|
||||
// Ignore stale requests that come in after dialog is closed
|
||||
setQueriedSections([]);
|
||||
return [];
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
setQueriedSections([]);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [queryDebounced, isPending, appText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryLive === '') {
|
||||
setQueriedSections([]);
|
||||
setIsLoading(false);
|
||||
} else if (queryLive.length >= MINIMUM_QUERY_LENGTH) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
}, [queryLive]);
|
||||
},
|
||||
[photos],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setQueryLiveRaw('');
|
||||
setQueriedSections([]);
|
||||
setIsLoading(false);
|
||||
setQuery('');
|
||||
reset();
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, reset]);
|
||||
|
||||
const recent = recents[0];
|
||||
const recentsStatus = useMemo(() => {
|
||||
@ -345,17 +314,17 @@ export default function CommandKClient({
|
||||
|
||||
// Years only accessible by search
|
||||
const years = useMemo(() =>
|
||||
_years.filter(({ year }) => queryLive && year.includes(queryLive))
|
||||
, [_years, queryLive]);
|
||||
_years.filter(({ year }) => queryFormatted && year.includes(queryFormatted))
|
||||
, [_years, queryFormatted]);
|
||||
|
||||
const tags = useMemo(() => {
|
||||
const tagsIncludingPrivate = photosCountHidden > 0
|
||||
? addPrivateToTags(_tags, photosCountHidden)
|
||||
: _tags;
|
||||
return HIDE_TAGS_WITH_ONE_PHOTO
|
||||
? limitTagsByCount(tagsIncludingPrivate, 2, queryLive)
|
||||
? limitTagsByCount(tagsIncludingPrivate, 2, queryFormatted)
|
||||
: tagsIncludingPrivate;
|
||||
}, [_tags, photosCountHidden, queryLive]);
|
||||
}, [_tags, photosCountHidden, queryFormatted]);
|
||||
|
||||
const categorySections: CommandKSection[] = useMemo(() =>
|
||||
CATEGORY_VISIBILITY
|
||||
@ -751,9 +720,9 @@ export default function CommandKClient({
|
||||
)}>
|
||||
<Command.Input
|
||||
ref={refInput}
|
||||
value={queryLiveRaw}
|
||||
value={query}
|
||||
onValueChange={value => {
|
||||
setQueryLiveRaw(value);
|
||||
setQuery(value);
|
||||
updateMask();
|
||||
}}
|
||||
className={clsx(
|
||||
@ -782,15 +751,15 @@ export default function CommandKClient({
|
||||
'text-gray-400/90 dark:text-gray-700',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (queryLiveRaw) {
|
||||
setQueryLiveRaw('');
|
||||
if (query) {
|
||||
setQuery('');
|
||||
updateMask();
|
||||
} else {
|
||||
setIsOpen?.(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{queryLiveRaw
|
||||
{query
|
||||
? <IoClose size={17} className="text-dim" />
|
||||
: <>
|
||||
<span className="sm:hidden">
|
||||
@ -889,7 +858,7 @@ export default function CommandKClient({
|
||||
/>;
|
||||
})}
|
||||
</Command.Group>)}
|
||||
{footer && !queryLive &&
|
||||
{footer && !queryFormatted &&
|
||||
<div className={clsx(
|
||||
'text-center text-base text-dim pt-1',
|
||||
'pb-2',
|
||||
|
||||
56
src/components/SegmentMenu.tsx
Normal file
56
src/components/SegmentMenu.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import clsx from 'clsx/lite';
|
||||
import { ReactNode } from 'react';
|
||||
import Spinner from './Spinner';
|
||||
|
||||
export default function SegmentMenu<T extends string>({
|
||||
items,
|
||||
selected,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
items: {
|
||||
value: T
|
||||
icon?: ReactNode
|
||||
iconSelected?: ReactNode
|
||||
isLoading?: boolean
|
||||
}[]
|
||||
selected: T
|
||||
onChange: (value: T) => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'flex justify-center gap-1',
|
||||
className,
|
||||
)}>
|
||||
{items.map(({ value, icon, iconSelected, isLoading }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => onChange(value)}
|
||||
className={clsx(
|
||||
'link',
|
||||
'rounded-full',
|
||||
value === selected
|
||||
? 'bg-dim text-main'
|
||||
: 'bg-transparent text-medium',
|
||||
'flex items-center justify-center',
|
||||
'h-7 min-w-14',
|
||||
'active:bg-extra-dim',
|
||||
)}
|
||||
>
|
||||
{isLoading
|
||||
? <Spinner />
|
||||
: icon
|
||||
? selected === value && iconSelected
|
||||
? iconSelected
|
||||
: icon
|
||||
: <span className={clsx(
|
||||
'text-sm font-medium uppercase tracking-wider',
|
||||
)}>
|
||||
{value}
|
||||
</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,21 +11,7 @@ import { FiMoreHorizontal } from 'react-icons/fi';
|
||||
import MoreMenuItem from './MoreMenuItem';
|
||||
import { clearGlobalFocus } from '@/utility/dom';
|
||||
import { FaChevronRight } from 'react-icons/fa6';
|
||||
|
||||
const surfaceStyles = (className?: string) => clsx(
|
||||
'z-10',
|
||||
'min-w-[8rem]',
|
||||
'component-surface',
|
||||
'py-1',
|
||||
'not-dark:shadow-lg not-dark:shadow-gray-900/10',
|
||||
'data-[side=top]:dark:shadow-[0_0px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=bottom]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=right]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=top]:animate-fade-in-from-bottom',
|
||||
'data-[side=bottom]:animate-fade-in-from-top',
|
||||
'data-[side=right]:animate-fade-in-from-top',
|
||||
className,
|
||||
);
|
||||
import { MENU_SURFACE_STYLES } from '../primitives/surface';
|
||||
|
||||
export type MoreMenuSection = {
|
||||
label?: string
|
||||
@ -115,7 +101,10 @@ export default function MoreMenu({
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={surfaceStyles(className)}
|
||||
className={clsx(
|
||||
MENU_SURFACE_STYLES,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{header && <div className={clsx(
|
||||
'px-3 pt-3 pb-2 text-dim uppercase',
|
||||
@ -171,7 +160,7 @@ export default function MoreMenu({
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.SubContent
|
||||
className={surfaceStyles()}
|
||||
className={MENU_SURFACE_STYLES}
|
||||
>
|
||||
{item.items.map(item =>
|
||||
<div key={item.label} className="px-1">
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { ReactNode, useRef, useState, ComponentProps } from 'react';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import MenuSurface from './MenuSurface';
|
||||
import ComponentSurface from './surface/ComponentSurface';
|
||||
import clsx from 'clsx/lite';
|
||||
import useClickInsideOutside from '@/utility/useClickInsideOutside';
|
||||
import KeyCommand from './KeyCommand';
|
||||
@ -31,7 +31,7 @@ export default function TooltipPrimitive({
|
||||
children: ReactNode
|
||||
className?: string
|
||||
classNameTrigger?: string
|
||||
color?: ComponentProps<typeof MenuSurface>['color']
|
||||
color?: ComponentProps<typeof ComponentSurface>['color']
|
||||
keyCommand?: string
|
||||
keyCommandModifier?: ComponentProps<typeof KeyCommand>['modifier']
|
||||
supportMobile?: boolean
|
||||
@ -126,9 +126,9 @@ export default function TooltipPrimitive({
|
||||
)}
|
||||
>
|
||||
{content &&
|
||||
<MenuSurface {...{ color, className }}>
|
||||
<ComponentSurface {...{ color, className }}>
|
||||
{content}
|
||||
</MenuSurface>}
|
||||
</ComponentSurface>}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
|
||||
@ -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,
|
||||
15
src/components/primitives/surface/index.ts
Normal file
15
src/components/primitives/surface/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import clsx from 'clsx/lite';
|
||||
|
||||
export const MENU_SURFACE_STYLES = clsx(
|
||||
'z-10',
|
||||
'min-w-[8rem]',
|
||||
'component-surface',
|
||||
'py-1',
|
||||
'not-dark:shadow-lg not-dark:shadow-gray-900/10',
|
||||
'data-[side=top]:dark:shadow-[0_0px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=bottom]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=right]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=top]:animate-fade-in-from-bottom',
|
||||
'data-[side=bottom]:animate-fade-in-from-top',
|
||||
'data-[side=right]:animate-fade-in-from-top',
|
||||
);
|
||||
@ -10,7 +10,7 @@ import {
|
||||
} from 'react';
|
||||
import { SharedHoverContext, SharedHoverProps } from './state';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import MenuSurface from '../primitives/MenuSurface';
|
||||
import ComponentSurface from '../primitives/surface/ComponentSurface';
|
||||
import clsx from 'clsx/lite';
|
||||
|
||||
const WINDOW_CHANGE_EVENTS = ['mouseup', 'mousewheel', 'resize'];
|
||||
@ -133,7 +133,7 @@ export default function SharedHoverProvider({
|
||||
className="fixed"
|
||||
style={hoverStyle}
|
||||
>
|
||||
<MenuSurface
|
||||
<ComponentSurface
|
||||
className="max-w-none p-1!"
|
||||
color={hoverProps.color}
|
||||
>
|
||||
@ -158,7 +158,7 @@ export default function SharedHoverProvider({
|
||||
: 'border-medium',
|
||||
)} />
|
||||
</div>
|
||||
</MenuSurface>
|
||||
</ComponentSurface>
|
||||
</motion.div>}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
SetStateAction,
|
||||
use,
|
||||
} from 'react';
|
||||
import MenuSurface from '../primitives/MenuSurface';
|
||||
import ComponentSurface from '../primitives/surface/ComponentSurface';
|
||||
|
||||
export type SharedHoverProps = {
|
||||
key: string
|
||||
@ -14,7 +14,7 @@ export type SharedHoverProps = {
|
||||
height: number
|
||||
offsetAbove: number
|
||||
offsetBelow: number
|
||||
color?: ComponentProps<typeof MenuSurface>['color']
|
||||
color?: ComponentProps<typeof ComponentSurface>['color']
|
||||
}
|
||||
|
||||
export type SharedHoverState = {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import { altTextForPhoto, doesPhotoNeedBlurCompatibility, Photo } from '.';
|
||||
import clsx from 'clsx/lite';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import ImageMedium from '@/components/image/ImageMedium';
|
||||
import PhotoGridInfinite from './PhotoGridInfinite';
|
||||
|
||||
export default function FieldsetPhotoChooser({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
photo,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
photo?: Photo
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<FieldsetWithStatus {...{ label, value, onChange, type: 'hidden' }} />
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button type="button" className="p-1.5">
|
||||
{photo &&
|
||||
<span className={clsx(
|
||||
'flex w-[8rem]',
|
||||
'border border-medium rounded-[4px]',
|
||||
'overflow-hidden select-none active:opacity-75',
|
||||
)}>
|
||||
<ImageMedium
|
||||
src={photo.url}
|
||||
alt={altTextForPhoto(photo)}
|
||||
aspectRatio={photo.aspectRatio}
|
||||
blurDataURL={photo.blurData}
|
||||
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
|
||||
/>
|
||||
</span>}
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
// alignOffset={-10}
|
||||
className={clsx(
|
||||
'z-20',
|
||||
'min-w-[8rem]',
|
||||
'component-surface',
|
||||
'p-1.5',
|
||||
'not-dark:shadow-lg not-dark:shadow-gray-900/10',
|
||||
'data-[side=top]:dark:shadow-[0_0px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=bottom]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=right]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=top]:animate-fade-in-from-bottom',
|
||||
'data-[side=bottom]:animate-fade-in-from-top',
|
||||
'data-[side=right]:animate-fade-in-from-top',
|
||||
)}>
|
||||
<div className={clsx(
|
||||
'w-[14rem] max-h-[20rem] rounded-[3px] overflow-y-auto',
|
||||
'space-y-1',
|
||||
)}>
|
||||
<PhotoGridInfinite
|
||||
cacheKey="photo-chooser-menu"
|
||||
initialOffset={0}
|
||||
sortBy="takenAt"
|
||||
animate={false}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import { Photo } from '.';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnnotatedTag } from './form';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import PhotoSmall from './PhotoSmall';
|
||||
import { getPhotosAction } from './actions';
|
||||
|
||||
const convertPhotoToAnnotatedTag = (photo: Photo): AnnotatedTag => ({
|
||||
value: photo.id,
|
||||
label: photo.title,
|
||||
icon: <div className="w-[3rem] overflow-hidden rounded-[3px]">
|
||||
<PhotoSmall photo={photo} />
|
||||
</div>,
|
||||
});
|
||||
|
||||
export default function FieldsetPhotoQuery({
|
||||
label,
|
||||
photos = [],
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
photos?: Photo[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [queryDebounced] = useDebounce(query, 500);
|
||||
const [isQuerying, setIsQuerying] = useState(false);
|
||||
|
||||
const [photoOptions, setPhotoOptions] = useState<AnnotatedTag[]>(photos
|
||||
.map(convertPhotoToAnnotatedTag),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryDebounced) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsQuerying(true);
|
||||
getPhotosAction({ query: queryDebounced })
|
||||
.then(photos => {
|
||||
setPhotoOptions(photos.map(convertPhotoToAnnotatedTag));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsQuerying(false);
|
||||
});
|
||||
} else {
|
||||
setPhotoOptions([]);
|
||||
}
|
||||
}, [queryDebounced]);
|
||||
|
||||
return (
|
||||
<FieldsetWithStatus
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
tagOptions={photoOptions}
|
||||
tagOptionsOnInputTextChange={setQuery}
|
||||
tagOptionsLabelOverride={value =>
|
||||
photoOptions.find(option => option.value === value)?.label}
|
||||
tagOptionsAllowNewValues={false}
|
||||
tagOptionsShouldParameterize={false}
|
||||
tagOptionsLimit={1}
|
||||
loading={isQuerying}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
243
src/photo/form/FieldsetPhotoChooser.tsx
Normal file
243
src/photo/form/FieldsetPhotoChooser.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import {
|
||||
altTextForPhoto,
|
||||
doesPhotoNeedBlurCompatibility,
|
||||
INFINITE_SCROLL_GRID_MULTIPLE,
|
||||
Photo,
|
||||
} from '..';
|
||||
import clsx from 'clsx/lite';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import ImageMedium from '@/components/image/ImageMedium';
|
||||
import { MENU_SURFACE_STYLES } from '@/components/primitives/surface';
|
||||
import { IoSearch } from 'react-icons/io5';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import usePhotoQuery from '../usePhotoQuery';
|
||||
import { BiChevronRight } from 'react-icons/bi';
|
||||
import SegmentMenu from '@/components/SegmentMenu';
|
||||
import IconFavs from '@/components/icons/IconFavs';
|
||||
import InfinitePhotoScroll from '../InfinitePhotoScroll';
|
||||
import AdminEmptyState from '@/admin/AdminEmptyState';
|
||||
import { TbPhotoSearch } from 'react-icons/tb';
|
||||
|
||||
type Mode = 'all' | 'favs' | 'search';
|
||||
|
||||
const CLASSNAME_GRID = 'grid grid-cols-3 gap-0.5';
|
||||
|
||||
const renderPhoto = (photo: Photo) =>
|
||||
<ImageMedium
|
||||
src={photo.url}
|
||||
alt={altTextForPhoto(photo)}
|
||||
aspectRatio={photo.aspectRatio}
|
||||
blurDataURL={photo.blurData}
|
||||
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
|
||||
/>;
|
||||
|
||||
export default function FieldsetPhotoChooser({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
photo: _photo,
|
||||
photos = [],
|
||||
photosCount,
|
||||
photosFavs,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (photoId: string) => void
|
||||
photo?: Photo
|
||||
photos: Photo[]
|
||||
photosCount: number
|
||||
photosFavs: Photo[]
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [photo, setPhoto] = useState(_photo);
|
||||
|
||||
const [mode, setMode] = useState<Mode>('all');
|
||||
|
||||
const showQuery = mode === 'search';
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const {
|
||||
photos: photosQuery,
|
||||
isLoading: isLoadingPhotoQuery,
|
||||
reset: resetPhotoQuery,
|
||||
resultsNotFound,
|
||||
} = usePhotoQuery({ query, isPrivate: true });
|
||||
|
||||
const reset = useCallback((resetMenu?: boolean) => {
|
||||
resetPhotoQuery();
|
||||
setQuery('');
|
||||
if (resetMenu) { setMode('all'); }
|
||||
}, [resetPhotoQuery]);
|
||||
|
||||
// Focus input on query mode
|
||||
useEffect(() => {
|
||||
if (showQuery) { inputRef.current?.focus(); }
|
||||
}, [showQuery]);
|
||||
|
||||
// Reset menu when closed
|
||||
useEffect(() => {
|
||||
if (!isOpen) { reset(true); }
|
||||
}, [isOpen, reset]);
|
||||
|
||||
const renderPhotoButton = (photo: Photo) =>
|
||||
<span
|
||||
key={photo.id}
|
||||
className={clsx(
|
||||
'flex w-full aspect-square object-cover',
|
||||
'overflow-hidden select-none active:opacity-75',
|
||||
'cursor-pointer',
|
||||
)}
|
||||
onClick={() => {
|
||||
setPhoto(photo);
|
||||
onChange(photo.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{renderPhoto(photo)}
|
||||
</span>;
|
||||
|
||||
const photosToShow = showQuery && query
|
||||
? photosQuery
|
||||
: mode === 'favs'
|
||||
? photosFavs : photos;
|
||||
|
||||
const shouldPaginate =
|
||||
!(showQuery && query) &&
|
||||
photosCount > photos.length &&
|
||||
mode !== 'favs';
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldsetWithStatus {...{ label, value, onChange, type: 'hidden' }} />
|
||||
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button type="button" className={clsx(
|
||||
'inline-flex flex-col p-1.5 pt-0 gap-0',
|
||||
)}>
|
||||
<span className={clsx(
|
||||
'w-full',
|
||||
'flex items-center gap-1',
|
||||
'font-sans',
|
||||
'text-xs text-medium font-medium uppercase tracking-wider',
|
||||
'py-1',
|
||||
'select-none',
|
||||
)}>
|
||||
<span className="grow truncate text-left">
|
||||
{label}
|
||||
</span>
|
||||
<BiChevronRight
|
||||
size={18}
|
||||
className={clsx(
|
||||
'transition-transform ',
|
||||
isOpen && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'flex size-[6rem]',
|
||||
'border border-medium rounded-[4px]',
|
||||
'overflow-hidden select-none active:opacity-75',
|
||||
)}>
|
||||
{photo && renderPhoto(photo)}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
align="start"
|
||||
sideOffset={-80}
|
||||
className={clsx(
|
||||
MENU_SURFACE_STYLES,
|
||||
'z-20 rounded-2xl pb-0 overflow-auto',
|
||||
)}
|
||||
>
|
||||
<SegmentMenu
|
||||
className="pt-1 pb-2 px-1.5"
|
||||
items={[{
|
||||
value: 'all',
|
||||
}, {
|
||||
value: 'favs',
|
||||
icon: <IconFavs size={16} />,
|
||||
iconSelected: <IconFavs size={16} highlight />,
|
||||
}, {
|
||||
value: 'search',
|
||||
icon: <IoSearch size={16} />,
|
||||
isLoading: isLoadingPhotoQuery,
|
||||
}]}
|
||||
selected={mode}
|
||||
onChange={mode => {
|
||||
setMode(mode);
|
||||
if (mode !== 'search') {
|
||||
reset();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className={clsx(
|
||||
'w-[18rem] h-[20rem] overflow-y-auto',
|
||||
'space-y-0.5',
|
||||
)}>
|
||||
<div className={clsx(
|
||||
'flex items-center transition-all overflow-hidden',
|
||||
showQuery ? 'h-12 opacity-100' : 'h-0 opacity-0',
|
||||
)}>
|
||||
<div className="w-full px-1.5">
|
||||
<input
|
||||
id="query"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search for a photo"
|
||||
className={clsx(
|
||||
'block w-full m-0 outline-none',
|
||||
'rounded-full border border-dim',
|
||||
'mb-2',
|
||||
)}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showQuery && resultsNotFound &&
|
||||
<AdminEmptyState
|
||||
icon={<IoSearch className="text-dim" />}
|
||||
className="translate-y-8"
|
||||
includeContainer={false}
|
||||
>
|
||||
No photos found
|
||||
</AdminEmptyState>}
|
||||
{!showQuery && photosToShow.length === 0 &&
|
||||
<AdminEmptyState
|
||||
icon={<TbPhotoSearch className="text-dim" />}
|
||||
className="translate-y-16"
|
||||
includeContainer={false}
|
||||
>
|
||||
No photos
|
||||
</AdminEmptyState>}
|
||||
<div className={CLASSNAME_GRID}>
|
||||
{photosToShow.map(photo => renderPhotoButton(photo))}
|
||||
</div>
|
||||
{shouldPaginate &&
|
||||
<InfinitePhotoScroll
|
||||
cacheKey="photo-chooser"
|
||||
initialOffset={photos.length}
|
||||
itemsPerPage={INFINITE_SCROLL_GRID_MULTIPLE}
|
||||
moreButtonClassName="mt-2"
|
||||
>
|
||||
{({ key, photos }) => (
|
||||
<div key={key} className={CLASSNAME_GRID}>
|
||||
{photos.map(photo => renderPhotoButton(photo))}
|
||||
</div>
|
||||
)}
|
||||
</InfinitePhotoScroll>}
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
73
src/photo/usePhotoQuery.ts
Normal file
73
src/photo/usePhotoQuery.ts
Normal file
@ -0,0 +1,73 @@
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Photo } from '.';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { getPhotosAction, searchPhotosPublicAction } from './actions';
|
||||
|
||||
const formatQuery = (query: string) =>
|
||||
query.trim().toLocaleLowerCase();
|
||||
|
||||
export default function usePhotoQuery({
|
||||
query,
|
||||
isEnabled = true,
|
||||
minimumQueryLength = 2,
|
||||
isPrivate,
|
||||
}: {
|
||||
query: string
|
||||
isEnabled?: boolean
|
||||
minimumQueryLength?: number
|
||||
isPrivate?: boolean
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryFormatted = useMemo(() =>
|
||||
formatQuery(query), [query]);
|
||||
const [_queryDebounced] = useDebounce(query, 500, { leading: true });
|
||||
const queryDebounced = useMemo(() =>
|
||||
formatQuery(_queryDebounced), [_queryDebounced]);
|
||||
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
|
||||
const resultsNotFound =
|
||||
queryDebounced.length >= minimumQueryLength &&
|
||||
!isLoading &&
|
||||
photos.length === 0;
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPhotos([]);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryDebounced.length >= minimumQueryLength && isEnabled) {
|
||||
setIsLoading(true);
|
||||
(isPrivate
|
||||
? getPhotosAction({ query: queryDebounced })
|
||||
: searchPhotosPublicAction(queryDebounced))
|
||||
.then(setPhotos)
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
}, [
|
||||
queryDebounced,
|
||||
minimumQueryLength,
|
||||
isEnabled,
|
||||
isPrivate,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryFormatted.length >= minimumQueryLength) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
setPhotos([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [minimumQueryLength, queryFormatted]);
|
||||
|
||||
return {
|
||||
queryFormatted,
|
||||
photos,
|
||||
isLoading,
|
||||
resultsNotFound,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user