Create initial UI for choosing tags
This commit is contained in:
parent
3717b39520
commit
929769eb48
@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getPhotoNoStore } from '@/cache';
|
||||
import { getPhotoNoStore, getUniqueTagsCached } from '@/cache';
|
||||
import { PATH_ADMIN } from '@/site/paths';
|
||||
import PhotoEditPageClient from '@/photo/PhotoEditPageClient';
|
||||
|
||||
@ -12,7 +12,9 @@ export default async function PhotoEditPage({
|
||||
|
||||
if (!photo) { redirect(PATH_ADMIN); }
|
||||
|
||||
const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag);
|
||||
|
||||
return (
|
||||
<PhotoEditPageClient {...{ photo }} />
|
||||
<PhotoEditPageClient {...{ photo, uniqueTags }} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import PhotoForm from '@/photo/PhotoForm';
|
||||
import PhotoForm from '@/photo/form/PhotoForm';
|
||||
import AdminChildPage from '@/components/AdminChildPage';
|
||||
import { PATH_ADMIN, PATH_ADMIN_UPLOADS } from '@/site/paths';
|
||||
import { extractExifDataFromBlobPath } from '@/photo/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getUniqueTagsCached } from '@/cache';
|
||||
|
||||
interface Params {
|
||||
params: { uploadPath: string }
|
||||
@ -14,6 +15,8 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
|
||||
photoFormExif,
|
||||
} = await extractExifDataFromBlobPath(uploadPath);
|
||||
|
||||
const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag);
|
||||
|
||||
if (!photoFormExif) { redirect(PATH_ADMIN); }
|
||||
|
||||
return (
|
||||
@ -22,7 +25,10 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
|
||||
backLabel="Uploads"
|
||||
breadcrumb={blobId}
|
||||
>
|
||||
<PhotoForm initialPhotoForm={photoFormExif} />
|
||||
<PhotoForm
|
||||
initialPhotoForm={photoFormExif}
|
||||
uniqueTags={uniqueTags}
|
||||
/>
|
||||
</AdminChildPage>
|
||||
);
|
||||
};
|
||||
|
||||
@ -26,7 +26,7 @@ export default function Badge({
|
||||
'px-[0.3rem] py-1 rounded-[0.25rem]',
|
||||
'text-[0.7rem] font-medium',
|
||||
highContrast
|
||||
? 'text-invert bg-primary'
|
||||
? 'text-invert bg-invert'
|
||||
: 'text-medium bg-gray-300/30 dark:bg-gray-700/50',
|
||||
interactive && highContrast
|
||||
? 'hover:opacity-70'
|
||||
|
||||
86
src/components/CommaSeparatedInput.tsx
Normal file
86
src/components/CommaSeparatedInput.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { convertStringToArray } from '@/utility/string';
|
||||
import { Combobox } from '@headlessui/react';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { BiExpandVertical } from 'react-icons/bi';
|
||||
import { FaCheck } from 'react-icons/fa';
|
||||
|
||||
export default function CommaSeparatedInput({
|
||||
onChange,
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
type,
|
||||
autoCapitalize,
|
||||
readOnly,
|
||||
options: optionsRaw = [],
|
||||
}: {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
options?: string[]
|
||||
} & Omit<React.HTMLProps<HTMLInputElement>, 'onChange'>) {
|
||||
const items = (convertStringToArray(value) ?? [])
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const options = items
|
||||
.filter(item => !optionsRaw.includes(item))
|
||||
.concat(optionsRaw);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Combobox
|
||||
value={items}
|
||||
onChange={e => onChange?.(e.join(','))}
|
||||
multiple
|
||||
>
|
||||
<div className="relative">
|
||||
<Combobox.Input
|
||||
className="w-full !pr-16"
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
displayValue={(tags: string[]) => tags.join(', ')}
|
||||
{...{
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
autoCapitalize,
|
||||
readOnly,
|
||||
}}
|
||||
/>
|
||||
{options &&
|
||||
<Combobox.Button className={clsx(
|
||||
'absolute top-0 right-0 border-none !bg-transparent',
|
||||
'flex items-center',
|
||||
)}>
|
||||
<BiExpandVertical
|
||||
className="text-gray-400"
|
||||
size={16}
|
||||
/>
|
||||
</Combobox.Button>}
|
||||
</div>
|
||||
{options &&
|
||||
<Combobox.Options className={clsx(
|
||||
'control px-1.5 absolute mt-4 w-full',
|
||||
)}>
|
||||
{options.map((tag) => (
|
||||
<Combobox.Option
|
||||
key={tag}
|
||||
value={tag}
|
||||
className={({ focus }) => clsx(
|
||||
'p-1 rounded-[0.2rem] !hover:cursor',
|
||||
focus && 'text-invert bg-invert',
|
||||
)}
|
||||
>
|
||||
{({ selected }) => <div className="flex items-center">
|
||||
<span className="grow">
|
||||
{tag}
|
||||
</span>
|
||||
{selected &&
|
||||
<FaCheck size={12} className="translate-y-[1px]" />}
|
||||
</div>}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>}
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,8 @@ import { LegacyRef } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
import Spinner from './Spinner';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { FieldSetType } from '@/photo/form';
|
||||
import CommaSeparatedInput from '@/components/CommaSeparatedInput';
|
||||
|
||||
export default function FieldSetWithStatus({
|
||||
id,
|
||||
@ -14,6 +16,7 @@ export default function FieldSetWithStatus({
|
||||
onChange,
|
||||
selectOptions,
|
||||
selectOptionsDefaultLabel,
|
||||
commaSeparatedOptions,
|
||||
placeholder,
|
||||
loading,
|
||||
required,
|
||||
@ -30,12 +33,13 @@ export default function FieldSetWithStatus({
|
||||
onChange?: (value: string) => void
|
||||
selectOptions?: { value: string, label: string }[]
|
||||
selectOptionsDefaultLabel?: string
|
||||
commaSeparatedOptions?: string[]
|
||||
placeholder?: string
|
||||
loading?: boolean
|
||||
required?: boolean
|
||||
readOnly?: boolean
|
||||
capitalize?: boolean
|
||||
type?: 'text' | 'email' | 'password' | 'checkbox'
|
||||
type?: FieldSetType
|
||||
inputRef?: LegacyRef<HTMLInputElement>
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
@ -86,25 +90,41 @@ export default function FieldSetWithStatus({
|
||||
{optionLabel}
|
||||
</option>)}
|
||||
</select>
|
||||
: <input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
checked={type === 'checkbox' ? value === 'true' : undefined}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(type === 'checkbox'
|
||||
? e.target.value === 'true' ? 'false' : 'true'
|
||||
: e.target.value)}
|
||||
type={type}
|
||||
autoComplete="off"
|
||||
readOnly={readOnly || pending}
|
||||
className={clsx(
|
||||
type === 'text' && 'w-full',
|
||||
error && 'error',
|
||||
)}
|
||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||
/>}
|
||||
: commaSeparatedOptions
|
||||
? <CommaSeparatedInput
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
options={commaSeparatedOptions}
|
||||
type={type}
|
||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||
readOnly={readOnly || pending}
|
||||
className={clsx(
|
||||
type === 'text' && 'w-full',
|
||||
error && 'error',
|
||||
)}
|
||||
/>
|
||||
: <input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
checked={type === 'checkbox' ? value === 'true' : undefined}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(type === 'checkbox'
|
||||
? e.target.value === 'true' ? 'false' : 'true'
|
||||
: e.target.value)}
|
||||
type={type}
|
||||
autoComplete="off"
|
||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||
readOnly={readOnly || pending}
|
||||
className={clsx(
|
||||
type === 'text' && 'w-full',
|
||||
error && 'error',
|
||||
)}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,7 +5,7 @@ import { Photo } from '.';
|
||||
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import { PhotoFormData, convertPhotoToFormData } from './form';
|
||||
import PhotoForm from './PhotoForm';
|
||||
import PhotoForm from './form/PhotoForm';
|
||||
import { useFormState } from 'react-dom';
|
||||
import { areSimpleObjectsEqual } from '@/utility/object';
|
||||
import IconGrSync from '@/site/IconGrSync';
|
||||
@ -13,8 +13,10 @@ import { getExifDataAction } from './actions';
|
||||
|
||||
export default function PhotoEditPageClient({
|
||||
photo,
|
||||
uniqueTags,
|
||||
}: {
|
||||
photo: Photo
|
||||
uniqueTags?: string[]
|
||||
}) {
|
||||
const seedExifData = { url: photo.url };
|
||||
|
||||
@ -51,6 +53,7 @@ export default function PhotoEditPageClient({
|
||||
updatedExifData={hasExifDataBeenFound
|
||||
? updatedExifData
|
||||
: undefined}
|
||||
uniqueTags={uniqueTags}
|
||||
/>
|
||||
</AdminChildPage>
|
||||
);
|
||||
|
||||
@ -7,9 +7,9 @@ import {
|
||||
convertFormKeysToLabels,
|
||||
getInitialErrors,
|
||||
isFormValid,
|
||||
} from './form';
|
||||
} from '.';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import { createPhotoAction, updatePhotoAction } from './actions';
|
||||
import { createPhotoAction, updatePhotoAction } from '../actions';
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import Link from 'next/link';
|
||||
import { clsx } from 'clsx/lite';
|
||||
@ -23,6 +23,7 @@ import { toastSuccess, toastWarning } from '@/toast';
|
||||
import { getDimensionsFromSize } from '@/utility/size';
|
||||
import ImageBlurFallback from '@/components/ImageBlurFallback';
|
||||
import { BLUR_ENABLED } from '@/site/config';
|
||||
import { sortTagsWithoutFavs } from '@/tag';
|
||||
|
||||
const THUMBNAIL_SIZE = 300;
|
||||
|
||||
@ -30,11 +31,13 @@ export default function PhotoForm({
|
||||
initialPhotoForm,
|
||||
updatedExifData,
|
||||
type = 'create',
|
||||
uniqueTags,
|
||||
debugBlur,
|
||||
}: {
|
||||
initialPhotoForm: Partial<PhotoFormData>
|
||||
updatedExifData?: Partial<PhotoFormData>
|
||||
type?: 'create' | 'edit'
|
||||
uniqueTags?: string[]
|
||||
debugBlur?: boolean
|
||||
}) {
|
||||
const [formData, setFormData] =
|
||||
@ -140,6 +143,7 @@ export default function PhotoForm({
|
||||
</div>
|
||||
<form
|
||||
action={type === 'create' ? createPhotoAction : updatePhotoAction}
|
||||
onSubmit={() => blur()}
|
||||
className="space-y-6"
|
||||
>
|
||||
{FORM_METADATA_ENTRIES.map(([key, {
|
||||
@ -154,7 +158,7 @@ export default function PhotoForm({
|
||||
hideIfEmpty,
|
||||
hideBasedOnCamera,
|
||||
loadingMessage,
|
||||
checkbox,
|
||||
type,
|
||||
}]) =>
|
||||
(
|
||||
(!hideIfEmpty || formData[key]) &&
|
||||
@ -175,6 +179,9 @@ export default function PhotoForm({
|
||||
}}
|
||||
selectOptions={options}
|
||||
selectOptionsDefaultLabel={optionsDefaultLabel}
|
||||
commaSeparatedOptions={key === 'tags'
|
||||
? sortTagsWithoutFavs(uniqueTags ?? [])
|
||||
: undefined}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
capitalize={capitalize}
|
||||
@ -182,7 +189,7 @@ export default function PhotoForm({
|
||||
? loadingMessage
|
||||
: undefined}
|
||||
loading={loadingMessage && !formData[key] ? true : false}
|
||||
type={checkbox ? 'checkbox' : undefined}
|
||||
type={type}
|
||||
/>)}
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ExifData } from 'ts-exif-parser';
|
||||
import { Photo, PhotoDbInsert, PhotoExif } from '.';
|
||||
import { Photo, PhotoDbInsert, PhotoExif } from '..';
|
||||
import {
|
||||
convertTimestampToNaivePostgresString,
|
||||
convertTimestampWithOffsetToPostgresString,
|
||||
@ -20,6 +20,12 @@ type VirtualFields = 'favorite';
|
||||
|
||||
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>;
|
||||
|
||||
export type FieldSetType =
|
||||
'text' |
|
||||
'email' |
|
||||
'password' |
|
||||
'checkbox';
|
||||
|
||||
type FormMeta = {
|
||||
label: string
|
||||
note?: string
|
||||
@ -32,7 +38,7 @@ type FormMeta = {
|
||||
hideIfEmpty?: boolean
|
||||
hideBasedOnCamera?: (make?: string, mode?: string) => boolean
|
||||
loadingMessage?: string
|
||||
checkbox?: boolean
|
||||
type?: FieldSetType
|
||||
options?: { value: string, label: string }[]
|
||||
optionsDefaultLabel?: string
|
||||
};
|
||||
@ -77,8 +83,8 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
||||
takenAt: { label: 'taken at' },
|
||||
takenAtNaive: { label: 'taken at (naive)' },
|
||||
priorityOrder: { label: 'priority order' },
|
||||
favorite: { label: 'favorite', checkbox: true, virtual: true },
|
||||
hidden: { label: 'hidden', checkbox: true },
|
||||
favorite: { label: 'favorite', type: 'checkbox', virtual: true },
|
||||
hidden: { label: 'hidden', type: 'checkbox' },
|
||||
};
|
||||
|
||||
export const FORM_METADATA_ENTRIES =
|
||||
@ -7,8 +7,8 @@
|
||||
body {
|
||||
@apply
|
||||
text-main
|
||||
bg-main
|
||||
font-mono text-sm md:text-base
|
||||
bg-white dark:bg-black
|
||||
}
|
||||
/* Forms */
|
||||
label {
|
||||
@ -17,12 +17,13 @@
|
||||
text-medium
|
||||
tracking-wider
|
||||
}
|
||||
.control,
|
||||
button, .button,
|
||||
input[type=text], input[type=email], input[type=password], select {
|
||||
@apply
|
||||
px-2.5 py-2
|
||||
border rounded-md
|
||||
bg-white dark:bg-black
|
||||
bg-main
|
||||
border-gray-200 dark:border-gray-700
|
||||
font-mono text-base leading-tight
|
||||
min-h-[2.4rem]
|
||||
@ -142,12 +143,16 @@
|
||||
text-red-500 dark:text-red-400
|
||||
}
|
||||
/* Common Utilities: Background */
|
||||
.bg-main {
|
||||
@apply
|
||||
bg-white dark:bg-black
|
||||
}
|
||||
.bg-content {
|
||||
@apply
|
||||
bg-white border-gray-200
|
||||
dark:bg-black dark:border-gray-800
|
||||
}
|
||||
.bg-primary {
|
||||
.bg-invert {
|
||||
@apply
|
||||
bg-gray-900 dark:bg-gray-100
|
||||
}
|
||||
|
||||
@ -43,6 +43,9 @@ export const sortTagsObject = (
|
||||
.filter(({ tag }) => tag!== tagToHide)
|
||||
.sort(({ tag: a }, { tag: b }) => isTagFavs(a) ? -1 : a.localeCompare(b));
|
||||
|
||||
export const sortTagsWithoutFavs = (tags: string[]) =>
|
||||
sortTags(tags, TAG_FAVS);
|
||||
|
||||
export const descriptionForTaggedPhotos = (
|
||||
photos: Photo[],
|
||||
dateBased?: boolean,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user