Sketch out photo chooser component
This commit is contained in:
parent
5970bfb850
commit
b7f8b9fa15
@ -1,111 +1,10 @@
|
||||
'use client';
|
||||
import AdminComponentPageClient from '@/admin/AdminComponentPageClient';
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
|
||||
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';
|
||||
export default async function ComponentsPage() {
|
||||
const photos = await getPhotosCached({ limit: 1});
|
||||
|
||||
export default function ComponentsPage() {
|
||||
const [value, setValue] = useState('visible');
|
||||
return (
|
||||
<AppGrid
|
||||
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>}
|
||||
/>
|
||||
<AdminComponentPageClient photo={photos[0]} />
|
||||
);
|
||||
}
|
||||
|
||||
138
src/admin/AdminComponentPageClient.tsx
Normal file
138
src/admin/AdminComponentPageClient.tsx
Normal 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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -71,7 +71,7 @@ export default function FieldsetWithStatus({
|
||||
tagOptionsShouldParameterize?: boolean
|
||||
tagOptionsDefaultIcon?: ReactNode
|
||||
tagOptionsDefaultIconSelected?: ReactNode
|
||||
tagOptionsLabelOverride?: (value: string) => string
|
||||
tagOptionsLabelOverride?: (value: string) => string | undefined
|
||||
tagOptionsAllowNewValues?: boolean
|
||||
tagOptionsAccessory?: ReactNode
|
||||
tagOptionsOnInputTextChange?: (value: string) => void
|
||||
|
||||
@ -41,7 +41,7 @@ export default function TagInput({
|
||||
name: string
|
||||
value?: string
|
||||
options?: AnnotatedTag[]
|
||||
labelForValueOverride?: (value: string) => string
|
||||
labelForValueOverride?: (value: string) => string | undefined
|
||||
defaultIcon?: ReactNode
|
||||
defaultIconSelected?: ReactNode
|
||||
accessory?: ReactNode
|
||||
@ -416,7 +416,7 @@ export default function TagInput({
|
||||
<div
|
||||
className={clsx(
|
||||
'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',
|
||||
'shadow-lg dark:shadow-xl',
|
||||
)}
|
||||
|
||||
76
src/photo/FieldsetPhotoChooser.tsx
Normal file
76
src/photo/FieldsetPhotoChooser.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
src/photo/FieldsetPhotoQuery.tsx
Normal file
69
src/photo/FieldsetPhotoQuery.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user