Merge branch 'main' into static

This commit is contained in:
Sam Becker 2024-02-04 23:47:09 -06:00
commit 0f557d07db
16 changed files with 754 additions and 415 deletions

View File

@ -9,23 +9,23 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@aws-sdk/client-s3": "3.501.0",
"@aws-sdk/s3-request-presigner": "3.501.0",
"@aws-sdk/client-s3": "3.504.0",
"@aws-sdk/s3-request-presigner": "3.504.0",
"@headlessui/react": "2.0.0-alpha.4",
"@next/bundle-analyzer": "14.1.0",
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.3.0",
"@testing-library/react": "^14.1.2",
"@types/jest": "^29.5.11",
"@types/node": "^20.11.8",
"@types/react": "18.2.48",
"@testing-library/jest-dom": "^6.4.1",
"@testing-library/react": "^14.2.1",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.16",
"@types/react": "18.2.53",
"@types/react-dom": "18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"@vercel/analytics": "^1.1.2",
"@vercel/blob": "^0.19.0",
"@vercel/blob": "^0.20.0",
"@vercel/postgres": "0.7.2",
"@vercel/speed-insights": "^1.0.8",
"@vercel/speed-insights": "^1.0.9",
"autoprefixer": "10.4.17",
"camelcase-keys": "^9.1.3",
"clsx": "^2.1.0",
@ -36,15 +36,15 @@
"framer-motion": "^11.0.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.4",
"next": "14.1.1-canary.21",
"nanoid": "^5.0.5",
"next": "14.1.1-canary.27",
"next-auth": "5.0.0-beta.5",
"next-themes": "^0.2.1",
"postcss": "8.4.33",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "^5.0.1",
"sonner": "^1.3.1",
"sonner": "^1.4.0",
"tailwindcss": "3.4.1",
"ts-exif-parser": "^0.2.2",
"typescript": "5.3.3"

757
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -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 }} />
);
};

View File

@ -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>
);
};

View File

@ -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'

View File

@ -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 TagInput from './TagInput';
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();
@ -72,6 +76,7 @@ export default function FieldSetWithStatus({
onChange={e => onChange?.(e.target.value)}
className={clsx(
'w-full',
clsx(Boolean(error) && 'error'),
// Use special class because `select` can't be readonly
readOnly || pending && 'disabled-select',
)}
@ -86,6 +91,15 @@ export default function FieldSetWithStatus({
{optionLabel}
</option>)}
</select>
: commaSeparatedOptions
? <TagInput
name={id}
value={value}
options={commaSeparatedOptions}
onChange={onChange}
className={clsx(Boolean(error) && 'error')}
readOnly={readOnly || pending}
/>
: <input
ref={inputRef}
id={id}
@ -98,12 +112,12 @@ export default function FieldSetWithStatus({
: e.target.value)}
type={type}
autoComplete="off"
autoCapitalize={!capitalize ? 'off' : undefined}
readOnly={readOnly || pending}
className={clsx(
type === 'text' && 'w-full',
error && 'error',
Boolean(error) && 'error',
)}
autoCapitalize={!capitalize ? 'off' : undefined}
/>}
</div>
);

View File

@ -1,5 +1,5 @@
import { BLUR_ENABLED } from '@/site/config';
import clsx from 'clsx/lite';
import { clsx} from 'clsx/lite';
import Image, { ImageProps } from 'next/image';
export default function ImageBlurFallback(props: ImageProps) {

View File

@ -1,4 +1,4 @@
import clsx from 'clsx/lite';
import { clsx} from 'clsx/lite';
import Link from 'next/link';
import { Menu } from '@headlessui/react';
import { FiMoreHorizontal } from 'react-icons/fi';

248
src/components/TagInput.tsx Normal file
View 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>
);
}

View File

@ -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>
);

View File

@ -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
@ -191,9 +198,7 @@ export default function PhotoForm({
>
Cancel
</Link>
<SubmitButtonWithStatus
disabled={!isFormValid(formData)}
>
<SubmitButtonWithStatus disabled={!isFormValid(formData)}>
{type === 'create' ? 'Create' : 'Update'}
</SubmitButtonWithStatus>
</div>

View File

@ -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
};
@ -41,7 +47,6 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
title: { label: 'title', capitalize: true },
tags: {
label: 'tags',
note: 'comma-separated values',
validate: tags => doesTagsStringIncludeFavs(tags)
? `'${TAG_FAVS}' is a reserved tag`
: undefined,
@ -77,8 +82,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 =

View File

@ -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]
@ -64,7 +65,7 @@
@apply
rounded-md
}
input.error, select.error {
.error {
@apply
border-red-500 dark:border-red-400
}
@ -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
}

View File

@ -2,7 +2,7 @@ import { FaStar } from 'react-icons/fa';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
import { TAG_FAVS } from '.';
import { pathForTag } from '@/site/paths';
import clsx from 'clsx';
import { clsx } from 'clsx/lite';
export default function FavsTag({
type,

View File

@ -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,

View File

@ -37,6 +37,9 @@ module.exports = {
},
},
},
future: {
hoverOnlyWhenSupported: true,
},
plugins: [
require('@tailwindcss/forms'),
],