Merge branch 'main' into static
This commit is contained in:
commit
0f557d07db
28
package.json
28
package.json
@ -9,23 +9,23 @@
|
|||||||
"analyze": "ANALYZE=true next build"
|
"analyze": "ANALYZE=true next build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.501.0",
|
"@aws-sdk/client-s3": "3.504.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.501.0",
|
"@aws-sdk/s3-request-presigner": "3.504.0",
|
||||||
"@headlessui/react": "2.0.0-alpha.4",
|
"@headlessui/react": "2.0.0-alpha.4",
|
||||||
"@next/bundle-analyzer": "14.1.0",
|
"@next/bundle-analyzer": "14.1.0",
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@testing-library/jest-dom": "^6.3.0",
|
"@testing-library/jest-dom": "^6.4.1",
|
||||||
"@testing-library/react": "^14.1.2",
|
"@testing-library/react": "^14.2.1",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^20.11.8",
|
"@types/node": "^20.11.16",
|
||||||
"@types/react": "18.2.48",
|
"@types/react": "18.2.53",
|
||||||
"@types/react-dom": "18.2.18",
|
"@types/react-dom": "18.2.18",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||||
"@typescript-eslint/parser": "^6.19.1",
|
"@typescript-eslint/parser": "^6.20.0",
|
||||||
"@vercel/analytics": "^1.1.2",
|
"@vercel/analytics": "^1.1.2",
|
||||||
"@vercel/blob": "^0.19.0",
|
"@vercel/blob": "^0.20.0",
|
||||||
"@vercel/postgres": "0.7.2",
|
"@vercel/postgres": "0.7.2",
|
||||||
"@vercel/speed-insights": "^1.0.8",
|
"@vercel/speed-insights": "^1.0.9",
|
||||||
"autoprefixer": "10.4.17",
|
"autoprefixer": "10.4.17",
|
||||||
"camelcase-keys": "^9.1.3",
|
"camelcase-keys": "^9.1.3",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
@ -36,15 +36,15 @@
|
|||||||
"framer-motion": "^11.0.3",
|
"framer-motion": "^11.0.3",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.5",
|
||||||
"next": "14.1.1-canary.21",
|
"next": "14.1.1-canary.27",
|
||||||
"next-auth": "5.0.0-beta.5",
|
"next-auth": "5.0.0-beta.5",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"postcss": "8.4.33",
|
"postcss": "8.4.33",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-icons": "^5.0.1",
|
"react-icons": "^5.0.1",
|
||||||
"sonner": "^1.3.1",
|
"sonner": "^1.4.0",
|
||||||
"tailwindcss": "3.4.1",
|
"tailwindcss": "3.4.1",
|
||||||
"ts-exif-parser": "^0.2.2",
|
"ts-exif-parser": "^0.2.2",
|
||||||
"typescript": "5.3.3"
|
"typescript": "5.3.3"
|
||||||
|
|||||||
757
pnpm-lock.yaml
generated
757
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getPhotoNoStore } from '@/cache';
|
import { getPhotoNoStore, getUniqueTagsCached } from '@/cache';
|
||||||
import { PATH_ADMIN } from '@/site/paths';
|
import { PATH_ADMIN } from '@/site/paths';
|
||||||
import PhotoEditPageClient from '@/photo/PhotoEditPageClient';
|
import PhotoEditPageClient from '@/photo/PhotoEditPageClient';
|
||||||
|
|
||||||
@ -12,7 +12,9 @@ export default async function PhotoEditPage({
|
|||||||
|
|
||||||
if (!photo) { redirect(PATH_ADMIN); }
|
if (!photo) { redirect(PATH_ADMIN); }
|
||||||
|
|
||||||
|
const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag);
|
||||||
|
|
||||||
return (
|
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 AdminChildPage from '@/components/AdminChildPage';
|
||||||
import { PATH_ADMIN, PATH_ADMIN_UPLOADS } from '@/site/paths';
|
import { PATH_ADMIN, PATH_ADMIN_UPLOADS } from '@/site/paths';
|
||||||
import { extractExifDataFromBlobPath } from '@/photo/server';
|
import { extractExifDataFromBlobPath } from '@/photo/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getUniqueTagsCached } from '@/cache';
|
||||||
|
|
||||||
interface Params {
|
interface Params {
|
||||||
params: { uploadPath: string }
|
params: { uploadPath: string }
|
||||||
@ -14,6 +15,8 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
|
|||||||
photoFormExif,
|
photoFormExif,
|
||||||
} = await extractExifDataFromBlobPath(uploadPath);
|
} = await extractExifDataFromBlobPath(uploadPath);
|
||||||
|
|
||||||
|
const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag);
|
||||||
|
|
||||||
if (!photoFormExif) { redirect(PATH_ADMIN); }
|
if (!photoFormExif) { redirect(PATH_ADMIN); }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -22,7 +25,10 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
|
|||||||
backLabel="Uploads"
|
backLabel="Uploads"
|
||||||
breadcrumb={blobId}
|
breadcrumb={blobId}
|
||||||
>
|
>
|
||||||
<PhotoForm initialPhotoForm={photoFormExif} />
|
<PhotoForm
|
||||||
|
initialPhotoForm={photoFormExif}
|
||||||
|
uniqueTags={uniqueTags}
|
||||||
|
/>
|
||||||
</AdminChildPage>
|
</AdminChildPage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export default function Badge({
|
|||||||
'px-[0.3rem] py-1 rounded-[0.25rem]',
|
'px-[0.3rem] py-1 rounded-[0.25rem]',
|
||||||
'text-[0.7rem] font-medium',
|
'text-[0.7rem] font-medium',
|
||||||
highContrast
|
highContrast
|
||||||
? 'text-invert bg-primary'
|
? 'text-invert bg-invert'
|
||||||
: 'text-medium bg-gray-300/30 dark:bg-gray-700/50',
|
: 'text-medium bg-gray-300/30 dark:bg-gray-700/50',
|
||||||
interactive && highContrast
|
interactive && highContrast
|
||||||
? 'hover:opacity-70'
|
? 'hover:opacity-70'
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { LegacyRef } from 'react';
|
|||||||
import { useFormStatus } from 'react-dom';
|
import { useFormStatus } from 'react-dom';
|
||||||
import Spinner from './Spinner';
|
import Spinner from './Spinner';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
|
import { FieldSetType } from '@/photo/form';
|
||||||
|
import TagInput from './TagInput';
|
||||||
|
|
||||||
export default function FieldSetWithStatus({
|
export default function FieldSetWithStatus({
|
||||||
id,
|
id,
|
||||||
@ -14,6 +16,7 @@ export default function FieldSetWithStatus({
|
|||||||
onChange,
|
onChange,
|
||||||
selectOptions,
|
selectOptions,
|
||||||
selectOptionsDefaultLabel,
|
selectOptionsDefaultLabel,
|
||||||
|
commaSeparatedOptions,
|
||||||
placeholder,
|
placeholder,
|
||||||
loading,
|
loading,
|
||||||
required,
|
required,
|
||||||
@ -30,12 +33,13 @@ 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
|
||||||
|
commaSeparatedOptions?: string[]
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
required?: boolean
|
required?: boolean
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
capitalize?: boolean
|
capitalize?: boolean
|
||||||
type?: 'text' | 'email' | 'password' | 'checkbox'
|
type?: FieldSetType
|
||||||
inputRef?: LegacyRef<HTMLInputElement>
|
inputRef?: LegacyRef<HTMLInputElement>
|
||||||
}) {
|
}) {
|
||||||
const { pending } = useFormStatus();
|
const { pending } = useFormStatus();
|
||||||
@ -72,6 +76,7 @@ export default function FieldSetWithStatus({
|
|||||||
onChange={e => onChange?.(e.target.value)}
|
onChange={e => onChange?.(e.target.value)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-full',
|
'w-full',
|
||||||
|
clsx(Boolean(error) && 'error'),
|
||||||
// Use special class because `select` can't be readonly
|
// Use special class because `select` can't be readonly
|
||||||
readOnly || pending && 'disabled-select',
|
readOnly || pending && 'disabled-select',
|
||||||
)}
|
)}
|
||||||
@ -86,6 +91,15 @@ export default function FieldSetWithStatus({
|
|||||||
{optionLabel}
|
{optionLabel}
|
||||||
</option>)}
|
</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
: commaSeparatedOptions
|
||||||
|
? <TagInput
|
||||||
|
name={id}
|
||||||
|
value={value}
|
||||||
|
options={commaSeparatedOptions}
|
||||||
|
onChange={onChange}
|
||||||
|
className={clsx(Boolean(error) && 'error')}
|
||||||
|
readOnly={readOnly || pending}
|
||||||
|
/>
|
||||||
: <input
|
: <input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
id={id}
|
id={id}
|
||||||
@ -98,12 +112,12 @@ export default function FieldSetWithStatus({
|
|||||||
: e.target.value)}
|
: e.target.value)}
|
||||||
type={type}
|
type={type}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||||
readOnly={readOnly || pending}
|
readOnly={readOnly || pending}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
type === 'text' && 'w-full',
|
type === 'text' && 'w-full',
|
||||||
error && 'error',
|
Boolean(error) && 'error',
|
||||||
)}
|
)}
|
||||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
|
||||||
/>}
|
/>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { BLUR_ENABLED } from '@/site/config';
|
import { BLUR_ENABLED } from '@/site/config';
|
||||||
import clsx from 'clsx/lite';
|
import { clsx} from 'clsx/lite';
|
||||||
import Image, { ImageProps } from 'next/image';
|
import Image, { ImageProps } from 'next/image';
|
||||||
|
|
||||||
export default function ImageBlurFallback(props: ImageProps) {
|
export default function ImageBlurFallback(props: ImageProps) {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import clsx from 'clsx/lite';
|
import { clsx} from 'clsx/lite';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Menu } from '@headlessui/react';
|
import { Menu } from '@headlessui/react';
|
||||||
import { FiMoreHorizontal } from 'react-icons/fi';
|
import { FiMoreHorizontal } from 'react-icons/fi';
|
||||||
|
|||||||
248
src/components/TagInput.tsx
Normal file
248
src/components/TagInput.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { convertStringToArray, parameterize } from '@/utility/string';
|
||||||
|
import { clsx } from 'clsx/lite';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const KEYDOWN_KEY = 'keydown';
|
||||||
|
const CREATE_LABEL = 'Create ';
|
||||||
|
|
||||||
|
export default function TagInput({
|
||||||
|
name,
|
||||||
|
value = '',
|
||||||
|
options = [],
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
readOnly,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
value?: string
|
||||||
|
options?: string[]
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
className?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLInputElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const optionsRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [hasFocus, setHasFocus] = useState(false);
|
||||||
|
const [inputText, setInputText] = useState('');
|
||||||
|
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>();
|
||||||
|
|
||||||
|
const selectedOptions = useMemo(() =>
|
||||||
|
convertStringToArray(value) ?? []
|
||||||
|
, [value]);
|
||||||
|
|
||||||
|
const inputTextFormatted = parameterize(inputText);
|
||||||
|
const isInputTextUnique =
|
||||||
|
inputTextFormatted &&
|
||||||
|
!options.includes(inputTextFormatted) &&
|
||||||
|
!selectedOptions.includes(inputTextFormatted);
|
||||||
|
|
||||||
|
const optionsFiltered = (isInputTextUnique
|
||||||
|
? [`${CREATE_LABEL}"${inputTextFormatted}"`]
|
||||||
|
: []).concat(options
|
||||||
|
.filter(option =>
|
||||||
|
!selectedOptions.includes(option) &&
|
||||||
|
(
|
||||||
|
!inputTextFormatted ||
|
||||||
|
option.includes(inputTextFormatted)
|
||||||
|
)));
|
||||||
|
|
||||||
|
const addOption = useCallback((option?: string) => {
|
||||||
|
if (option && !selectedOptions.includes(option)) {
|
||||||
|
onChange?.([
|
||||||
|
...selectedOptions,
|
||||||
|
option.startsWith(CREATE_LABEL)
|
||||||
|
? option.slice(CREATE_LABEL.length + 1, -1)
|
||||||
|
: option,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(parameterize)
|
||||||
|
.join(','));
|
||||||
|
}
|
||||||
|
setSelectedOptionIndex(undefined);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [onChange, selectedOptions]);
|
||||||
|
|
||||||
|
const removeOption = useCallback((option: string) => {
|
||||||
|
onChange?.(selectedOptions.filter(o =>
|
||||||
|
o !== parameterize(option)).join(','));
|
||||||
|
setSelectedOptionIndex(undefined);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [onChange, selectedOptions]);
|
||||||
|
|
||||||
|
// Reset selected option index when focus is lost
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasFocus) { setSelectedOptionIndex(undefined); }
|
||||||
|
}, [hasFocus]);
|
||||||
|
|
||||||
|
// Focus option in the DOM when selected index changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedOptionIndex !== undefined) {
|
||||||
|
const options = optionsRef.current?.querySelectorAll('div');
|
||||||
|
const option = options?.[selectedOptionIndex] as HTMLElement | undefined;
|
||||||
|
option?.focus();
|
||||||
|
}
|
||||||
|
}, [selectedOptionIndex]);
|
||||||
|
|
||||||
|
// Setup keyboard listener
|
||||||
|
useEffect(() => {
|
||||||
|
const ref = containerRef.current;
|
||||||
|
const listener = (e: KeyboardEvent) => {
|
||||||
|
// Keys which always trap focus
|
||||||
|
switch (e.key) {
|
||||||
|
case ',':
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'Escape':
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Enter':
|
||||||
|
// Only trap focus if there are options to select
|
||||||
|
// otherwise allow form to submit
|
||||||
|
if (optionsFiltered.length > 0) {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
addOption(optionsFiltered[selectedOptionIndex ?? 0]);
|
||||||
|
setInputText('');
|
||||||
|
break;
|
||||||
|
case ',':
|
||||||
|
addOption(inputText);
|
||||||
|
setInputText('');
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
setSelectedOptionIndex(i => {
|
||||||
|
if (i === undefined) {
|
||||||
|
return 1;
|
||||||
|
} else if (i >= optionsFiltered.length - 1) {
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
setSelectedOptionIndex(i => {
|
||||||
|
if (i === undefined || i === 0) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return undefined;
|
||||||
|
} else {
|
||||||
|
return i - 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'Backspace':
|
||||||
|
if (inputText === '' && selectedOptions.length > 0) {
|
||||||
|
removeOption(selectedOptions[selectedOptions.length - 1]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
inputRef.current?.blur();
|
||||||
|
setHasFocus(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ref?.addEventListener(KEYDOWN_KEY, listener);
|
||||||
|
return () => ref?.removeEventListener(KEYDOWN_KEY, listener);
|
||||||
|
}, [
|
||||||
|
inputText,
|
||||||
|
removeOption,
|
||||||
|
hasFocus,
|
||||||
|
selectedOptions,
|
||||||
|
selectedOptionIndex,
|
||||||
|
optionsFiltered,
|
||||||
|
addOption,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex flex-col w-full"
|
||||||
|
onFocus={() => setHasFocus(true)}
|
||||||
|
onBlur={e => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||||
|
setHasFocus(false);
|
||||||
|
setSelectedOptionIndex(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={clsx(
|
||||||
|
className,
|
||||||
|
'w-full control !px-2 !py-2',
|
||||||
|
'inline-flex flex-wrap items-center gap-2',
|
||||||
|
readOnly && 'cursor-not-allowed',
|
||||||
|
readOnly && 'bg-gray-100 dark:bg-gray-900 dark:text-gray-400',
|
||||||
|
)}>
|
||||||
|
{selectedOptions
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(option =>
|
||||||
|
<span
|
||||||
|
key={option}
|
||||||
|
className={clsx(
|
||||||
|
'cursor-pointer select-none',
|
||||||
|
'whitespace-nowrap',
|
||||||
|
'px-1.5 py-0.5',
|
||||||
|
'bg-gray-100 dark:bg-gray-800',
|
||||||
|
'active:bg-gray-50 dark:active:bg-gray-900',
|
||||||
|
'rounded-sm',
|
||||||
|
)}
|
||||||
|
onClick={() => removeOption(option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</span>)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className={clsx(
|
||||||
|
'grow !min-w-0 !p-0 -my-2 text-xl',
|
||||||
|
'!border-none !ring-transparent',
|
||||||
|
)}
|
||||||
|
size={10}
|
||||||
|
value={inputText}
|
||||||
|
onChange={e => setInputText(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
ref={optionsRef}
|
||||||
|
className={clsx(
|
||||||
|
!(hasFocus && optionsFiltered.length > 0) && 'hidden',
|
||||||
|
'control absolute top-0 mt-3 w-full z-10 !px-1.5 !py-1.5',
|
||||||
|
'max-h-[8rem] overflow-y-auto',
|
||||||
|
'flex flex-col gap-y-1',
|
||||||
|
'text-xl shadow-lg dark:shadow-xl',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{optionsFiltered.map((option, index) =>
|
||||||
|
<div
|
||||||
|
key={option}
|
||||||
|
tabIndex={0}
|
||||||
|
className={clsx(
|
||||||
|
'cursor-pointer select-none',
|
||||||
|
'px-1 py-1 rounded-sm',
|
||||||
|
index === 0 && selectedOptionIndex === undefined &&
|
||||||
|
'bg-gray-100 dark:bg-gray-800',
|
||||||
|
'hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||||
|
'active:bg-gray-50 dark:active:bg-gray-900',
|
||||||
|
'focus:bg-gray-100 dark:focus:bg-gray-800',
|
||||||
|
'outline-none',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
addOption(option);
|
||||||
|
setInputText('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import { Photo } from '.';
|
|||||||
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||||
import { PhotoFormData, convertPhotoToFormData } from './form';
|
import { PhotoFormData, convertPhotoToFormData } from './form';
|
||||||
import PhotoForm from './PhotoForm';
|
import PhotoForm from './form/PhotoForm';
|
||||||
import { useFormState } from 'react-dom';
|
import { useFormState } from 'react-dom';
|
||||||
import { areSimpleObjectsEqual } from '@/utility/object';
|
import { areSimpleObjectsEqual } from '@/utility/object';
|
||||||
import IconGrSync from '@/site/IconGrSync';
|
import IconGrSync from '@/site/IconGrSync';
|
||||||
@ -13,8 +13,10 @@ import { getExifDataAction } from './actions';
|
|||||||
|
|
||||||
export default function PhotoEditPageClient({
|
export default function PhotoEditPageClient({
|
||||||
photo,
|
photo,
|
||||||
|
uniqueTags,
|
||||||
}: {
|
}: {
|
||||||
photo: Photo
|
photo: Photo
|
||||||
|
uniqueTags?: string[]
|
||||||
}) {
|
}) {
|
||||||
const seedExifData = { url: photo.url };
|
const seedExifData = { url: photo.url };
|
||||||
|
|
||||||
@ -51,6 +53,7 @@ export default function PhotoEditPageClient({
|
|||||||
updatedExifData={hasExifDataBeenFound
|
updatedExifData={hasExifDataBeenFound
|
||||||
? updatedExifData
|
? updatedExifData
|
||||||
: undefined}
|
: undefined}
|
||||||
|
uniqueTags={uniqueTags}
|
||||||
/>
|
/>
|
||||||
</AdminChildPage>
|
</AdminChildPage>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,9 +7,9 @@ import {
|
|||||||
convertFormKeysToLabels,
|
convertFormKeysToLabels,
|
||||||
getInitialErrors,
|
getInitialErrors,
|
||||||
isFormValid,
|
isFormValid,
|
||||||
} from './form';
|
} from '.';
|
||||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||||
import { createPhotoAction, updatePhotoAction } from './actions';
|
import { createPhotoAction, updatePhotoAction } from '../actions';
|
||||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
@ -23,6 +23,7 @@ import { toastSuccess, toastWarning } from '@/toast';
|
|||||||
import { getDimensionsFromSize } from '@/utility/size';
|
import { getDimensionsFromSize } from '@/utility/size';
|
||||||
import ImageBlurFallback from '@/components/ImageBlurFallback';
|
import ImageBlurFallback from '@/components/ImageBlurFallback';
|
||||||
import { BLUR_ENABLED } from '@/site/config';
|
import { BLUR_ENABLED } from '@/site/config';
|
||||||
|
import { sortTagsWithoutFavs } from '@/tag';
|
||||||
|
|
||||||
const THUMBNAIL_SIZE = 300;
|
const THUMBNAIL_SIZE = 300;
|
||||||
|
|
||||||
@ -30,11 +31,13 @@ export default function PhotoForm({
|
|||||||
initialPhotoForm,
|
initialPhotoForm,
|
||||||
updatedExifData,
|
updatedExifData,
|
||||||
type = 'create',
|
type = 'create',
|
||||||
|
uniqueTags,
|
||||||
debugBlur,
|
debugBlur,
|
||||||
}: {
|
}: {
|
||||||
initialPhotoForm: Partial<PhotoFormData>
|
initialPhotoForm: Partial<PhotoFormData>
|
||||||
updatedExifData?: Partial<PhotoFormData>
|
updatedExifData?: Partial<PhotoFormData>
|
||||||
type?: 'create' | 'edit'
|
type?: 'create' | 'edit'
|
||||||
|
uniqueTags?: string[]
|
||||||
debugBlur?: boolean
|
debugBlur?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [formData, setFormData] =
|
const [formData, setFormData] =
|
||||||
@ -140,6 +143,7 @@ export default function PhotoForm({
|
|||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
action={type === 'create' ? createPhotoAction : updatePhotoAction}
|
action={type === 'create' ? createPhotoAction : updatePhotoAction}
|
||||||
|
onSubmit={() => blur()}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
{FORM_METADATA_ENTRIES.map(([key, {
|
{FORM_METADATA_ENTRIES.map(([key, {
|
||||||
@ -154,7 +158,7 @@ export default function PhotoForm({
|
|||||||
hideIfEmpty,
|
hideIfEmpty,
|
||||||
hideBasedOnCamera,
|
hideBasedOnCamera,
|
||||||
loadingMessage,
|
loadingMessage,
|
||||||
checkbox,
|
type,
|
||||||
}]) =>
|
}]) =>
|
||||||
(
|
(
|
||||||
(!hideIfEmpty || formData[key]) &&
|
(!hideIfEmpty || formData[key]) &&
|
||||||
@ -175,6 +179,9 @@ export default function PhotoForm({
|
|||||||
}}
|
}}
|
||||||
selectOptions={options}
|
selectOptions={options}
|
||||||
selectOptionsDefaultLabel={optionsDefaultLabel}
|
selectOptionsDefaultLabel={optionsDefaultLabel}
|
||||||
|
commaSeparatedOptions={key === 'tags'
|
||||||
|
? sortTagsWithoutFavs(uniqueTags ?? [])
|
||||||
|
: undefined}
|
||||||
required={required}
|
required={required}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
capitalize={capitalize}
|
capitalize={capitalize}
|
||||||
@ -182,7 +189,7 @@ export default function PhotoForm({
|
|||||||
? loadingMessage
|
? loadingMessage
|
||||||
: undefined}
|
: undefined}
|
||||||
loading={loadingMessage && !formData[key] ? true : false}
|
loading={loadingMessage && !formData[key] ? true : false}
|
||||||
type={checkbox ? 'checkbox' : undefined}
|
type={type}
|
||||||
/>)}
|
/>)}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Link
|
<Link
|
||||||
@ -191,9 +198,7 @@ export default function PhotoForm({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<SubmitButtonWithStatus
|
<SubmitButtonWithStatus disabled={!isFormValid(formData)}>
|
||||||
disabled={!isFormValid(formData)}
|
|
||||||
>
|
|
||||||
{type === 'create' ? 'Create' : 'Update'}
|
{type === 'create' ? 'Create' : 'Update'}
|
||||||
</SubmitButtonWithStatus>
|
</SubmitButtonWithStatus>
|
||||||
</div>
|
</div>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { ExifData } from 'ts-exif-parser';
|
import type { ExifData } from 'ts-exif-parser';
|
||||||
import { Photo, PhotoDbInsert, PhotoExif } from '.';
|
import { Photo, PhotoDbInsert, PhotoExif } from '..';
|
||||||
import {
|
import {
|
||||||
convertTimestampToNaivePostgresString,
|
convertTimestampToNaivePostgresString,
|
||||||
convertTimestampWithOffsetToPostgresString,
|
convertTimestampWithOffsetToPostgresString,
|
||||||
@ -20,6 +20,12 @@ type VirtualFields = 'favorite';
|
|||||||
|
|
||||||
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>;
|
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>;
|
||||||
|
|
||||||
|
export type FieldSetType =
|
||||||
|
'text' |
|
||||||
|
'email' |
|
||||||
|
'password' |
|
||||||
|
'checkbox';
|
||||||
|
|
||||||
type FormMeta = {
|
type FormMeta = {
|
||||||
label: string
|
label: string
|
||||||
note?: string
|
note?: string
|
||||||
@ -32,7 +38,7 @@ type FormMeta = {
|
|||||||
hideIfEmpty?: boolean
|
hideIfEmpty?: boolean
|
||||||
hideBasedOnCamera?: (make?: string, mode?: string) => boolean
|
hideBasedOnCamera?: (make?: string, mode?: string) => boolean
|
||||||
loadingMessage?: string
|
loadingMessage?: string
|
||||||
checkbox?: boolean
|
type?: FieldSetType
|
||||||
options?: { value: string, label: string }[]
|
options?: { value: string, label: string }[]
|
||||||
optionsDefaultLabel?: string
|
optionsDefaultLabel?: string
|
||||||
};
|
};
|
||||||
@ -41,7 +47,6 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
|||||||
title: { label: 'title', capitalize: true },
|
title: { label: 'title', capitalize: true },
|
||||||
tags: {
|
tags: {
|
||||||
label: 'tags',
|
label: 'tags',
|
||||||
note: 'comma-separated values',
|
|
||||||
validate: tags => doesTagsStringIncludeFavs(tags)
|
validate: tags => doesTagsStringIncludeFavs(tags)
|
||||||
? `'${TAG_FAVS}' is a reserved tag`
|
? `'${TAG_FAVS}' is a reserved tag`
|
||||||
: undefined,
|
: undefined,
|
||||||
@ -77,8 +82,8 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
|||||||
takenAt: { label: 'taken at' },
|
takenAt: { label: 'taken at' },
|
||||||
takenAtNaive: { label: 'taken at (naive)' },
|
takenAtNaive: { label: 'taken at (naive)' },
|
||||||
priorityOrder: { label: 'priority order' },
|
priorityOrder: { label: 'priority order' },
|
||||||
favorite: { label: 'favorite', checkbox: true, virtual: true },
|
favorite: { label: 'favorite', type: 'checkbox', virtual: true },
|
||||||
hidden: { label: 'hidden', checkbox: true },
|
hidden: { label: 'hidden', type: 'checkbox' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FORM_METADATA_ENTRIES =
|
export const FORM_METADATA_ENTRIES =
|
||||||
@ -7,8 +7,8 @@
|
|||||||
body {
|
body {
|
||||||
@apply
|
@apply
|
||||||
text-main
|
text-main
|
||||||
|
bg-main
|
||||||
font-mono text-sm md:text-base
|
font-mono text-sm md:text-base
|
||||||
bg-white dark:bg-black
|
|
||||||
}
|
}
|
||||||
/* Forms */
|
/* Forms */
|
||||||
label {
|
label {
|
||||||
@ -17,12 +17,13 @@
|
|||||||
text-medium
|
text-medium
|
||||||
tracking-wider
|
tracking-wider
|
||||||
}
|
}
|
||||||
|
.control,
|
||||||
button, .button,
|
button, .button,
|
||||||
input[type=text], input[type=email], input[type=password], select {
|
input[type=text], input[type=email], input[type=password], select {
|
||||||
@apply
|
@apply
|
||||||
px-2.5 py-2
|
px-2.5 py-2
|
||||||
border rounded-md
|
border rounded-md
|
||||||
bg-white dark:bg-black
|
bg-main
|
||||||
border-gray-200 dark:border-gray-700
|
border-gray-200 dark:border-gray-700
|
||||||
font-mono text-base leading-tight
|
font-mono text-base leading-tight
|
||||||
min-h-[2.4rem]
|
min-h-[2.4rem]
|
||||||
@ -64,7 +65,7 @@
|
|||||||
@apply
|
@apply
|
||||||
rounded-md
|
rounded-md
|
||||||
}
|
}
|
||||||
input.error, select.error {
|
.error {
|
||||||
@apply
|
@apply
|
||||||
border-red-500 dark:border-red-400
|
border-red-500 dark:border-red-400
|
||||||
}
|
}
|
||||||
@ -142,12 +143,16 @@
|
|||||||
text-red-500 dark:text-red-400
|
text-red-500 dark:text-red-400
|
||||||
}
|
}
|
||||||
/* Common Utilities: Background */
|
/* Common Utilities: Background */
|
||||||
|
.bg-main {
|
||||||
|
@apply
|
||||||
|
bg-white dark:bg-black
|
||||||
|
}
|
||||||
.bg-content {
|
.bg-content {
|
||||||
@apply
|
@apply
|
||||||
bg-white border-gray-200
|
bg-white border-gray-200
|
||||||
dark:bg-black dark:border-gray-800
|
dark:bg-black dark:border-gray-800
|
||||||
}
|
}
|
||||||
.bg-primary {
|
.bg-invert {
|
||||||
@apply
|
@apply
|
||||||
bg-gray-900 dark:bg-gray-100
|
bg-gray-900 dark:bg-gray-100
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { FaStar } from 'react-icons/fa';
|
|||||||
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
|
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
|
||||||
import { TAG_FAVS } from '.';
|
import { TAG_FAVS } from '.';
|
||||||
import { pathForTag } from '@/site/paths';
|
import { pathForTag } from '@/site/paths';
|
||||||
import clsx from 'clsx';
|
import { clsx } from 'clsx/lite';
|
||||||
|
|
||||||
export default function FavsTag({
|
export default function FavsTag({
|
||||||
type,
|
type,
|
||||||
|
|||||||
@ -43,6 +43,9 @@ export const sortTagsObject = (
|
|||||||
.filter(({ tag }) => tag!== tagToHide)
|
.filter(({ tag }) => tag!== tagToHide)
|
||||||
.sort(({ tag: a }, { tag: b }) => isTagFavs(a) ? -1 : a.localeCompare(b));
|
.sort(({ tag: a }, { tag: b }) => isTagFavs(a) ? -1 : a.localeCompare(b));
|
||||||
|
|
||||||
|
export const sortTagsWithoutFavs = (tags: string[]) =>
|
||||||
|
sortTags(tags, TAG_FAVS);
|
||||||
|
|
||||||
export const descriptionForTaggedPhotos = (
|
export const descriptionForTaggedPhotos = (
|
||||||
photos: Photo[],
|
photos: Photo[],
|
||||||
dateBased?: boolean,
|
dateBased?: boolean,
|
||||||
|
|||||||
@ -37,6 +37,9 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
future: {
|
||||||
|
hoverOnlyWhenSupported: true,
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
require('@tailwindcss/forms'),
|
require('@tailwindcss/forms'),
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user