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';
|
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>}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@ -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',
|
||||||
)}
|
)}
|
||||||
|
|||||||
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