Sketch out photo chooser component

This commit is contained in:
Sam Becker 2026-02-27 20:29:55 -06:00
parent 5970bfb850
commit b7f8b9fa15
6 changed files with 291 additions and 109 deletions

View File

@ -1,111 +1,10 @@
'use client'; import AdminComponentPageClient from '@/admin/AdminComponentPageClient';
import { getPhotosCached } from '@/photo/cache';
import FieldsetTag from '@/tag/FieldsetTag'; export default async function ComponentsPage() {
import AppGrid from '@/components/AppGrid'; const photos = await getPhotosCached({ limit: 1});
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import IconHidden from '@/components/icons/IconHidden';
import IconLock from '@/components/icons/IconLock';
import SelectMenu from '@/components/SelectMenu';
import StatusIcon from '@/components/StatusIcon';
import clsx from 'clsx/lite';
import { useState } from 'react';
export default function ComponentsPage() {
const [value, setValue] = useState('visible');
return ( return (
<AppGrid <AdminComponentPageClient photo={photos[0]} />
contentMain={<div className="flex flex-col gap-4">
<div className={clsx(
'flex gap-0.5',
'*:inline-flex *:bg-medium',
)}>
<StatusIcon type="checked" />
<StatusIcon type="missing" />
<StatusIcon type="warning" />
<StatusIcon type="optional" />
</div>
<div className="z-12">
<FieldsetTag
tags="tag-1"
tagOptions={[{
tag: 'Tag 1',
count: 1,
lastModified: new Date(),
}, {
tag: 'Tag 2',
count: 1,
lastModified: new Date(),
}]}
onChange={() => {}}
onError={() => {}}
openOnLoad={false}
/>
</div>
<div className="z-11">
<FieldsetWithStatus
label="Select"
value="tag-1"
selectOptions={[{
value: 'tag-1',
label: 'Tag 1',
}, {
value: 'tag-2',
label: 'Tag 2',
}]}
onChange={() => {}}
/>
</div>
<div className="z-9 mt-12">
<SelectMenu
name="select-menu"
value={value}
onChange={setValue}
options={[{
value: 'visible',
accessoryStart: <IconHidden size={15} visible />,
label: 'Always visible',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'hidden',
accessoryStart: <IconHidden size={15} />,
label: 'Hide from feeds',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private1',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private4',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private2',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private3',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}]}
/>
</div>
</div>}
/>
); );
} }

View File

@ -0,0 +1,138 @@
'use client';
import FieldsetTag from '@/tag/FieldsetTag';
import AppGrid from '@/components/AppGrid';
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import IconHidden from '@/components/icons/IconHidden';
import IconLock from '@/components/icons/IconLock';
import SelectMenu from '@/components/SelectMenu';
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';
export default function ComponentsPageClient({
photo,
}: {
photo: Photo
}) {
const [valuePhoto, setValuePhoto] = useState(photo?.id ?? '');
const [valuePhotoChooser, setValuePhotoChooser] = useState(photo?.id ?? '');
const [value, setValue] = useState('visible');
return (
<AppGrid
contentMain={<div className="flex flex-col gap-4">
<div className={clsx(
'flex gap-1',
'*:inline-flex *:bg-medium *:rounded-[3px]',
)}>
<StatusIcon type="checked" />
<StatusIcon type="missing" />
<StatusIcon type="warning" />
<StatusIcon type="optional" />
</div>
<div className="z-14">
<FieldsetPhotoChooser
label="Photo"
photo={photo}
value={valuePhotoChooser}
onChange={setValuePhotoChooser}
/>
</div>
<div className="z-13">
<FieldsetPhotoQuery
label="Photo"
photos={[photo]}
value={valuePhoto}
onChange={setValuePhoto}
/>
</div>
<div className="z-12">
<FieldsetTag
tags="tag-1"
tagOptions={[{
tag: 'Tag 1',
count: 1,
lastModified: new Date(),
}, {
tag: 'Tag 2',
count: 1,
lastModified: new Date(),
}]}
onChange={() => {}}
onError={() => {}}
openOnLoad={false}
/>
</div>
<div className="z-11">
<FieldsetWithStatus
label="Select"
value="tag-1"
selectOptions={[{
value: 'tag-1',
label: 'Tag 1',
}, {
value: 'tag-2',
label: 'Tag 2',
}]}
onChange={() => {}}
/>
</div>
<div className="z-9">
<SelectMenu
name="select-menu"
value={value}
onChange={setValue}
options={[{
value: 'visible',
accessoryStart: <IconHidden size={15} visible />,
label: 'Always visible',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'hidden',
accessoryStart: <IconHidden size={15} />,
label: 'Hide from feeds',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private1',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private4',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private2',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private3',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}]}
/>
</div>
</div>}
/>
);
}

View File

@ -71,7 +71,7 @@ export default function FieldsetWithStatus({
tagOptionsShouldParameterize?: boolean tagOptionsShouldParameterize?: boolean
tagOptionsDefaultIcon?: ReactNode tagOptionsDefaultIcon?: ReactNode
tagOptionsDefaultIconSelected?: ReactNode tagOptionsDefaultIconSelected?: ReactNode
tagOptionsLabelOverride?: (value: string) => string tagOptionsLabelOverride?: (value: string) => string | undefined
tagOptionsAllowNewValues?: boolean tagOptionsAllowNewValues?: boolean
tagOptionsAccessory?: ReactNode tagOptionsAccessory?: ReactNode
tagOptionsOnInputTextChange?: (value: string) => void tagOptionsOnInputTextChange?: (value: string) => void

View File

@ -41,7 +41,7 @@ export default function TagInput({
name: string name: string
value?: string value?: string
options?: AnnotatedTag[] options?: AnnotatedTag[]
labelForValueOverride?: (value: string) => string labelForValueOverride?: (value: string) => string | undefined
defaultIcon?: ReactNode defaultIcon?: ReactNode
defaultIconSelected?: ReactNode defaultIconSelected?: ReactNode
accessory?: ReactNode accessory?: ReactNode
@ -416,7 +416,7 @@ export default function TagInput({
<div <div
className={clsx( className={clsx(
'component-surface', 'component-surface',
'absolute top-3 w-full px-1.5 py-1.5', 'absolute top-3 w-full px-1.5 py-1.5 -mx-px',
'max-h-[8rem] overflow-y-auto flex flex-col', 'max-h-[8rem] overflow-y-auto flex flex-col',
'shadow-lg dark:shadow-xl', 'shadow-lg dark:shadow-xl',
)} )}

View File

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

View File

@ -0,0 +1,69 @@
'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}
/>
);
}