From 0f632fe236874f5ff20e42d2e677e2fcc27ec0a4 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 30 Dec 2023 23:06:24 -0500 Subject: [PATCH 1/6] Add special "favs" tag --- .vscode/settings.json | 1 + src/app/(static)/grid/page.tsx | 15 ++-------- src/app/(static)/sets/page.tsx | 17 ++--------- src/camera/PhotoCamera.tsx | 4 +-- src/components/Badge.tsx | 39 ++++++++++++++++---------- src/components/EntityLink.tsx | 17 +++++++---- src/photo/PhotoGridSidebar.tsx | 19 ++++++++----- src/photo/data.ts | 17 +++++++++++ src/simulation/PhotoFilmSimulation.tsx | 4 +-- src/site/globals.css | 4 +++ src/tag/FavsTag.tsx | 38 +++++++++++++++++++++++++ src/tag/PhotoTag.tsx | 4 +-- src/tag/TagHeader.tsx | 7 +++-- src/tag/index.ts | 4 +++ 14 files changed, 129 insertions(+), 61 deletions(-) create mode 100644 src/photo/data.ts create mode 100644 src/tag/FavsTag.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index b1ff9f78..7f46718b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,7 @@ "exif", "exifr", "exiftool", + "favs", "ghijklmnopqrstuv", "hgetall", "hset", diff --git a/src/app/(static)/grid/page.tsx b/src/app/(static)/grid/page.tsx index 04b49373..c9a4d5bc 100644 --- a/src/app/(static)/grid/page.tsx +++ b/src/app/(static)/grid/page.tsx @@ -1,10 +1,4 @@ -import { - getPhotosCached, - getPhotosCountCached, - getUniqueCamerasCached, - getUniqueFilmSimulationsCached, - getUniqueTagsCached, -} from '@/cache'; +import { getPhotosCached } from '@/cache'; import SiteGrid from '@/components/SiteGrid'; import { generateOgImageMetaForPhotos } from '@/photo'; import PhotoGrid from '@/photo/PhotoGrid'; @@ -17,7 +11,7 @@ import { getPaginationForSearchParams, } from '@/site/pagination'; import PhotoGridSidebar from '@/photo/PhotoGridSidebar'; -import { SHOW_FILM_SIMULATIONS } from '@/site/config'; +import { getPhotoSidebarDataCached } from '@/photo/data'; export const runtime = 'edge'; @@ -37,10 +31,7 @@ export default async function GridPage({ searchParams }: PaginationParams) { simulations, ] = await Promise.all([ getPhotosCached({ limit }), - getPhotosCountCached(), - getUniqueTagsCached(), - getUniqueCamerasCached(), - SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [], + ...getPhotoSidebarDataCached(), ]); const showMorePath = photosCount > photos.length diff --git a/src/app/(static)/sets/page.tsx b/src/app/(static)/sets/page.tsx index 9427e65d..dfcca476 100644 --- a/src/app/(static)/sets/page.tsx +++ b/src/app/(static)/sets/page.tsx @@ -1,17 +1,11 @@ -import { - getPhotosCached, - getPhotosCountCached, - getUniqueCamerasCached, - getUniqueFilmSimulationsCached, - getUniqueTagsCached, -} from '@/cache'; +import { getPhotosCached } from '@/cache'; import InfoBlock from '@/components/InfoBlock'; import RedirectOnDesktop from '@/components/RedirectOnDesktop'; import SiteGrid from '@/components/SiteGrid'; import { generateOgImageMetaForPhotos } from '@/photo'; import PhotoGridSidebar from '@/photo/PhotoGridSidebar'; +import { getPhotoSidebarDataCached } from '@/photo/data'; import { MAX_PHOTOS_TO_SHOW_OG } from '@/photo/image-response'; -import { SHOW_FILM_SIMULATIONS } from '@/site/config'; import { PATH_GRID } from '@/site/paths'; import { Metadata } from 'next'; @@ -26,12 +20,7 @@ export default async function SetsPage() { tags, cameras, simulations, - ] = await Promise.all([ - getPhotosCountCached(), - getUniqueTagsCached(), - getUniqueCamerasCached(), - SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [], - ]); + ] = await Promise.all(getPhotoSidebarDataCached()); return ( } type={showAppleIcon && isCameraApple ? 'icon-first' : type} badged={badged} - dim={dim} + contrast={contrast} hoverEntity={countOnHover} /> ); diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 541dfb89..c8078b01 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -2,30 +2,39 @@ import { clsx } from 'clsx'; export default function Badge({ children, - type = 'primary', + type = 'large', + highContrast, uppercase, interactive, }: { children: React.ReactNode - type?: 'primary' | 'secondary' | 'text-only' + type?: 'large' | 'small' | 'text-only' + highContrast?: boolean uppercase?: boolean interactive?: boolean }) { const stylesForType = () => { switch (type) { - case 'primary': return clsx( - 'px-1.5 py-[0.3rem] rounded-md', - 'bg-gray-100/80 dark:bg-gray-900/80', - 'border border-gray-200/60 dark:border-gray-800/75' - ); - case 'secondary': return clsx( - 'px-[0.3rem] py-1 rounded-[0.25rem]', - 'bg-gray-300/30 dark:bg-gray-700/50', - 'text-medium', - 'font-medium text-[0.7rem]', - interactive && 'hover:text-gray-900 dark:hover:text-gray-100', - interactive && 'active:bg-gray-200 dark:active:bg-gray-700/60', - ); + case 'large': + return clsx( + 'px-1.5 py-[0.3rem] rounded-md', + 'bg-gray-100/80 dark:bg-gray-900/80', + 'border border-gray-200/60 dark:border-gray-800/75' + ); + case 'small': + return clsx( + 'px-[0.3rem] py-1 rounded-[0.25rem]', + 'text-[0.7rem] font-medium', + highContrast + ? 'text-invert bg-main' + : 'text-medium bg-gray-300/30 dark:bg-gray-700/50', + interactive && highContrast + ? 'hover:opacity-70' + : 'hover:text-gray-900 dark:hover:text-gray-100', + interactive && highContrast + ? 'active:opacity-90' + : 'active:bg-gray-200 dark:active:bg-gray-700/60', + ); } }; return ( diff --git a/src/components/EntityLink.tsx b/src/components/EntityLink.tsx index aad2dd48..220d20fa 100644 --- a/src/components/EntityLink.tsx +++ b/src/components/EntityLink.tsx @@ -6,7 +6,7 @@ import { clsx } from 'clsx'; export interface EntityLinkExternalProps { type?: 'icon-last' | 'icon-first' | 'icon-only' | 'text-only' badged?: boolean - dim?: boolean + contrast?: 'low' | 'medium' | 'high' } export default function EntityLink({ @@ -17,8 +17,8 @@ export default function EntityLink({ title, type = 'icon-first', badged, + contrast, hoverEntity, - dim, }: { label: ReactNode labelSmall?: ReactNode @@ -44,13 +44,18 @@ export default function EntityLink({ className={clsx( 'inline-flex gap-[0.23rem]', !badged && 'text-main hover:text-gray-900 dark:hover:text-gray-100', - dim && 'text-dim', + contrast === 'low' && 'text-dim', )} > {type !== 'icon-only' && <> {badged ? - + {renderLabel()} @@ -61,9 +66,11 @@ export default function EntityLink({ {icon && type !== 'text-only' && {icon} } diff --git a/src/photo/PhotoGridSidebar.tsx b/src/photo/PhotoGridSidebar.tsx index 0eec733c..999c3c25 100644 --- a/src/photo/PhotoGridSidebar.tsx +++ b/src/photo/PhotoGridSidebar.tsx @@ -5,12 +5,11 @@ import PhotoTag from '@/tag/PhotoTag'; import { FaTag } from 'react-icons/fa'; import { IoMdCamera } from 'react-icons/io'; import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.'; -import { Tags } from '@/tag'; -import PhotoFilmSimulation from - '@/simulation/PhotoFilmSimulation'; -import PhotoFilmSimulationIcon from - '@/simulation/PhotoFilmSimulationIcon'; +import { TAG_FAVS, Tags } from '@/tag'; +import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; +import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon'; import { FilmSimulations, sortFilmSimulationsWithCount } from '@/simulation'; +import FavsTag from '../tag/FavsTag'; export default function PhotoGridSidebar({ tags, @@ -32,8 +31,14 @@ export default function PhotoGridSidebar({ {tags.length > 0 && } - items={tags.map(({ tag, count }) => - tag === TAG_FAVS + ? + : [ + getPhotosCountCached(), + getUniqueTagsCached().then(tags => + ([tags.find(({ tag }) => tag === TAG_FAVS) ?? []] as Tags) + .concat(tags.filter(({ tag }) => tag !== TAG_FAVS))), + getUniqueCamerasCached(), + SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [], +] as const; diff --git a/src/simulation/PhotoFilmSimulation.tsx b/src/simulation/PhotoFilmSimulation.tsx index 08bb8a02..7087be84 100644 --- a/src/simulation/PhotoFilmSimulation.tsx +++ b/src/simulation/PhotoFilmSimulation.tsx @@ -8,7 +8,7 @@ export default function PhotoFilmSimulation({ simulation, type = 'icon-last', badged = true, - dim, + contrast, countOnHover, }: { simulation: FilmSimulation @@ -28,7 +28,7 @@ export default function PhotoFilmSimulation({ title={`Film Simulation: ${large}`} type={type} badged={badged} - dim={dim} + contrast={contrast} hoverEntity={countOnHover} /> ); diff --git a/src/site/globals.css b/src/site/globals.css index 81c052e0..dc8ba265 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -132,4 +132,8 @@ @apply text-red-500 dark:text-red-400 } + .bg-main { + @apply + bg-gray-900 dark:bg-gray-100 + } } diff --git a/src/tag/FavsTag.tsx b/src/tag/FavsTag.tsx new file mode 100644 index 00000000..fc92975a --- /dev/null +++ b/src/tag/FavsTag.tsx @@ -0,0 +1,38 @@ +import { FaStar } from 'react-icons/fa'; +import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink'; +import { TAG_FAVS } from '.'; +import { pathForTag } from '@/site/paths'; + +export default function FavsTag({ + type, + badged, + contrast, + countOnHover, +}: { + countOnHover?: number +} & EntityLinkExternalProps) { + return ( + + {TAG_FAVS} + + + : TAG_FAVS} + href={pathForTag(TAG_FAVS)} + icon={!badged && + } + type={type} + hoverEntity={countOnHover} + badged={badged} + contrast={contrast} + /> + ); +} diff --git a/src/tag/PhotoTag.tsx b/src/tag/PhotoTag.tsx index 800833fc..ecc4efaf 100644 --- a/src/tag/PhotoTag.tsx +++ b/src/tag/PhotoTag.tsx @@ -7,7 +7,7 @@ export default function PhotoTag({ tag, type, badged, - dim, + contrast, countOnHover, }: { tag: string @@ -23,7 +23,7 @@ export default function PhotoTag({ />} type={type} badged={badged} - dim={dim} + contrast={contrast} hoverEntity={countOnHover} /> ); diff --git a/src/tag/TagHeader.tsx b/src/tag/TagHeader.tsx index 7d63ed21..f320de7d 100644 --- a/src/tag/TagHeader.tsx +++ b/src/tag/TagHeader.tsx @@ -1,8 +1,9 @@ import { Photo, PhotoDateRange } from '@/photo'; import PhotoTag from './PhotoTag'; -import { descriptionForTaggedPhotos } from '.'; +import { descriptionForTaggedPhotos, isTagFavs } from '.'; import { pathForTagShare } from '@/site/paths'; import PhotoSetHeader from '@/photo/PhotoSetHeader'; +import FavsTag from './FavsTag'; export default function TagHeader({ tag, @@ -19,7 +20,9 @@ export default function TagHeader({ }) { return ( } + entity={isTagFavs(tag) + ? + : } entityVerb="Tagged" entityDescription={descriptionForTaggedPhotos(photos, undefined, count)} photos={photos} diff --git a/src/tag/index.ts b/src/tag/index.ts index e3ab8bb7..1b773b93 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -7,6 +7,8 @@ import { import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths'; import { capitalizeWords } from '@/utility/string'; +export const TAG_FAVS = 'favs'; + export type Tags = { tag: string count: number @@ -50,3 +52,5 @@ export const generateMetaForTag = ( descriptionForTaggedPhotos(photos, true, explicitCount, explicitDateRange), images: absolutePathForTagImage(tag), }); + +export const isTagFavs = (tag: string) => tag === TAG_FAVS; From 4c3c2a73ef417748b847e5fd7337783ad9a1cb07 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 31 Dec 2023 21:44:40 -0500 Subject: [PATCH 2/6] Prevent empty favs tag --- src/photo/data.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/photo/data.ts b/src/photo/data.ts index 26ed3f67..b06b5790 100644 --- a/src/photo/data.ts +++ b/src/photo/data.ts @@ -5,13 +5,13 @@ import { getUniqueTagsCached, } from '@/cache'; import { SHOW_FILM_SIMULATIONS } from '@/site/config'; -import { TAG_FAVS, Tags } from '@/tag'; +import { TAG_FAVS } from '@/tag'; export const getPhotoSidebarDataCached = () => [ getPhotosCountCached(), getUniqueTagsCached().then(tags => - ([tags.find(({ tag }) => tag === TAG_FAVS) ?? []] as Tags) - .concat(tags.filter(({ tag }) => tag !== TAG_FAVS))), + tags.filter(({ tag }) => tag === TAG_FAVS).concat( + tags.filter(({ tag }) => tag !== TAG_FAVS))), getUniqueCamerasCached(), SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [], ] as const; From 0d3155fc7a82bfed5e1f4d9eb567e1a6417aaa0e Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 1 Jan 2024 01:28:29 -0500 Subject: [PATCH 3/6] Flesh out favs visualization, incorporate into photo form --- src/components/EntityLink.tsx | 2 +- src/components/FieldSetWithStatus.tsx | 13 ++++- src/photo/PhotoForm.tsx | 18 +++++-- src/photo/PhotoLarge.tsx | 7 +-- src/photo/form.ts | 50 ++++++++++++++++--- src/photo/image-response/TagImageResponse.tsx | 20 ++++++-- src/site/globals.css | 4 ++ src/tag/FavsTag.tsx | 6 ++- src/tag/PhotoTags.tsx | 6 ++- src/tag/index.ts | 9 +++- 10 files changed, 110 insertions(+), 25 deletions(-) diff --git a/src/components/EntityLink.tsx b/src/components/EntityLink.tsx index 220d20fa..fc4028f0 100644 --- a/src/components/EntityLink.tsx +++ b/src/components/EntityLink.tsx @@ -37,7 +37,7 @@ export default function EntityLink({ ; return ( - + void selectOptions?: { value: string, label: string }[] @@ -45,10 +47,14 @@ export default function FieldSetWithStatus({ htmlFor={id} > {label} - {note && + {note && !error && ({note}) } + {error && + + {error} + } {required && Required @@ -93,7 +99,10 @@ export default function FieldSetWithStatus({ type={type} autoComplete="off" readOnly={readOnly || pending} - className={clsx(type === 'text' && 'w-full')} + className={clsx( + type === 'text' && 'w-full', + error && 'error', + )} autoCapitalize={!capitalize ? 'off' : undefined} />} diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index d11e23ae..340600b4 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react'; import { FORM_METADATA_ENTRIES, PhotoFormData, + getInitialErrors, + isFormValid, } from './form'; import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import NextImage from 'next/image'; @@ -35,6 +37,8 @@ export default function PhotoForm({ }) { const [formData, setFormData] = useState>(initialPhotoForm); + const [formErrors, setFormErrors] = + useState(getInitialErrors(initialPhotoForm)); // Update form when EXIF data // is refreshed by parent @@ -97,9 +101,6 @@ export default function PhotoForm({ })); }, []); - const isFormValid = FORM_METADATA_ENTRIES.every(([key, { required }]) => - !required || Boolean(formData[key])); - return (
@@ -143,6 +144,7 @@ export default function PhotoForm({ options, optionsDefaultLabel, readOnly, + validate, capitalize, hideIfEmpty, hideBasedOnCamera, @@ -158,8 +160,14 @@ export default function PhotoForm({ id={key} label={label} note={note} + error={formErrors[key]} value={formData[key] ?? ''} - onChange={value => setFormData({ ...formData, [key]: value })} + onChange={value => { + setFormData({ ...formData, [key]: value }); + if (validate) { + setFormErrors({ ...formErrors, [key]: validate(value) }); + } + }} selectOptions={options} selectOptionsDefaultLabel={optionsDefaultLabel} required={required} @@ -179,7 +187,7 @@ export default function PhotoForm({ Cancel {type === 'create' ? 'Create' : 'Update'} diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 9854d814..138ccc33 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -9,6 +9,7 @@ import ShareButton from '@/components/ShareButton'; import PhotoCamera from '../camera/PhotoCamera'; import { cameraFromPhoto } from '@/camera'; import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; +import { sortTags } from '@/tag'; export default function PhotoLarge({ photo, @@ -33,7 +34,7 @@ export default function PhotoLarge({ shouldShareSimulation?: boolean shouldScrollOnShare?: boolean }) { - const tagsToShow = photo.tags.filter(t => t !== primaryTag); + const tags = sortTags(photo.tags, primaryTag); const camera = cameraFromPhoto(photo); @@ -77,8 +78,8 @@ export default function PhotoLarge({ > {titleForPhoto(photo)} - {tagsToShow.length > 0 && - } + {tags.length > 0 && + }
{showCamera && photoHasCameraData(photo) &&
diff --git a/src/photo/form.ts b/src/photo/form.ts index 57103c49..864d2bce 100644 --- a/src/photo/form.ts +++ b/src/photo/form.ts @@ -14,14 +14,19 @@ import { } from '@/vendors/fujifilm'; import { FilmSimulation } from '@/simulation'; import { GEO_PRIVACY_ENABLED } from '@/site/config'; +import { TAG_FAVS } from '@/tag'; -export type PhotoFormData = Record; +type VirtualFields = 'favorite'; + +export type PhotoFormData = Record; type FormMeta = { label: string note?: string required?: boolean + virtual?: boolean readOnly?: boolean + validate?: (value?: string) => string | undefined capitalize?: boolean hideIfEmpty?: boolean hideTemporarily?: boolean @@ -34,7 +39,13 @@ type FormMeta = { const FORM_METADATA: Record = { title: { label: 'title', capitalize: true }, - tags: { label: 'tags', note: 'comma-separated values' }, + tags: { + label: 'tags', + note: 'comma-separated values', + validate: tags => tags?.toLowerCase().includes(TAG_FAVS) + ? `'${TAG_FAVS}' is a reserved tag` + : undefined, + }, id: { label: 'id', readOnly: true, hideIfEmpty: true }, blurData: { label: 'blur data', @@ -65,6 +76,7 @@ const FORM_METADATA: Record = { 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 }, }; @@ -72,13 +84,31 @@ export const FORM_METADATA_ENTRIES = (Object.entries(FORM_METADATA) as [keyof PhotoFormData, FormMeta][]) .filter(([_, meta]) => !meta.hideTemporarily); +export const getInitialErrors = ( + formData: Partial +): Partial> => + Object.keys(formData).reduce((acc, key) => ({ + ...acc, + [key]: FORM_METADATA_ENTRIES.find(([k]) => k === key)?.[1] + .validate?.(formData[key as keyof PhotoFormData]), + }), {}); + +export const isFormValid = (formData: Partial) => + FORM_METADATA_ENTRIES.every( + ([key, { required, validate }]) => + (!required || Boolean(formData[key])) && + (validate?.(formData[key]) === undefined) + ); + export const convertPhotoToFormData = ( photo: Photo, ): PhotoFormData => { const valueForKey = (key: keyof Photo, value: any) => { switch (key) { case 'tags': - return value?.join ? value.join(', ') : value; + return (value ?? []) + .filter((tag: string) => tag !== TAG_FAVS) + .join(', '); case 'takenAt': return value?.toISOString ? value.toISOString() : value; case 'hidden': @@ -92,7 +122,9 @@ export const convertPhotoToFormData = ( return Object.entries(photo).reduce((photoForm, [key, value]) => ({ ...photoForm, [key]: valueForKey(key as keyof Photo, value), - }), {} as PhotoFormData); + }), { + favorite: photo.tags.includes(TAG_FAVS) ? 'true' : 'false', + } as PhotoFormData); }; export const convertExifToFormData = ( @@ -131,6 +163,11 @@ export const convertFormDataToPhotoDbInsert = ( const photoForm = formData instanceof FormData ? Object.fromEntries(formData) as PhotoFormData : formData; + + const tags = convertStringToArray(photoForm.tags) ?? []; + if (photoForm.favorite === 'true') { + tags.push(TAG_FAVS); + } // Parse FormData: // - remove server action ID @@ -138,7 +175,8 @@ export const convertFormDataToPhotoDbInsert = ( Object.keys(photoForm).forEach(key => { if ( key.startsWith('$ACTION_ID_') || - (photoForm as any)[key] === '' + (photoForm as any)[key] === '' || + FORM_METADATA[key as keyof PhotoFormData]?.virtual ) { delete (photoForm as any)[key]; } @@ -148,7 +186,7 @@ export const convertFormDataToPhotoDbInsert = ( ...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }), ...(generateId && !photoForm.id) && { id: generateNanoid() }, // Convert form strings to arrays - tags: convertStringToArray(photoForm.tags), + tags: tags.length > 0 ? tags : undefined, // Convert form strings to numbers aspectRatio: toFixedNumber(parseFloat(photoForm.aspectRatio), 6), focalLength: photoForm.focalLength diff --git a/src/photo/image-response/TagImageResponse.tsx b/src/photo/image-response/TagImageResponse.tsx index 2f5f8c56..038d6fd0 100644 --- a/src/photo/image-response/TagImageResponse.tsx +++ b/src/photo/image-response/TagImageResponse.tsx @@ -1,9 +1,10 @@ import { Photo } from '..'; -import { FaTag } from 'react-icons/fa'; +import { FaStar, FaTag } from 'react-icons/fa'; import ImageCaption from './components/ImageCaption'; import ImagePhotoGrid from './components/ImagePhotoGrid'; import ImageContainer from './components/ImageContainer'; import { NextImageSize } from '@/services/next-image'; +import { isTagFavs } from '@/tag'; export default function TagImageResponse({ tag, @@ -32,10 +33,19 @@ export default function TagImageResponse({ }} /> - + {isTagFavs(tag) + ? + : } {tag.toUpperCase()} diff --git a/src/site/globals.css b/src/site/globals.css index dc8ba265..6a71046c 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -64,6 +64,10 @@ @apply rounded-md } + input.error, select.error { + @apply + border-red-500 dark:border-red-400 + } button, .button { @apply cursor-pointer diff --git a/src/tag/FavsTag.tsx b/src/tag/FavsTag.tsx index fc92975a..3590f015 100644 --- a/src/tag/FavsTag.tsx +++ b/src/tag/FavsTag.tsx @@ -2,6 +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'; export default function FavsTag({ type, @@ -27,7 +28,10 @@ export default function FavsTag({ icon={!badged && } type={type} hoverEntity={countOnHover} diff --git a/src/tag/PhotoTags.tsx b/src/tag/PhotoTags.tsx index 79d2a212..4804dc17 100644 --- a/src/tag/PhotoTags.tsx +++ b/src/tag/PhotoTags.tsx @@ -1,4 +1,6 @@ import PhotoTag from '@/tag/PhotoTag'; +import { isTagFavs } from '.'; +import FavsTag from './FavsTag'; export default function PhotoTags({ tags, @@ -9,7 +11,9 @@ export default function PhotoTags({
{tags.map(tag =>
- + {isTagFavs(tag) + ? + : }
)}
); diff --git a/src/tag/index.ts b/src/tag/index.ts index 1b773b93..8bc7c2da 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -26,6 +26,13 @@ export const titleForTag = ( photoQuantityText(explicitCount ?? photos.length), ].join(' '); +export const sortTags = ( + tags: string[], + tagToHide?: string, +) => tags + .filter(t => t !== tagToHide) + .sort((a, b) => isTagFavs(a) ? -1 : a.localeCompare(b)); + export const descriptionForTaggedPhotos = ( photos: Photo[], dateBased?: boolean, @@ -53,4 +60,4 @@ export const generateMetaForTag = ( images: absolutePathForTagImage(tag), }); -export const isTagFavs = (tag: string) => tag === TAG_FAVS; +export const isTagFavs = (tag: string) => tag.toLowerCase() === TAG_FAVS; From 5decc372736cc0a23bb26b20e736d0c2b539b546 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 1 Jan 2024 11:48:39 -0500 Subject: [PATCH 4/6] Move /sets to edge runtime to prevent stale pages --- src/app/(static)/sets/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/(static)/sets/page.tsx b/src/app/(static)/sets/page.tsx index dfcca476..bc76bb52 100644 --- a/src/app/(static)/sets/page.tsx +++ b/src/app/(static)/sets/page.tsx @@ -9,6 +9,8 @@ import { MAX_PHOTOS_TO_SHOW_OG } from '@/photo/image-response'; import { PATH_GRID } from '@/site/paths'; import { Metadata } from 'next'; +export const runtime = 'edge'; + export async function generateMetadata(): Promise { const photos = await getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_OG }); return generateOgImageMetaForPhotos(photos); From 4ba7455442ff5bf80c1f559dd2130f89d1a82bb9 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 1 Jan 2024 23:40:09 -0500 Subject: [PATCH 5/6] Add photo form documentation --- src/photo/form.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/photo/form.ts b/src/photo/form.ts index 864d2bce..b9326ca0 100644 --- a/src/photo/form.ts +++ b/src/photo/form.ts @@ -100,6 +100,8 @@ export const isFormValid = (formData: Partial) => (validate?.(formData[key]) === undefined) ); +// CREATE FORM DATA: FROM PHOTO + export const convertPhotoToFormData = ( photo: Photo, ): PhotoFormData => { @@ -127,6 +129,8 @@ export const convertPhotoToFormData = ( } as PhotoFormData); }; +// CREATE FORM DATA: FROM EXIF + export const convertExifToFormData = ( data: ExifData, filmSimulation?: FilmSimulation, @@ -156,6 +160,8 @@ export const convertExifToFormData = ( : undefined, }); +// PREPARE FORM FOR DB INSERT + export const convertFormDataToPhotoDbInsert = ( formData: FormData | PhotoFormData, generateId?: boolean, From 7905fb569bae6faabf2e5e620ded4784fbbbe7b2 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 2 Jan 2024 23:02:54 -0500 Subject: [PATCH 6/6] Refine tags/favs validation --- src/photo/form.ts | 4 ++-- src/tag/index.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/photo/form.ts b/src/photo/form.ts index b9326ca0..cf4a322a 100644 --- a/src/photo/form.ts +++ b/src/photo/form.ts @@ -14,7 +14,7 @@ import { } from '@/vendors/fujifilm'; import { FilmSimulation } from '@/simulation'; import { GEO_PRIVACY_ENABLED } from '@/site/config'; -import { TAG_FAVS } from '@/tag'; +import { TAG_FAVS, doesTagsStringIncludeFavs } from '@/tag'; type VirtualFields = 'favorite'; @@ -42,7 +42,7 @@ const FORM_METADATA: Record = { tags: { label: 'tags', note: 'comma-separated values', - validate: tags => tags?.toLowerCase().includes(TAG_FAVS) + validate: tags => doesTagsStringIncludeFavs(tags) ? `'${TAG_FAVS}' is a reserved tag` : undefined, }, diff --git a/src/tag/index.ts b/src/tag/index.ts index 8bc7c2da..a703182f 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -5,7 +5,7 @@ import { photoQuantityText, } from '@/photo'; import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths'; -import { capitalizeWords } from '@/utility/string'; +import { capitalizeWords, convertStringToArray } from '@/utility/string'; export const TAG_FAVS = 'favs'; @@ -17,6 +17,9 @@ export type Tags = { export const formatTag = (tag?: string) => capitalizeWords(tag?.replaceAll('-', ' ')); +export const doesTagsStringIncludeFavs = (tags?: string) => + convertStringToArray(tags)?.some(tag => isTagFavs(tag)); + export const titleForTag = ( tag: string, photos:Photo[],