Refine photo tag control
This commit is contained in:
parent
235d73db3f
commit
dc8dedd806
@ -5,11 +5,7 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import Container from '@/components/Container';
|
||||
import { addAllUploadsAction } from '@/photo/actions';
|
||||
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||
import {
|
||||
Tags,
|
||||
convertTagsForForm,
|
||||
getValidationMessageForTags,
|
||||
} from '@/tag';
|
||||
import { Tags } from '@/tag';
|
||||
import {
|
||||
generateLocalNaivePostgresString,
|
||||
generateLocalPostgresString,
|
||||
@ -22,6 +18,7 @@ import { Dispatch, SetStateAction, useRef, useState } from 'react';
|
||||
import { BiCheckCircle, BiImageAdd } from 'react-icons/bi';
|
||||
import ProgressButton from '@/components/primitives/ProgressButton';
|
||||
import { UrlAddStatus } from './AdminUploadsClient';
|
||||
import PhotoTagFieldset from './PhotoTagFieldset';
|
||||
|
||||
const UPLOAD_BATCH_SIZE = 4;
|
||||
|
||||
@ -38,8 +35,6 @@ export default function AdminAddAllUploads({
|
||||
setIsAdding: (isAdding: boolean) => void
|
||||
setUrlAddStatuses: Dispatch<SetStateAction<UrlAddStatus[]>>
|
||||
}) {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [buttonText, setButtonText] = useState('Add All Uploads');
|
||||
const [showTags, setShowTags] = useState(false);
|
||||
const [tags, setTags] = useState('');
|
||||
@ -121,36 +116,20 @@ export default function AdminAddAllUploads({
|
||||
label="Apply tags"
|
||||
type="checkbox"
|
||||
value={showTags ? 'true' : 'false'}
|
||||
onChange={value => {
|
||||
setShowTags(value === 'true');
|
||||
if (value === 'true') {
|
||||
setTimeout(() =>
|
||||
divRef.current?.querySelectorAll('input')[0]?.focus()
|
||||
, 100);
|
||||
}
|
||||
}}
|
||||
onChange={value => setShowTags(value === 'true')}
|
||||
readOnly={isAdding}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={divRef}
|
||||
className={showTags && !actionErrorMessage ? undefined : 'hidden'}
|
||||
>
|
||||
<FieldSetWithStatus
|
||||
id="tags"
|
||||
label="Optional Tags"
|
||||
tagOptions={convertTagsForForm(uniqueTags)}
|
||||
value={tags}
|
||||
onChange={tags => {
|
||||
setTags(tags);
|
||||
setTagErrorMessage(getValidationMessageForTags(tags) ?? '');
|
||||
}}
|
||||
{showTags && !actionErrorMessage &&
|
||||
<PhotoTagFieldset
|
||||
tags={tags}
|
||||
tagOptions={uniqueTags}
|
||||
onChange={setTags}
|
||||
onError={setTagErrorMessage}
|
||||
readOnly={isAdding}
|
||||
error={tagErrorMessage}
|
||||
required={false}
|
||||
openOnLoad
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
/>}
|
||||
<div className="space-y-2">
|
||||
<ProgressButton
|
||||
primary
|
||||
|
||||
@ -30,7 +30,7 @@ export default function AdminAppMenu() {
|
||||
className="text-[18px] translate-y-[-0.5px]"
|
||||
/>
|
||||
: <ImCheckboxUnchecked
|
||||
className="text-[0.75rem]"
|
||||
className="text-[0.75rem] translate-y-[-0.5px]"
|
||||
/>,
|
||||
href: PATH_GRID_INFERRED,
|
||||
action: () => {
|
||||
|
||||
@ -2,8 +2,8 @@ import { getUniqueTagsCached } from '@/photo/cache';
|
||||
import AdminBatchEditPanelClient from './AdminBatchEditPanelClient';
|
||||
|
||||
export default async function AdminBatchEditPanel() {
|
||||
const existingTags = await getUniqueTagsCached();
|
||||
const uniqueTags = await getUniqueTagsCached();
|
||||
return (
|
||||
<AdminBatchEditPanelClient {...{ existingTags }} />
|
||||
<AdminBatchEditPanelClient {...{ uniqueTags }} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,15 +8,15 @@ import { clsx } from 'clsx/lite';
|
||||
import { IoCloseSharp } from 'react-icons/io5';
|
||||
import DeleteButton from './DeleteButton';
|
||||
import { useState } from 'react';
|
||||
import TagInput from '@/components/TagInput';
|
||||
import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag';
|
||||
import { Tags } from '@/tag';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { PATH_GRID_INFERRED } from '@/site/paths';
|
||||
import PhotoTagFieldset from './PhotoTagFieldset';
|
||||
|
||||
export default function AdminBatchEditPanelClient({
|
||||
existingTags,
|
||||
uniqueTags,
|
||||
}: {
|
||||
existingTags: Tags
|
||||
uniqueTags: Tags
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
@ -27,7 +27,7 @@ export default function AdminBatchEditPanelClient({
|
||||
} = useAppState();
|
||||
|
||||
const [tags, setTags] = useState<string>();
|
||||
const tagValidationMessage = getValidationMessageForTags(tags);
|
||||
const [tagErrorMessage, setTagErrorMessage] = useState('');
|
||||
const isTagging = tags !== undefined;
|
||||
|
||||
const photosPlural = selectedPhotoIds?.length === 1 ? 'photo' : 'photos';
|
||||
@ -40,7 +40,10 @@ export default function AdminBatchEditPanelClient({
|
||||
? <>
|
||||
<LoaderButton
|
||||
className="min-h-[2.5rem]"
|
||||
onClick={() => setTags(undefined)}
|
||||
onClick={() => {
|
||||
setTags(undefined);
|
||||
setTagErrorMessage('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</LoaderButton>
|
||||
@ -48,7 +51,7 @@ export default function AdminBatchEditPanelClient({
|
||||
className="min-h-[2.5rem]"
|
||||
// eslint-disable-next-line max-len
|
||||
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
|
||||
>
|
||||
Apply Tags
|
||||
@ -75,35 +78,42 @@ export default function AdminBatchEditPanelClient({
|
||||
)
|
||||
? <SiteGrid
|
||||
className="sticky top-0 z-10 mb-5 -mt-2 pt-2"
|
||||
contentMain={<Note
|
||||
color="gray"
|
||||
className={clsx(
|
||||
'min-h-[3.5rem]',
|
||||
'backdrop-blur-lg !border-transparent',
|
||||
'!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">
|
||||
{renderActions()}
|
||||
</div>}
|
||||
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()}
|
||||
contentMain={<div className="flex flex-col gap-2">
|
||||
<Note
|
||||
color="gray"
|
||||
className={clsx(
|
||||
'min-h-[3.5rem]',
|
||||
'backdrop-blur-lg !border-transparent',
|
||||
'!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">
|
||||
{renderActions()}
|
||||
</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;
|
||||
}
|
||||
|
||||
58
src/admin/PhotoTagFieldset.tsx
Normal file
58
src/admin/PhotoTagFieldset.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -29,7 +29,7 @@ export default function FieldSetWithStatus({
|
||||
hideLabel,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
label?: string
|
||||
note?: string
|
||||
error?: string
|
||||
value: string
|
||||
@ -37,7 +37,7 @@ export default function FieldSetWithStatus({
|
||||
onChange?: (value: string) => void
|
||||
selectOptions?: { value: string, label: string }[]
|
||||
selectOptionsDefaultLabel?: string
|
||||
tagOptions?: AnnotatedTag []
|
||||
tagOptions?: AnnotatedTag[]
|
||||
placeholder?: string
|
||||
loading?: boolean
|
||||
required?: boolean
|
||||
@ -55,7 +55,7 @@ export default function FieldSetWithStatus({
|
||||
'space-y-1',
|
||||
type === 'checkbox' && 'flex items-center gap-2',
|
||||
)}>
|
||||
{!hideLabel &&
|
||||
{!hideLabel && label &&
|
||||
<label
|
||||
className={clsx(
|
||||
'flex gap-2 items-center select-none',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user