commit
cf09a06eeb
@ -3,7 +3,6 @@ import { pathForCamera } from '@/site/paths';
|
||||
import { IoMdCamera } from 'react-icons/io';
|
||||
import { Camera, formatCameraText } from '.';
|
||||
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
|
||||
import { clsx } from 'clsx/lite';
|
||||
|
||||
export default function PhotoCamera({
|
||||
camera,
|
||||
@ -27,18 +26,12 @@ export default function PhotoCamera({
|
||||
icon={showAppleIcon
|
||||
? <AiFillApple
|
||||
title="Apple"
|
||||
className={clsx(
|
||||
'text-icon',
|
||||
'translate-x-[-2.5px] translate-y-[2px]',
|
||||
)}
|
||||
className="translate-x-[-2.5px] translate-y-[2px]"
|
||||
size={15}
|
||||
/>
|
||||
: <IoMdCamera
|
||||
size={13}
|
||||
className={clsx(
|
||||
'text-icon',
|
||||
'translate-x-[-1px] translate-y-[3.5px]',
|
||||
)}
|
||||
size={12}
|
||||
className="translate-x-[-1px] translate-y-[3.5px]"
|
||||
/>}
|
||||
type={showAppleIcon && isCameraApple ? 'icon-first' : type}
|
||||
badged={badged}
|
||||
|
||||
@ -17,7 +17,7 @@ export default function EntityLink({
|
||||
title,
|
||||
type = 'icon-first',
|
||||
badged,
|
||||
contrast,
|
||||
contrast = 'high',
|
||||
hoverEntity,
|
||||
}: {
|
||||
label: ReactNode
|
||||
@ -36,15 +36,26 @@ export default function EntityLink({
|
||||
</span>
|
||||
</>;
|
||||
|
||||
const classForContrast = () => {
|
||||
switch (contrast) {
|
||||
case 'low':
|
||||
return 'text-dim';
|
||||
case 'high':
|
||||
return 'text-main';
|
||||
default:
|
||||
return 'text-medium';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="group inline-flex items-center gap-2">
|
||||
<span className="group inline-flex items-center gap-2 h-5">
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
className={clsx(
|
||||
'inline-flex gap-[0.23rem]',
|
||||
!badged && 'text-main hover:text-gray-900 dark:hover:text-gray-100',
|
||||
contrast === 'low' && 'text-dim',
|
||||
classForContrast(),
|
||||
)}
|
||||
>
|
||||
{type !== 'icon-only' && <>
|
||||
@ -67,7 +78,9 @@ export default function EntityLink({
|
||||
<span className={clsx(
|
||||
'flex-shrink-0',
|
||||
'inline-flex min-w-[0.9rem]',
|
||||
contrast === 'low' ? 'text-dim' : 'text-main',
|
||||
contrast === 'high'
|
||||
? 'text-icon'
|
||||
: classForContrast(),
|
||||
type === 'icon-first' && 'order-first',
|
||||
badged && 'translate-y-[4px]',
|
||||
hoverEntity !== undefined && 'group-hover:hidden',
|
||||
|
||||
@ -36,6 +36,7 @@ export default function PhotoGridSidebar({
|
||||
key={TAG_FAVS}
|
||||
countOnHover={count}
|
||||
type="icon-last"
|
||||
contrast="low"
|
||||
badged
|
||||
/>
|
||||
: <PhotoTag
|
||||
@ -43,6 +44,7 @@ export default function PhotoGridSidebar({
|
||||
tag={tag}
|
||||
type="text-only"
|
||||
countOnHover={count}
|
||||
contrast="low"
|
||||
badged
|
||||
/>)}
|
||||
/>}
|
||||
@ -60,6 +62,7 @@ export default function PhotoGridSidebar({
|
||||
camera={camera}
|
||||
type="text-only"
|
||||
countOnHover={count}
|
||||
contrast="low"
|
||||
hideAppleIcon
|
||||
badged
|
||||
/>)}
|
||||
|
||||
@ -44,16 +44,10 @@ export default function PhotoLarge({
|
||||
const tags = sortTags(photo.tags, primaryTag);
|
||||
|
||||
const camera = cameraFromPhoto(photo);
|
||||
|
||||
const renderMiniGrid = (children: JSX.Element, rightPadding = true) =>
|
||||
<div className={clsx(
|
||||
'flex gap-y-4',
|
||||
'flex-col sm:flex-row md:flex-col',
|
||||
'[&>*]:sm:flex-grow',
|
||||
rightPadding && 'pr-2',
|
||||
)}>
|
||||
{children}
|
||||
</div>;
|
||||
|
||||
const showCameraContent = showCamera && shouldShowCameraDataForPhoto(photo);
|
||||
const showTagsContent = tags.length > 0;
|
||||
const showExifContent = shouldShowExifDataForPhoto(photo);
|
||||
|
||||
return (
|
||||
<SiteGrid
|
||||
@ -69,76 +63,81 @@ export default function PhotoLarge({
|
||||
/>}
|
||||
contentSide={
|
||||
<div className={clsx(
|
||||
'relative',
|
||||
'leading-snug',
|
||||
'sticky top-4 self-start',
|
||||
'sticky top-4 self-start -translate-y-1',
|
||||
'grid grid-cols-2 md:grid-cols-1',
|
||||
'gap-x-0.5 sm:gap-x-1',
|
||||
'gap-y-4',
|
||||
'-translate-y-1',
|
||||
'mb-4',
|
||||
'gap-x-0.5 sm:gap-x-1 gap-y-4',
|
||||
'pb-6',
|
||||
)}>
|
||||
{renderMiniGrid(<>
|
||||
<div className="-space-y-0.5">
|
||||
<div className="relative flex gap-2 items-start">
|
||||
<div className="md:flex-grow">
|
||||
<Link
|
||||
href={pathForPhoto(photo)}
|
||||
className="font-bold uppercase"
|
||||
>
|
||||
{titleForPhoto(photo)}
|
||||
</Link>
|
||||
</div>
|
||||
<Suspense>
|
||||
<div className="h-4 translate-y-[-3.5px] z-10">
|
||||
<AdminPhotoMenu photo={photo} />
|
||||
</div>
|
||||
</Suspense>
|
||||
{/* Meta */}
|
||||
<div className="pr-3 md:pr-0">
|
||||
<div className="md:relative flex gap-2 items-start">
|
||||
<div className="flex-grow">
|
||||
<Link
|
||||
href={pathForPhoto(photo)}
|
||||
className="font-bold uppercase"
|
||||
>
|
||||
{titleForPhoto(photo)}
|
||||
</Link>
|
||||
</div>
|
||||
{tags.length > 0 &&
|
||||
<PhotoTags tags={tags} />}
|
||||
<Suspense>
|
||||
<div className="absolute right-0 translate-y-[-4px] z-10">
|
||||
<AdminPhotoMenu photo={photo} />
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
{showCamera && shouldShowCameraDataForPhoto(photo) &&
|
||||
<div className="space-y-0.5">
|
||||
<PhotoCamera
|
||||
camera={camera}
|
||||
type="text-only"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{photo.caption &&
|
||||
<div className="uppercase">
|
||||
{photo.caption}
|
||||
</div>}
|
||||
{(showCameraContent || showTagsContent) &&
|
||||
<div>
|
||||
{showCameraContent &&
|
||||
<PhotoCamera
|
||||
camera={camera}
|
||||
contrast="medium"
|
||||
/>}
|
||||
{showTagsContent &&
|
||||
<PhotoTags tags={tags} contrast="medium" />}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
{/* EXIF Data */}
|
||||
<div className="space-y-4">
|
||||
{showExifContent &&
|
||||
<>
|
||||
<ul className="text-medium">
|
||||
<li>
|
||||
{photo.focalLengthFormatted}
|
||||
{photo.focalLengthIn35MmFormatFormatted &&
|
||||
<>
|
||||
{' '}
|
||||
<span
|
||||
title="35mm equivalent"
|
||||
className="text-extra-dim"
|
||||
>
|
||||
{photo.focalLengthIn35MmFormatFormatted}
|
||||
</span>
|
||||
</>}
|
||||
</li>
|
||||
<li>{photo.fNumberFormatted}</li>
|
||||
<li>{photo.exposureTimeFormatted}</li>
|
||||
<li>{photo.isoFormatted}</li>
|
||||
<li>{photo.exposureCompensationFormatted ?? '0ev'}</li>
|
||||
</ul>
|
||||
{showSimulation && photo.filmSimulation &&
|
||||
<div className="translate-x-[-0.3rem]">
|
||||
<PhotoFilmSimulation
|
||||
simulation={photo.filmSimulation}
|
||||
/>
|
||||
</div>}
|
||||
</div>}
|
||||
</>)}
|
||||
{renderMiniGrid(<>
|
||||
{shouldShowExifDataForPhoto(photo) &&
|
||||
<ul className="text-medium">
|
||||
<li>
|
||||
{photo.focalLengthFormatted}
|
||||
{photo.focalLengthIn35MmFormatFormatted &&
|
||||
<>
|
||||
{' '}
|
||||
<span
|
||||
title="35mm equivalent"
|
||||
className="text-extra-dim"
|
||||
>
|
||||
{photo.focalLengthIn35MmFormatFormatted}
|
||||
</span>
|
||||
</>}
|
||||
</li>
|
||||
<li>{photo.fNumberFormatted}</li>
|
||||
<li>{photo.exposureTimeFormatted}</li>
|
||||
<li>{photo.isoFormatted}</li>
|
||||
<li>{photo.exposureCompensationFormatted ?? '—'}</li>
|
||||
</ul>}
|
||||
<PhotoFilmSimulation
|
||||
simulation={photo.filmSimulation}
|
||||
/>}
|
||||
</>}
|
||||
<div className={clsx(
|
||||
'flex gap-y-4',
|
||||
'flex-col sm:flex-row md:flex-col',
|
||||
'flex gap-2',
|
||||
'md:flex-col md:gap-4 md:justify-normal',
|
||||
)}>
|
||||
<div className={clsx(
|
||||
'grow uppercase',
|
||||
'text-medium',
|
||||
'text-medium uppercase pr-1',
|
||||
)}>
|
||||
{photo.takenAtNaiveFormatted}
|
||||
</div>
|
||||
@ -153,7 +152,7 @@ export default function PhotoLarge({
|
||||
shouldScroll={shouldScrollOnShare}
|
||||
/>
|
||||
</div>
|
||||
</>, false)}
|
||||
</div>
|
||||
</div>}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -168,15 +168,16 @@ export default function PhotoForm({
|
||||
tagOptions,
|
||||
readOnly,
|
||||
validate,
|
||||
validateStringMaxLength,
|
||||
capitalize,
|
||||
hideIfEmpty,
|
||||
hideBasedOnCamera,
|
||||
shouldHide,
|
||||
loadingMessage,
|
||||
type,
|
||||
}]) =>
|
||||
(
|
||||
(!hideIfEmpty || formData[key]) &&
|
||||
!hideBasedOnCamera?.(formData.make)
|
||||
!shouldHide?.(formData)
|
||||
) &&
|
||||
<FieldSetWithStatus
|
||||
key={key}
|
||||
@ -189,6 +190,13 @@ export default function PhotoForm({
|
||||
setFormData({ ...formData, [key]: value });
|
||||
if (validate) {
|
||||
setFormErrors({ ...formErrors, [key]: validate(value) });
|
||||
} else if (validateStringMaxLength !== undefined) {
|
||||
setFormErrors({
|
||||
...formErrors,
|
||||
[key]: value.length > validateStringMaxLength
|
||||
? `${validateStringMaxLength} characters or less`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (key === 'title') {
|
||||
onTitleChange?.(value.trim());
|
||||
|
||||
@ -39,10 +39,11 @@ type FormMeta = {
|
||||
virtual?: boolean
|
||||
readOnly?: boolean
|
||||
validate?: (value?: string) => string | undefined
|
||||
validateStringMaxLength?: number
|
||||
capitalize?: boolean
|
||||
hide?: boolean
|
||||
hideIfEmpty?: boolean
|
||||
hideBasedOnCamera?: (make?: string, mode?: string) => boolean
|
||||
shouldHide?: (formData: Partial<PhotoFormData>) => boolean
|
||||
loadingMessage?: string
|
||||
type?: FieldSetType
|
||||
selectOptions?: { value: string, label: string }[]
|
||||
@ -50,10 +51,29 @@ type FormMeta = {
|
||||
tagOptions?: AnnotatedTag[]
|
||||
};
|
||||
|
||||
const STRING_MAX_LENGTH_SHORT = 255;
|
||||
const STRING_MAX_LENGTH_LONG = 1000;
|
||||
|
||||
const FORM_METADATA = (
|
||||
tagOptions?: AnnotatedTag[]
|
||||
): Record<keyof PhotoFormData, FormMeta> => ({
|
||||
title: { label: 'title', capitalize: true },
|
||||
title: {
|
||||
label: 'title',
|
||||
capitalize: true,
|
||||
validateStringMaxLength: STRING_MAX_LENGTH_SHORT,
|
||||
},
|
||||
caption: {
|
||||
label: 'caption',
|
||||
capitalize: true,
|
||||
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
|
||||
shouldHide: ({ title, caption }) => !title && !caption,
|
||||
},
|
||||
semanticDescription: {
|
||||
label: 'semantic description',
|
||||
capitalize: true,
|
||||
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
|
||||
hide: true,
|
||||
},
|
||||
tags: {
|
||||
label: 'tags',
|
||||
tagOptions,
|
||||
@ -78,7 +98,7 @@ const FORM_METADATA = (
|
||||
label: 'fujifilm simulation',
|
||||
selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS,
|
||||
selectOptionsDefaultLabel: 'Unknown',
|
||||
hideBasedOnCamera: make => make !== MAKE_FUJIFILM,
|
||||
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
|
||||
},
|
||||
focalLength: { label: 'focal length' },
|
||||
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
|
||||
@ -116,9 +136,12 @@ export const getFormErrors = (
|
||||
|
||||
export const isFormValid = (formData: Partial<PhotoFormData>) =>
|
||||
FORM_METADATA_ENTRIES().every(
|
||||
([key, { required, validate }]) =>
|
||||
([key, { required, validate, validateStringMaxLength }]) =>
|
||||
(!required || Boolean(formData[key])) &&
|
||||
(validate?.(formData[key]) === undefined)
|
||||
(validate?.(formData[key]) === undefined) &&
|
||||
// eslint-disable-next-line max-len
|
||||
(!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength) &&
|
||||
(key !== 'tags' || !doesTagsStringIncludeFavs(formData.tags ?? ''))
|
||||
);
|
||||
|
||||
// CREATE FORM DATA: FROM PHOTO
|
||||
|
||||
@ -47,6 +47,8 @@ export interface PhotoDbInsert extends PhotoExif {
|
||||
extension: string
|
||||
blurData?: string
|
||||
title?: string
|
||||
caption?: string
|
||||
semanticDescription?: string
|
||||
tags?: string[]
|
||||
locationName?: string
|
||||
priorityOrder?: number
|
||||
@ -229,16 +231,16 @@ export const dateRangeForPhotos = (
|
||||
};
|
||||
|
||||
const photoHasCameraData = (photo: Photo) =>
|
||||
photo.make &&
|
||||
photo.model;
|
||||
Boolean(photo.make) &&
|
||||
Boolean(photo.model);
|
||||
|
||||
const photoHasExifData = (photo: Photo) =>
|
||||
photo.focalLength ||
|
||||
photo.focalLengthIn35MmFormat ||
|
||||
photo.fNumberFormatted ||
|
||||
photo.isoFormatted ||
|
||||
photo.exposureTimeFormatted ||
|
||||
photo.exposureCompensationFormatted;
|
||||
Boolean(photo.focalLength) ||
|
||||
Boolean(photo.focalLengthIn35MmFormat) ||
|
||||
Boolean(photo.fNumberFormatted) ||
|
||||
Boolean(photo.isoFormatted) ||
|
||||
Boolean(photo.exposureTimeFormatted) ||
|
||||
Boolean(photo.exposureCompensationFormatted);
|
||||
|
||||
export const shouldShowCameraDataForPhoto = (photo: Photo) =>
|
||||
SHOW_EXIF_DATA && photoHasCameraData(photo);
|
||||
|
||||
@ -28,6 +28,8 @@ const sqlCreatePhotosTable = () =>
|
||||
aspect_ratio REAL DEFAULT 1.5,
|
||||
blur_data TEXT,
|
||||
title VARCHAR(255),
|
||||
caption TEXT,
|
||||
semantic_description TEXT,
|
||||
tags VARCHAR(255)[],
|
||||
make VARCHAR(255),
|
||||
model VARCHAR(255),
|
||||
@ -50,9 +52,18 @@ const sqlCreatePhotosTable = () =>
|
||||
)
|
||||
`;
|
||||
|
||||
// Migration 01
|
||||
const MIGRATION_FIELDS_01 = ['caption', 'semantic_description'];
|
||||
const sqlRunMigration01 = () =>
|
||||
sql`
|
||||
ALTER TABLE photos
|
||||
ADD COLUMN IF NOT EXISTS caption TEXT,
|
||||
ADD COLUMN IF NOT EXISTS semantic_description TEXT
|
||||
`;
|
||||
|
||||
// Must provide id as 8-character nanoid
|
||||
export const sqlInsertPhoto = (photo: PhotoDbInsert) => {
|
||||
return sql`
|
||||
export const sqlInsertPhoto = (photo: PhotoDbInsert) =>
|
||||
safelyQueryPhotos(() => sql`
|
||||
INSERT INTO photos (
|
||||
id,
|
||||
url,
|
||||
@ -60,6 +71,8 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) => {
|
||||
aspect_ratio,
|
||||
blur_data,
|
||||
title,
|
||||
caption,
|
||||
semantic_description,
|
||||
tags,
|
||||
make,
|
||||
model,
|
||||
@ -85,6 +98,8 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) => {
|
||||
${photo.aspectRatio},
|
||||
${photo.blurData},
|
||||
${photo.title},
|
||||
${photo.caption},
|
||||
${photo.semanticDescription},
|
||||
${convertArrayToPostgresString(photo.tags)},
|
||||
${photo.make},
|
||||
${photo.model},
|
||||
@ -103,17 +118,18 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) => {
|
||||
${photo.takenAt},
|
||||
${photo.takenAtNaive}
|
||||
)
|
||||
`;
|
||||
};
|
||||
`);
|
||||
|
||||
export const sqlUpdatePhoto = (photo: PhotoDbInsert) =>
|
||||
sql`
|
||||
safelyQueryPhotos(() => sql`
|
||||
UPDATE photos SET
|
||||
url=${photo.url},
|
||||
extension=${photo.extension},
|
||||
aspect_ratio=${photo.aspectRatio},
|
||||
blur_data=${photo.blurData},
|
||||
title=${photo.title},
|
||||
caption=${photo.caption},
|
||||
semantic_description=${photo.semanticDescription},
|
||||
tags=${convertArrayToPostgresString(photo.tags)},
|
||||
make=${photo.make},
|
||||
model=${photo.model},
|
||||
@ -133,27 +149,29 @@ export const sqlUpdatePhoto = (photo: PhotoDbInsert) =>
|
||||
taken_at_naive=${photo.takenAtNaive},
|
||||
updated_at=${(new Date()).toISOString()}
|
||||
WHERE id=${photo.id}
|
||||
`;
|
||||
`);
|
||||
|
||||
export const sqlDeletePhotoTagGlobally = (tag: string) =>
|
||||
sql`
|
||||
safelyQueryPhotos(() => sql`
|
||||
UPDATE photos
|
||||
SET tags=ARRAY_REMOVE(tags, ${tag})
|
||||
WHERE ${tag}=ANY(tags)
|
||||
`;
|
||||
`);
|
||||
|
||||
export const sqlRenamePhotoTagGlobally = (tag: string, updatedTag: string) =>
|
||||
sql`
|
||||
safelyQueryPhotos(() => sql`
|
||||
UPDATE photos
|
||||
SET tags=ARRAY_REPLACE(tags, ${tag}, ${updatedTag})
|
||||
WHERE ${tag}=ANY(tags)
|
||||
`;
|
||||
`);
|
||||
|
||||
export const sqlDeletePhoto = (id: string) =>
|
||||
sql`DELETE FROM photos WHERE id=${id}`;
|
||||
safelyQueryPhotos(() => sql`DELETE FROM photos WHERE id=${id}`);
|
||||
|
||||
const sqlGetPhoto = (id: string) =>
|
||||
sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`;
|
||||
safelyQueryPhotos(() =>
|
||||
sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`
|
||||
);
|
||||
|
||||
const sqlGetPhotosCount = async () => sql`
|
||||
SELECT COUNT(*) FROM photos
|
||||
@ -291,8 +309,16 @@ const safelyQueryPhotos = async <T>(callback: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
result = await callback();
|
||||
} catch (e: any) {
|
||||
if (/relation "photos" does not exist/i.test(e.message)) {
|
||||
console.log('Creating table "photos" because it did not exist');
|
||||
if (MIGRATION_FIELDS_01.some(field => new RegExp(
|
||||
`column "${field}" of relation "photos" does not exist`,
|
||||
'i',
|
||||
).test(e.message))) {
|
||||
console.log('Running migration 01 ...');
|
||||
await sqlRunMigration01();
|
||||
result = await callback();
|
||||
} else if (/relation "photos" does not exist/i.test(e.message)) {
|
||||
// If the table does not exist, create it
|
||||
console.log('Creating photos table ...');
|
||||
await sqlCreatePhotosTable();
|
||||
result = await callback();
|
||||
} else if (/endpoint is in transition/i.test(e.message)) {
|
||||
|
||||
@ -19,7 +19,7 @@ export default function PhotoTag({
|
||||
href={pathForTag(tag)}
|
||||
icon={<FaTag
|
||||
size={11}
|
||||
className="text-icon translate-y-[5px]"
|
||||
className="translate-y-[5px]"
|
||||
/>}
|
||||
type={type}
|
||||
badged={badged}
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
import PhotoTag from '@/tag/PhotoTag';
|
||||
import { isTagFavs } from '.';
|
||||
import FavsTag from './FavsTag';
|
||||
import { EntityLinkExternalProps } from '@/components/EntityLink';
|
||||
|
||||
export default function PhotoTags({
|
||||
tags,
|
||||
contrast,
|
||||
}: {
|
||||
tags: string[]
|
||||
}) {
|
||||
} & EntityLinkExternalProps) {
|
||||
return (
|
||||
<div className="-space-y-0.5">
|
||||
{tags.map(tag =>
|
||||
<div key={tag}>
|
||||
{isTagFavs(tag)
|
||||
? <FavsTag />
|
||||
: <PhotoTag tag={tag} />}
|
||||
? <FavsTag {...{ contrast }} />
|
||||
: <PhotoTag {...{ tag, contrast }} />}
|
||||
</div>)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user