Refine photo tag control

This commit is contained in:
Sam Becker 2024-07-20 15:58:09 -05:00
parent 235d73db3f
commit dc8dedd806
6 changed files with 121 additions and 74 deletions

View File

@ -5,11 +5,7 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import Container from '@/components/Container'; import Container from '@/components/Container';
import { addAllUploadsAction } from '@/photo/actions'; import { addAllUploadsAction } from '@/photo/actions';
import { PATH_ADMIN_PHOTOS } from '@/site/paths'; import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { import { Tags } from '@/tag';
Tags,
convertTagsForForm,
getValidationMessageForTags,
} from '@/tag';
import { import {
generateLocalNaivePostgresString, generateLocalNaivePostgresString,
generateLocalPostgresString, generateLocalPostgresString,
@ -22,6 +18,7 @@ import { Dispatch, SetStateAction, useRef, useState } from 'react';
import { BiCheckCircle, BiImageAdd } from 'react-icons/bi'; import { BiCheckCircle, BiImageAdd } from 'react-icons/bi';
import ProgressButton from '@/components/primitives/ProgressButton'; import ProgressButton from '@/components/primitives/ProgressButton';
import { UrlAddStatus } from './AdminUploadsClient'; import { UrlAddStatus } from './AdminUploadsClient';
import PhotoTagFieldset from './PhotoTagFieldset';
const UPLOAD_BATCH_SIZE = 4; const UPLOAD_BATCH_SIZE = 4;
@ -38,8 +35,6 @@ export default function AdminAddAllUploads({
setIsAdding: (isAdding: boolean) => void setIsAdding: (isAdding: boolean) => void
setUrlAddStatuses: Dispatch<SetStateAction<UrlAddStatus[]>> setUrlAddStatuses: Dispatch<SetStateAction<UrlAddStatus[]>>
}) { }) {
const divRef = useRef<HTMLDivElement>(null);
const [buttonText, setButtonText] = useState('Add All Uploads'); const [buttonText, setButtonText] = useState('Add All Uploads');
const [showTags, setShowTags] = useState(false); const [showTags, setShowTags] = useState(false);
const [tags, setTags] = useState(''); const [tags, setTags] = useState('');
@ -121,36 +116,20 @@ export default function AdminAddAllUploads({
label="Apply tags" label="Apply tags"
type="checkbox" type="checkbox"
value={showTags ? 'true' : 'false'} value={showTags ? 'true' : 'false'}
onChange={value => { onChange={value => setShowTags(value === 'true')}
setShowTags(value === 'true');
if (value === 'true') {
setTimeout(() =>
divRef.current?.querySelectorAll('input')[0]?.focus()
, 100);
}
}}
readOnly={isAdding} readOnly={isAdding}
/> />
</div> </div>
<div {showTags && !actionErrorMessage &&
ref={divRef} <PhotoTagFieldset
className={showTags && !actionErrorMessage ? undefined : 'hidden'} tags={tags}
> tagOptions={uniqueTags}
<FieldSetWithStatus onChange={setTags}
id="tags" onError={setTagErrorMessage}
label="Optional Tags"
tagOptions={convertTagsForForm(uniqueTags)}
value={tags}
onChange={tags => {
setTags(tags);
setTagErrorMessage(getValidationMessageForTags(tags) ?? '');
}}
readOnly={isAdding} readOnly={isAdding}
error={tagErrorMessage} openOnLoad
required={false}
hideLabel hideLabel
/> />}
</div>
<div className="space-y-2"> <div className="space-y-2">
<ProgressButton <ProgressButton
primary primary

View File

@ -30,7 +30,7 @@ export default function AdminAppMenu() {
className="text-[18px] translate-y-[-0.5px]" className="text-[18px] translate-y-[-0.5px]"
/> />
: <ImCheckboxUnchecked : <ImCheckboxUnchecked
className="text-[0.75rem]" className="text-[0.75rem] translate-y-[-0.5px]"
/>, />,
href: PATH_GRID_INFERRED, href: PATH_GRID_INFERRED,
action: () => { action: () => {

View File

@ -2,8 +2,8 @@ import { getUniqueTagsCached } from '@/photo/cache';
import AdminBatchEditPanelClient from './AdminBatchEditPanelClient'; import AdminBatchEditPanelClient from './AdminBatchEditPanelClient';
export default async function AdminBatchEditPanel() { export default async function AdminBatchEditPanel() {
const existingTags = await getUniqueTagsCached(); const uniqueTags = await getUniqueTagsCached();
return ( return (
<AdminBatchEditPanelClient {...{ existingTags }} /> <AdminBatchEditPanelClient {...{ uniqueTags }} />
); );
} }

View File

@ -8,15 +8,15 @@ import { clsx } from 'clsx/lite';
import { IoCloseSharp } from 'react-icons/io5'; import { IoCloseSharp } from 'react-icons/io5';
import DeleteButton from './DeleteButton'; import DeleteButton from './DeleteButton';
import { useState } from 'react'; import { useState } from 'react';
import TagInput from '@/components/TagInput'; import { Tags } from '@/tag';
import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { PATH_GRID_INFERRED } from '@/site/paths'; import { PATH_GRID_INFERRED } from '@/site/paths';
import PhotoTagFieldset from './PhotoTagFieldset';
export default function AdminBatchEditPanelClient({ export default function AdminBatchEditPanelClient({
existingTags, uniqueTags,
}: { }: {
existingTags: Tags uniqueTags: Tags
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
@ -27,7 +27,7 @@ export default function AdminBatchEditPanelClient({
} = useAppState(); } = useAppState();
const [tags, setTags] = useState<string>(); const [tags, setTags] = useState<string>();
const tagValidationMessage = getValidationMessageForTags(tags); const [tagErrorMessage, setTagErrorMessage] = useState('');
const isTagging = tags !== undefined; const isTagging = tags !== undefined;
const photosPlural = selectedPhotoIds?.length === 1 ? 'photo' : 'photos'; const photosPlural = selectedPhotoIds?.length === 1 ? 'photo' : 'photos';
@ -40,7 +40,10 @@ export default function AdminBatchEditPanelClient({
? <> ? <>
<LoaderButton <LoaderButton
className="min-h-[2.5rem]" className="min-h-[2.5rem]"
onClick={() => setTags(undefined)} onClick={() => {
setTags(undefined);
setTagErrorMessage('');
}}
> >
Cancel Cancel
</LoaderButton> </LoaderButton>
@ -48,7 +51,7 @@ export default function AdminBatchEditPanelClient({
className="min-h-[2.5rem]" className="min-h-[2.5rem]"
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
confirmText={`Are you sure you want to apply tags to ${selectedPhotoIds?.length} ${photosPlural}? This action cannot be undone.`} confirmText={`Are you sure you want to apply tags to ${selectedPhotoIds?.length} ${photosPlural}? This action cannot be undone.`}
disabled={!tags || Boolean(tagValidationMessage)} disabled={!tags || Boolean(tagErrorMessage)}
primary primary
> >
Apply Tags Apply Tags
@ -75,35 +78,42 @@ export default function AdminBatchEditPanelClient({
) )
? <SiteGrid ? <SiteGrid
className="sticky top-0 z-10 mb-5 -mt-2 pt-2" className="sticky top-0 z-10 mb-5 -mt-2 pt-2"
contentMain={<Note contentMain={<div className="flex flex-col gap-2">
color="gray" <Note
className={clsx( color="gray"
'min-h-[3.5rem]', className={clsx(
'backdrop-blur-lg !border-transparent', 'min-h-[3.5rem]',
'!text-gray-900 dark:!text-gray-100', 'backdrop-blur-lg !border-transparent',
'!bg-gray-100/90 dark:!bg-gray-900/70', '!text-gray-900 dark:!text-gray-100',
)} '!bg-gray-100/90 dark:!bg-gray-900/70',
padding={isTagging ? 'tight-cta-right-left' : 'tight-cta-right'} )}
cta={<div className="flex items-center gap-2.5"> padding={isTagging ? 'tight-cta-right-left' : 'tight-cta-right'}
{renderActions()} cta={<div className="flex items-center gap-2.5">
</div>} {renderActions()}
spaceChildren={false}
hideIcon
>
{isTagging
? <TagInput
name="tags"
value={tags}
options={convertTagsForForm(existingTags)}
onChange={setTags}
placeholder={`Tag ${selectedPhotoIds?.length} ${photosPlural} ...`}
className={clsx(
Boolean(tagValidationMessage) && 'error',
)}
/>
: <div className="text-base">
{renderPhotoText()}
</div>} </div>}
</Note>} /> spaceChildren={false}
hideIcon
>
{isTagging
? <PhotoTagFieldset
tags={tags}
tagOptions={uniqueTags}
placeholder={
`Tag ${selectedPhotoIds?.length} ${photosPlural} ...`
}
onChange={setTags}
onError={setTagErrorMessage}
openOnLoad
hideLabel
/>
: <div className="text-base">
{renderPhotoText()}
</div>}
</Note>
{tagErrorMessage &&
<div className="text-error pl-4">
{tagErrorMessage}
</div>}
</div>} />
: null; : null;
} }

View File

@ -0,0 +1,58 @@
'use client';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag';
import { ComponentProps, useEffect, useRef, useState } from 'react';
export default function PhotoTagFieldset(props: {
tags: string
tagOptions?: Tags
onChange: (tags: string) => void
onError?: (error: string) => void
openOnLoad?: boolean
} & Partial<Omit<
ComponentProps<typeof FieldSetWithStatus>,
'tagOptions'
>>) {
const {
id,
tags,
tagOptions,
onChange,
onError,
openOnLoad,
...rest
} = props;
const ref = useRef<HTMLInputElement>(null);
const [errorMessageLocal, setErrorMessageLocal] = useState('');
useEffect(() => {
if (openOnLoad) {
const timeout = setTimeout(() => {
ref.current?.querySelectorAll('input')[0]?.focus();
}, 100);
return () => clearTimeout(timeout);
}
}, [openOnLoad]);
return (
<div ref={ref}>
<FieldSetWithStatus
{...rest}
inputRef={ref}
id={id ?? 'tags'}
value={tags}
tagOptions={convertTagsForForm(tagOptions)}
onChange={tags => {
onChange(tags);
const validationMessage = getValidationMessageForTags(tags) ?? '';
onError?.(validationMessage);
setErrorMessageLocal(validationMessage);
}}
error={errorMessageLocal}
/>
</div>
);
}

View File

@ -29,7 +29,7 @@ export default function FieldSetWithStatus({
hideLabel, hideLabel,
}: { }: {
id: string id: string
label: string label?: string
note?: string note?: string
error?: string error?: string
value: string value: string
@ -37,7 +37,7 @@ export default function FieldSetWithStatus({
onChange?: (value: string) => void onChange?: (value: string) => void
selectOptions?: { value: string, label: string }[] selectOptions?: { value: string, label: string }[]
selectOptionsDefaultLabel?: string selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag [] tagOptions?: AnnotatedTag[]
placeholder?: string placeholder?: string
loading?: boolean loading?: boolean
required?: boolean required?: boolean
@ -55,7 +55,7 @@ export default function FieldSetWithStatus({
'space-y-1', 'space-y-1',
type === 'checkbox' && 'flex items-center gap-2', type === 'checkbox' && 'flex items-center gap-2',
)}> )}>
{!hideLabel && {!hideLabel && label &&
<label <label
className={clsx( className={clsx(
'flex gap-2 items-center select-none', 'flex gap-2 items-center select-none',