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 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
|
||||||
|
|||||||
@ -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: () => {
|
||||||
|
|||||||
@ -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 }} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user