Merge pull request #71 from sambecker/captions

Add captions to photos
This commit is contained in:
Sam Becker 2024-03-17 23:47:14 -05:00 committed by GitHub
commit cf09a06eeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 188 additions and 119 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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