diff --git a/.vscode/settings.json b/.vscode/settings.json index ea94cecd..4a786c5d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,7 @@ "Astia", "authjs", "camelcase", + "CLAR", "cloudflarestorage", "cmdk", "Consolas", diff --git a/app/admin/photos/[photoId]/edit/page.tsx b/app/admin/photos/[photoId]/edit/page.tsx index e81de2c7..38dda275 100644 --- a/app/admin/photos/[photoId]/edit/page.tsx +++ b/app/admin/photos/[photoId]/edit/page.tsx @@ -1,6 +1,7 @@ import { redirect } from 'next/navigation'; import { getPhotoNoStore, + getUniqueFilmsCached, getUniqueRecipesCached, getUniqueTagsCached, } from '@/photo/cache'; @@ -10,7 +11,6 @@ import { AI_TEXT_GENERATION_ENABLED, BLUR_ENABLED, IS_PREVIEW, - SHOW_RECIPES, } from '@/app/config'; import { blurImageFromUrl, resizeImageFromUrl } from '@/photo/server'; import { getNextImageUrlForManipulation } from '@/platforms/next-image'; @@ -22,16 +22,20 @@ export default async function PhotoEditPage({ }) { const { photoId } = await params; - const photo = await getPhotoNoStore(photoId, true); + const [ + photo, + uniqueTags, + uniqueRecipes, + uniqueFilms, + ] = await Promise.all([ + getPhotoNoStore(photoId, true), + getUniqueTagsCached(), + getUniqueRecipesCached(), + getUniqueFilmsCached(), + ]); if (!photo) { redirect(PATH_ADMIN); } - const uniqueTags = await getUniqueTagsCached(); - - const uniqueRecipes = SHOW_RECIPES - ? await getUniqueRecipesCached() - : []; - const hasAiTextGeneration = AI_TEXT_GENERATION_ENABLED; // Only generate image thumbnails when AI generation is enabled @@ -52,6 +56,7 @@ export default async function PhotoEditPage({ photo, uniqueTags, uniqueRecipes, + uniqueFilms, hasAiTextGeneration, imageThumbnailBase64, blurData, diff --git a/app/admin/recipes/[recipe]/edit/page.tsx b/app/admin/recipes/[recipe]/edit/page.tsx index c9db0fbd..9565cc55 100644 --- a/app/admin/recipes/[recipe]/edit/page.tsx +++ b/app/admin/recipes/[recipe]/edit/page.tsx @@ -45,7 +45,7 @@ export default async function RecipePageEdit({ accessory={recipeData && film && } diff --git a/app/admin/uploads/[uploadPath]/page.tsx b/app/admin/uploads/[uploadPath]/page.tsx index d55b9912..3bc007e9 100644 --- a/app/admin/uploads/[uploadPath]/page.tsx +++ b/app/admin/uploads/[uploadPath]/page.tsx @@ -1,7 +1,11 @@ import { PATH_ADMIN } from '@/app/paths'; import { extractImageDataFromBlobPath } from '@/photo/server'; import { redirect } from 'next/navigation'; -import { getUniqueRecipesCached, getUniqueTagsCached } from '@/photo/cache'; +import { + getUniqueFilmsCached, + getUniqueRecipesCached, + getUniqueTagsCached, +} from '@/photo/cache'; import UploadPageClient from '@/photo/UploadPageClient'; import { AI_TEXT_AUTO_GENERATED_FIELDS, @@ -10,7 +14,6 @@ import { } from '@/app/config'; import ErrorNote from '@/components/ErrorNote'; import { getRecipeTitleForData } from '@/photo/db/query'; -import { FilmSimulation } from '@/film'; export const maxDuration = 60; @@ -45,14 +48,16 @@ export default async function UploadPage({ params }: Params) { const [ uniqueTags, uniqueRecipes, + uniqueFilms, recipeTitle, ] = await Promise.all([ getUniqueTagsCached(), getUniqueRecipesCached(), + getUniqueFilmsCached(), formDataFromExif?.recipeData && formDataFromExif.film ? getRecipeTitleForData( formDataFromExif.recipeData, - formDataFromExif.film as FilmSimulation, + formDataFromExif.film, ) : undefined, ]); @@ -72,6 +77,7 @@ export default async function UploadPage({ params }: Params) { formDataFromExif, uniqueTags, uniqueRecipes, + uniqueFilms, hasAiTextGeneration, textFieldsToAutoGenerate, imageThumbnailBase64, diff --git a/app/film-demo/animate/page.tsx b/app/film-demo/animate/page.tsx index 0542cda2..7c8c8d5a 100644 --- a/app/film-demo/animate/page.tsx +++ b/app/film-demo/animate/page.tsx @@ -3,7 +3,7 @@ import AppGrid from '@/components/AppGrid'; import { clsx } from 'clsx/lite'; import { - FILM_SIMULATION_FORM_INPUT_OPTIONS, + FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS, } from '@/platforms/fujifilm/simulation'; import PhotoFilm from '@/film/PhotoFilm'; import { useEffect, useState } from 'react'; @@ -13,7 +13,7 @@ export default function FilmPage() { useEffect(() => { const interval = setInterval(() => { - setIndex((index + 1) % FILM_SIMULATION_FORM_INPUT_OPTIONS.length); + setIndex((index + 1) % FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS.length); }, 200); return () => clearInterval(interval); }); @@ -28,7 +28,7 @@ export default function FilmPage() { Film Simulation:
diff --git a/app/film-demo/page.tsx b/app/film-demo/page.tsx index 972e4143..4bb841d0 100644 --- a/app/film-demo/page.tsx +++ b/app/film-demo/page.tsx @@ -1,12 +1,12 @@ import { - FILM_SIMULATION_FORM_INPUT_OPTIONS, + FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS, } from '@/platforms/fujifilm/simulation'; import PhotoFilm from '@/film/PhotoFilm'; export default function FilmPage() { return (
- {FILM_SIMULATION_FORM_INPUT_OPTIONS.map(({ value }) => + {FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS.map(({ value }) =>
getPhotosNearIdCached( photoId, @@ -28,7 +27,7 @@ const getPhotosNearIdCachedCached = cache(( )); interface PhotoFilmProps { - params: Promise<{ photoId: string, film: FilmSimulation }> + params: Promise<{ photoId: string, film: string }> } export async function generateMetadata({ diff --git a/app/film/[film]/image/route.tsx b/app/film/[film]/image/route.tsx index 83a77b22..330d0355 100644 --- a/app/film/[film]/image/route.tsx +++ b/app/film/[film]/image/route.tsx @@ -5,7 +5,6 @@ import { } from '@/image-response'; import FilmImageResponse from '@/image-response/FilmImageResponse'; -import { FilmSimulation } from '@/film'; import { getIBMPlexMono } from '@/app/font'; import { ImageResponse } from 'next/og'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; @@ -21,7 +20,7 @@ export const generateStaticParams = staticallyGenerateCategoryIfConfigured( export async function GET( _: Request, - context: { params: Promise<{ film: FilmSimulation }> }, + context: { params: Promise<{ film: string }> }, ) { const { film } = await context.params; diff --git a/app/film/[film]/page.tsx b/app/film/[film]/page.tsx index 2fdff053..c521a4ea 100644 --- a/app/film/[film]/page.tsx +++ b/app/film/[film]/page.tsx @@ -1,6 +1,6 @@ import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo'; import { getUniqueFilms } from '@/photo/db/query'; -import { FilmSimulation, generateMetaForFilm } from '@/film'; +import { generateMetaForFilm } from '@/film'; import FilmOverview from '@/film/FilmOverview'; import { getPhotosFilmDataCached } from '@/film/data'; import { Metadata } from 'next/types'; @@ -20,7 +20,7 @@ export const generateStaticParams = staticallyGenerateCategoryIfConfigured( ); interface FilmProps { - params: Promise<{ film: FilmSimulation }> + params: Promise<{ film: string }> } export async function generateMetadata({ diff --git a/src/admin/AdminAppMenu.tsx b/src/admin/AdminAppMenu.tsx index 5bdc8521..2f53d0a1 100644 --- a/src/admin/AdminAppMenu.tsx +++ b/src/admin/AdminAppMenu.tsx @@ -44,7 +44,7 @@ export default function AdminAppMenu({ startUpload, setSelectedPhotoIds, refreshAdminData, - clearAuthStateAndRedirect, + clearAuthStateAndRedirectIfNecessary, } = useAppState(); const isSelecting = selectedPhotoIds !== undefined; @@ -148,7 +148,7 @@ export default function AdminAppMenu({ }, { label: 'Sign Out', icon: , - action: () => signOutAction().then(clearAuthStateAndRedirect), + action: () => signOutAction().then(clearAuthStateAndRedirectIfNecessary), }); return ( diff --git a/src/admin/AdminShowRecipeButton.tsx b/src/admin/AdminShowRecipeButton.tsx index bfe97689..80fd1ca8 100644 --- a/src/admin/AdminShowRecipeButton.tsx +++ b/src/admin/AdminShowRecipeButton.tsx @@ -1,20 +1,11 @@ 'use client'; import LoaderButton from '@/components/primitives/LoaderButton'; -import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; -import { FujifilmSimulation } from '@/platforms/fujifilm/simulation'; +import { RecipeProps } from '@/recipe'; import { useAppState } from '@/state/AppState'; import { TbChecklist } from 'react-icons/tb'; -export default function AdminShowRecipeButton({ - title, - recipe, - film, -}: { - title: string - recipe: FujifilmRecipe - film: FujifilmSimulation -}) { +export default function AdminShowRecipeButton(props: RecipeProps) { const { setRecipeModalProps } = useAppState(); return ( @@ -23,11 +14,7 @@ export default function AdminShowRecipeButton({ size={17} className="translate-y-[1px]" />} - onClick={() => setRecipeModalProps?.({ - title, - recipe, - film, - })} + onClick={() => setRecipeModalProps?.(props)} > Preview diff --git a/src/app/Footer.tsx b/src/app/Footer.tsx index eb982578..ba9930fc 100644 --- a/src/app/Footer.tsx +++ b/src/app/Footer.tsx @@ -17,7 +17,7 @@ import { useAppState } from '@/state/AppState'; export default function Footer() { const pathname = usePathname(); - const { userEmail, clearAuthStateAndRedirect } = useAppState(); + const { userEmail, clearAuthStateAndRedirectIfNecessary } = useAppState(); const showFooter = !isPathSignIn(pathname); @@ -49,7 +49,7 @@ export default function Footer() { {userEmail}
signOutAction() - .then(clearAuthStateAndRedirect)}> + .then(clearAuthStateAndRedirectIfNecessary)}> Sign out diff --git a/src/app/paths.ts b/src/app/paths.ts index 311f1da8..b275a832 100644 --- a/src/app/paths.ts +++ b/src/app/paths.ts @@ -2,7 +2,6 @@ import { Photo } from '@/photo'; import { PhotoSetCategory } from '@/category'; import { BASE_URL, GRID_HOMEPAGE_ENABLED } from './config'; import { Camera } from '@/camera'; -import { FilmSimulation } from '@/film'; import { parameterize } from '@/utility/string'; import { TAG_HIDDEN } from '@/tag'; import { Lens } from '@/lens'; @@ -151,7 +150,7 @@ export const pathForTag = (tag: string) => export const pathForCamera = ({ make, model }: Camera) => `${PREFIX_CAMERA}/${parameterize(make)}/${parameterize(model)}`; -export const pathForFilm = (film: FilmSimulation) => +export const pathForFilm = (film: string) => `${PREFIX_FILM}/${film}`; export const pathForLens = ({ make, model }: Lens) => @@ -177,7 +176,7 @@ export const absolutePathForCamera= (camera: Camera) => export const absolutePathForLens= (lens: Lens) => `${BASE_URL}${pathForLens(lens)}`; -export const absolutePathForFilm = (film: FilmSimulation) => +export const absolutePathForFilm = (film: string) => `${BASE_URL}${pathForFilm(film)}`; export const absolutePathForRecipe = (recipe: string) => @@ -198,7 +197,7 @@ export const absolutePathForCameraImage= (camera: Camera) => export const absolutePathForLensImage= (lens: Lens) => `${absolutePathForLens(lens)}/image`; -export const absolutePathForFilmImage = (film: FilmSimulation) => +export const absolutePathForFilmImage = (film: string) => `${absolutePathForFilm(film)}/image`; export const absolutePathForRecipeImage = (recipe: string) => @@ -308,7 +307,7 @@ export const getPathComponents = (pathname = ''): { const cameraModel = pathname.match( new RegExp(`^${PREFIX_CAMERA}/[^/]+/([^/]+)`))?.[1]; const film = pathname.match( - new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as FilmSimulation; + new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as string; const focalString = pathname.match( new RegExp(`^${PREFIX_FOCAL_LENGTH}/([0-9]+)mm`))?.[1]; diff --git a/src/auth/SignInForm.tsx b/src/auth/SignInForm.tsx index a59e7119..cc51dc86 100644 --- a/src/auth/SignInForm.tsx +++ b/src/auth/SignInForm.tsx @@ -61,15 +61,18 @@ export default function SignInForm({ password.length > 0; return ( - + {includeTitle &&

@@ -77,7 +80,7 @@ export default function SignInForm({

} -
+
{response === KEY_CREDENTIALS_SIGN_IN_ERROR && Invalid email/password diff --git a/src/category/index.ts b/src/category/index.ts index 698330cc..4c70238a 100644 --- a/src/category/index.ts +++ b/src/category/index.ts @@ -1,7 +1,7 @@ import { Photo } from '../photo'; import { Camera, Cameras } from '@/camera'; import { PhotoDateRange } from '../photo'; -import { FilmSimulation, Films } from '@/film'; +import { Films } from '@/film'; import { Lens, Lenses } from '@/lens'; import { Tags } from '@/tag'; import { FocalLengths } from '@/focal'; @@ -39,7 +39,7 @@ export interface PhotoSetCategory { lens?: Lens tag?: string recipe?: string - film?: FilmSimulation + film?: string focal?: number } diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx index aaddd55d..ef6d9320 100644 --- a/src/cmdk/CommandKClient.tsx +++ b/src/cmdk/CommandKClient.tsx @@ -59,7 +59,6 @@ import * as VisuallyHidden from '@radix-ui/react-visually-hidden'; import InsightsIndicatorDot from '@/admin/insights/InsightsIndicatorDot'; import { PhotoSetCategories } from '@/category'; import { formatCameraText } from '@/camera'; -import { labelForFilm } from '@/platforms/fujifilm/simulation'; import { formatFocalLength } from '@/focal'; import { formatRecipe } from '@/recipe'; import IconLens from '../components/icons/IconLens'; @@ -73,6 +72,7 @@ import IconFilm from '../components/icons/IconFilm'; import IconLock from '../components/icons/IconLock'; import useVisualViewportHeight from '@/utility/useVisualViewport'; import useMaskedScroll from '../components/useMaskedScroll'; +import { labelForFilm } from '@/film'; const DIALOG_TITLE = 'Global Command-K Menu'; const DIALOG_DESCRIPTION = 'For searching photos, views, and settings'; @@ -124,7 +124,7 @@ export default function CommandKClient({ const { isUserSignedIn, - clearAuthStateAndRedirect, + clearAuthStateAndRedirectIfNecessary, isCommandKOpen: isOpen, startUpload, photosCountHidden, @@ -521,7 +521,9 @@ export default function CommandKClient({ } adminSection.items.push({ label: 'Sign Out', - action: () => signOutAction().then(clearAuthStateAndRedirect), + action: () => signOutAction() + .then(clearAuthStateAndRedirectIfNecessary) + .then(() => setIsOpen?.(false)), }); } else { adminSection.items.push({ diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index d399f8a0..ee48dbf3 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -9,12 +9,14 @@ import TagInput from './TagInput'; import { FiChevronDown } from 'react-icons/fi'; import { parameterize } from '@/utility/string'; import Checkbox from './Checkbox'; +import ResponsiveText from './primitives/ResponsiveText'; export default function FieldSetWithStatus({ id: _id, label, icon, note, + noteShort, error, value, isModified, @@ -25,6 +27,7 @@ export default function FieldSetWithStatus({ tagOptions, tagOptionsLimit, tagOptionsLimitValidationMessage, + tagOptionsDefaultIcon, placeholder, loading, required, @@ -40,6 +43,7 @@ export default function FieldSetWithStatus({ label: string icon?: ReactNode note?: string + noteShort?: string error?: string value: string isModified?: boolean @@ -50,6 +54,7 @@ export default function FieldSetWithStatus({ tagOptions?: AnnotatedTag[] tagOptionsLimit?: number tagOptionsLimitValidationMessage?: string + tagOptionsDefaultIcon?: ReactNode placeholder?: string loading?: boolean required?: boolean @@ -128,9 +133,12 @@ export default function FieldSetWithStatus({ {label} {note && !error && - + ({note}) - } + } {isModified && !error && void showMenuOnDelete?: boolean className?: string @@ -34,6 +42,8 @@ export default function TagInput({ limit?: number limitValidationMessage?: string }) { + const behaveAsDropdown = limit === 1; + const containerRef = useRef(null); const inputRef = useRef(null); const optionsRef = useRef(null); @@ -51,8 +61,10 @@ export default function TagInput({ , [value]); const hasReachedLimit = useMemo(() => - limit !== undefined && selectedOptions.length >= limit - , [limit, selectedOptions]); + limit !== undefined && + selectedOptions.length >= limit && + !behaveAsDropdown + , [limit, behaveAsDropdown, selectedOptions]); const inputTextFormatted = parameterize(inputText); const isInputTextUnique = @@ -100,21 +112,29 @@ export default function TagInput({ .filter(option => !selectedOptions.includes(option)); if (optionsToAdd.length > 0) { - onChange?.([ - ...selectedOptions, - ...optionsToAdd, - ].join(',')); + if (behaveAsDropdown) { + // If behaving as dropdown, replace contents on add + onChange?.(optionsToAdd[0]); + } else { + onChange?.([ + ...selectedOptions, + ...optionsToAdd, + ].join(',')); + } } setSelectedOptionIndex(undefined); setInputText(''); - if (limit !== undefined && limit - 1 >= selectedOptions.length) { + if ( + behaveAsDropdown || + (limit !== undefined && limit - 1 >= selectedOptions.length) + ) { hideMenu(true); } else { inputRef.current?.focus(); } - }, [limit, selectedOptions, onChange, hideMenu]); + }, [limit, behaveAsDropdown, selectedOptions, onChange, hideMenu]); const removeOption = useCallback((option: string) => { onChange?.(selectedOptions.filter(o => @@ -232,6 +252,19 @@ export default function TagInput({ limit, ]); + const renderTag = useCallback((value: string) => { + const option = options.find(o => o.value === value); + const icon = option?.icon ?? defaultIcon; + return <> + + {option?.label ?? value} + + {icon && + {icon} + } + ; + }, [options, defaultIcon]); + return (
+ {/* Selected Options */} {selectedOptions .filter(Boolean) .map(option => @@ -280,6 +314,7 @@ export default function TagInput({ role="button" aria-label={`Remove tag "${option}"`} className={clsx( + 'inline-flex items-center gap-2 min-w-0', 'text-main', 'cursor-pointer select-none', 'whitespace-nowrap', @@ -290,7 +325,7 @@ export default function TagInput({ )} onClick={() => removeOption(option)} > - {option} + {renderTag(option)} )} setSelectedOptionIndex(undefined)} + onClick={() => { + if (!shouldShowMenu) { setShouldShowMenu(true); } + }} aria-autocomplete="list" aria-expanded={shouldShowMenu} aria-haspopup="true" @@ -332,6 +370,7 @@ export default function TagInput({ 'text-xl shadow-lg dark:shadow-xl', )} > + {/* Menu Options */} {optionsFiltered.map(({ value, annotation, @@ -346,7 +385,7 @@ export default function TagInput({ } tabIndex={0} className={clsx( - 'group flex items-center gap-1', + 'group flex items-center gap-2', 'px-1.5 py-1 rounded-xs', 'text-base select-none', hasReachedLimit ? 'cursor-not-allowed' : 'cursor-pointer', @@ -365,8 +404,8 @@ export default function TagInput({ }} onFocus={() => setSelectedOptionIndex(index)} > - - {value} + + {renderTag(value)} {annotation && {/* Short text */} - + {shortText ?? children} {/* Full text */} - + {children} diff --git a/src/film/FilmHeader.tsx b/src/film/FilmHeader.tsx index ee2b14f6..5e101df8 100644 --- a/src/film/FilmHeader.tsx +++ b/src/film/FilmHeader.tsx @@ -1,5 +1,5 @@ import { Photo, PhotoDateRange } from '@/photo'; -import { FilmSimulation, descriptionForFilmPhotos } from '.'; +import { descriptionForFilmPhotos } from '.'; import PhotoHeader from '@/photo/PhotoHeader'; import PhotoFilm from '@/film/PhotoFilm'; @@ -11,7 +11,7 @@ export default function FilmHeader({ count, dateRange, }: { - film: FilmSimulation + film: string photos: Photo[] selectedPhoto?: Photo indexNumber?: number diff --git a/src/film/FilmOGTile.tsx b/src/film/FilmOGTile.tsx index 19f7843a..399f7d08 100644 --- a/src/film/FilmOGTile.tsx +++ b/src/film/FilmOGTile.tsx @@ -4,11 +4,7 @@ import { pathForFilm, } from '@/app/paths'; import OGTile from '@/components/OGTile'; -import { - FilmSimulation, - descriptionForFilmPhotos, - titleForFilm, -} from '.'; +import { descriptionForFilmPhotos, titleForFilm } from '.'; export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed'; @@ -23,7 +19,7 @@ export default function FilmOGTile({ count, dateRange, }: { - film: FilmSimulation + film: string photos: Photo[] loadingState?: OGLoadingState onLoad?: () => void diff --git a/src/film/FilmOverview.tsx b/src/film/FilmOverview.tsx index 1ff9e59a..6f5f579d 100644 --- a/src/film/FilmOverview.tsx +++ b/src/film/FilmOverview.tsx @@ -1,6 +1,5 @@ import { Photo, PhotoDateRange } from '@/photo'; import FilmHeader from './FilmHeader'; -import { FilmSimulation } from '.'; import PhotoGridContainer from '@/photo/PhotoGridContainer'; export default function FilmOverview({ @@ -10,7 +9,7 @@ export default function FilmOverview({ dateRange, animateOnFirstLoadOnly, }: { - film: FilmSimulation, + film: string, photos: Photo[], count: number, dateRange?: PhotoDateRange, diff --git a/src/film/FilmShareModal.tsx b/src/film/FilmShareModal.tsx index 1c5b3a3c..9fdff02c 100644 --- a/src/film/FilmShareModal.tsx +++ b/src/film/FilmShareModal.tsx @@ -2,7 +2,7 @@ import { absolutePathForFilm } from '@/app/paths'; import { PhotoSetAttributes } from '../category'; import ShareModal from '@/share/ShareModal'; import FilmOGTile from './FilmOGTile'; -import { FilmSimulation, shareTextForFilm } from '.'; +import { shareTextForFilm } from '.'; export default function FilmShareModal({ film, @@ -10,7 +10,7 @@ export default function FilmShareModal({ count, dateRange, }: { - film: FilmSimulation + film: string } & PhotoSetAttributes) { return ( ); } diff --git a/src/film/PhotoFilmIcon.tsx b/src/film/PhotoFilmIcon.tsx index c7db5dbf..b412f30d 100644 --- a/src/film/PhotoFilmIcon.tsx +++ b/src/film/PhotoFilmIcon.tsx @@ -1,22 +1,150 @@ /* eslint-disable max-len */ -import { labelForFilm } from '@/platforms/fujifilm/simulation'; import { CSSProperties } from 'react'; -import { FilmSimulation } from '.'; +import { labelForFilm } from '.'; const INTRINSIC_WIDTH = 28; +const INTRINSIC_WIDTH_FALLBACK = 14; const INTRINSIC_HEIGHT = 16; +const FALLBACK_ICON = + +; + export default function PhotoFilmIcon({ film, height = INTRINSIC_HEIGHT, className, style, }: { - film?: FilmSimulation + film?: string height?: number className?: string style?: CSSProperties }) { + const simulationIcon = (() => { + // Self-calling switch function and non-fragment groups + // necessary for ImageResponse compatibility + switch (film) { + case 'monochrome': return + + + ; + case 'monochrome-ye': return + + + + ; + case 'monochrome-r': return + + + + ; + case 'monochrome-g': return + + + + ; + case 'sepia': return + + + + ; + case 'acros': return + + + ; + case 'acros-ye': return + + + + ; + case 'acros-r': return + + + + ; + case 'acros-g': return + + + + ; + case 'provia': return + + + + ; + case 'portrait': return + + + ; + case 'portrait-saturation': return + + + + ; + case 'astia': return + + + ; + case 'portrait-sharpness': return + + + + ; + case 'portrait-ex': return + + + + ; + case 'velvia': return + + + ; + case 'pro-neg-std': return + + + + ; + case 'pro-neg-hi': return + + + + ; + case 'classic-chrome': return + + + + ; + case 'eterna': return + + + ; + case 'classic-neg': return + + + + ; + case 'eterna-bleach-bypass': return + + + + ; + case 'nostalgic-neg': return + + + + ; + case 'reala': return + + + ; + } + })(); + + const width = simulationIcon + ? INTRINSIC_WIDTH + : INTRINSIC_WIDTH_FALLBACK; + return ( - {(() => { - // Self-calling switch function and non-fragment groups - // necessary for ImageResponse compatibility - switch (film) { - case 'monochrome': return - - - ; - case 'monochrome-ye': return - - - - ; - case 'monochrome-r': return - - - - ; - case 'monochrome-g': return - - - - ; - case 'sepia': return - - - - ; - case 'acros': return - - - ; - case 'acros-ye': return - - - - ; - case 'acros-r': return - - - - ; - case 'acros-g': return - - - - ; - case 'provia': return - - - - ; - case 'portrait': return - - - ; - case 'portrait-saturation': return - - - - ; - case 'astia': return - - - ; - case 'portrait-sharpness': return - - - - ; - case 'portrait-ex': return - - - - ; - case 'velvia': return - - - ; - case 'pro-neg-std': return - - - - ; - case 'pro-neg-hi': return - - - - ; - case 'classic-chrome': return - - - - ; - case 'eterna': return - - - ; - case 'classic-neg': return - - - - ; - case 'eterna-bleach-bypass': return - - - - ; - case 'nostalgic-neg': return - - - - ; - case 'reala': return - - - ; - default: return - - ; - } - })()} + {simulationIcon ?? FALLBACK_ICON} ); } diff --git a/src/film/data.ts b/src/film/data.ts index 227f5f9b..4b5697b3 100644 --- a/src/film/data.ts +++ b/src/film/data.ts @@ -2,13 +2,12 @@ import { getPhotosCached, getPhotosMetaCached, } from '@/photo/cache'; -import { FilmSimulation } from '.'; export const getPhotosFilmDataCached = ({ film, limit, }: { - film: FilmSimulation, + film: string, limit?: number, }) => Promise.all([ diff --git a/src/film/index.ts b/src/film/index.ts deleted file mode 100644 index b360e70f..00000000 --- a/src/film/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - Photo, - PhotoDateRange, - descriptionForPhotoSet, - photoQuantityText, -} from '@/photo'; -import { - absolutePathForFilm, - absolutePathForFilmImage, -} from '@/app/paths'; -import { - FujifilmSimulation, - labelForFilm, -} from '@/platforms/fujifilm/simulation'; - -export type FilmSimulation = FujifilmSimulation; - -export type FilmWithCount = { - film: FilmSimulation - count: number -} - -export type Films = FilmWithCount[] - -export const sortFilms = ( - films: Films, -) => films.sort(sortFilmsWithCount); - -export const sortFilmsWithCount = ( - a: FilmWithCount, - b: FilmWithCount, -) => { - const aLabel = labelForFilm(a.film).large; - const bLabel = labelForFilm(b.film).large; - return aLabel.localeCompare(bLabel); -}; - -export const titleForFilm = ( - film: FilmSimulation, - photos: Photo[], - explicitCount?: number, -) => [ - labelForFilm(film).large, - photoQuantityText(explicitCount ?? photos.length), -].join(' '); - -export const shareTextForFilm = ( - film: FilmSimulation, -) => - `Photos shot on Fujifilm ${labelForFilm(film).large}`; - -export const descriptionForFilmPhotos = ( - photos: Photo[], - dateBased?: boolean, - explicitCount?: number, - explicitDateRange?: PhotoDateRange, -) => - descriptionForPhotoSet( - photos, - undefined, - dateBased, - explicitCount, - explicitDateRange, - ); - -export const generateMetaForFilm = ( - film: FilmSimulation, - photos: Photo[], - explicitCount?: number, - explicitDateRange?: PhotoDateRange, -) => ({ - url: absolutePathForFilm(film), - title: titleForFilm(film, photos, explicitCount), - description: descriptionForFilmPhotos( - photos, - true, - explicitCount, - explicitDateRange, - ), - images: absolutePathForFilmImage(film), -}); - -export const photoHasFilmData = (photo: Photo) => - Boolean(photo.film); diff --git a/src/film/index.tsx b/src/film/index.tsx new file mode 100644 index 00000000..341acfb1 --- /dev/null +++ b/src/film/index.tsx @@ -0,0 +1,132 @@ +import { + Photo, + PhotoDateRange, + descriptionForPhotoSet, + photoQuantityText, +} from '@/photo'; +import { + absolutePathForFilm, + absolutePathForFilmImage, +} from '@/app/paths'; +import { + FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS, + labelForFujifilmSimulation, +} from '@/platforms/fujifilm/simulation'; +import { deparameterize, formatCount } from '@/utility/string'; +import { formatCountDescriptive } from '@/utility/string'; +import { AnnotatedTag } from '@/photo/form'; +import PhotoFilmIcon from './PhotoFilmIcon'; + +export type FilmWithCount = { + film: string + count: number +} + +export type Films = FilmWithCount[] + +export const labelForFilm = (film: string) => { + // Use Fujifilm simulation text when recognized + const simulationLabel = labelForFujifilmSimulation(film as any); + if (simulationLabel) { + return simulationLabel; + } else { + const filmFormatted = deparameterize(film); + return { + small: filmFormatted, + medium: filmFormatted, + large: filmFormatted, + }; + } +}; + +export const sortFilms = ( + films: Films, +) => films.sort(sortFilmsWithCount); + +export const sortFilmsWithCount = ( + a: FilmWithCount, + b: FilmWithCount, +) => { + const aLabel = labelForFilm(a.film).large; + const bLabel = labelForFilm(b.film).large; + return aLabel.localeCompare(bLabel); +}; + +export const titleForFilm = ( + film: string, + photos: Photo[], + explicitCount?: number, +) => [ + labelForFilm(film).large, + photoQuantityText(explicitCount ?? photos.length), +].join(' '); + +export const shareTextForFilm = ( + film: string, +) => + `Photos shot on ${labelForFilm(film).large}`; + +export const descriptionForFilmPhotos = ( + photos: Photo[], + dateBased?: boolean, + explicitCount?: number, + explicitDateRange?: PhotoDateRange, +) => + descriptionForPhotoSet( + photos, + undefined, + dateBased, + explicitCount, + explicitDateRange, + ); + +export const generateMetaForFilm = ( + film: string, + photos: Photo[], + explicitCount?: number, + explicitDateRange?: PhotoDateRange, +) => ({ + url: absolutePathForFilm(film), + title: titleForFilm(film, photos, explicitCount), + description: descriptionForFilmPhotos( + photos, + true, + explicitCount, + explicitDateRange, + ), + images: absolutePathForFilmImage(film), +}); + +export const photoHasFilmData = (photo: Photo) => + Boolean(photo.film); + +export const convertFilmsForForm = ( + _films: Films = [], + includeAllFujifilmSimulations?: boolean, +): AnnotatedTag[] => { + const films: AnnotatedTag[] = includeAllFujifilmSimulations + ? FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS + .map(({ value }) => ({ value })) + : []; + + _films.forEach(({ film, count }) => { + const index = films.findIndex(f => f.value === film); + const meta = { + annotation: formatCount(count), + annotationAria: formatCountDescriptive(count), + }; + if (index === -1) { + films.push({ value: film, ...meta }); + } else { + films[index] = { ...films[index], ...meta }; + } + }); + + return films + .map(film => ({ + ...film, + label: labelForFilm(film.value).large, + icon: , + })) + .sort((a, b) => a.value.localeCompare(b.value)); +}; diff --git a/src/image-response/FilmImageResponse.tsx b/src/image-response/FilmImageResponse.tsx index 68bb0cf5..2eb76d7c 100644 --- a/src/image-response/FilmImageResponse.tsx +++ b/src/image-response/FilmImageResponse.tsx @@ -2,13 +2,10 @@ import { Photo } from '../photo'; import ImageCaption from './components/ImageCaption'; import ImagePhotoGrid from './components/ImagePhotoGrid'; import ImageContainer from './components/ImageContainer'; -import { - labelForFilm, -} from '@/platforms/fujifilm/simulation'; import PhotoFilmIcon from '@/film/PhotoFilmIcon'; -import { FilmSimulation } from '@/film'; import { NextImageSize } from '@/platforms/next-image'; +import { labelForFilm } from '@/film'; export default function FilmImageResponse({ film, @@ -17,7 +14,7 @@ export default function FilmImageResponse({ height, fontFamily, }: { - film: FilmSimulation, + film: string, photos: Photo[] width: NextImageSize height: number diff --git a/src/image-response/RecipeImageResponse.tsx b/src/image-response/RecipeImageResponse.tsx index f3caddf1..ba311a67 100644 --- a/src/image-response/RecipeImageResponse.tsx +++ b/src/image-response/RecipeImageResponse.tsx @@ -6,7 +6,9 @@ import type { NextImageSize } from '@/platforms/next-image'; import { formatTag } from '@/tag'; import { generateRecipeText, getPhotoWithRecipeFromPhotos } from '@/recipe'; import PhotoFilmIcon from '@/film/PhotoFilmIcon'; -import { isStringFilmSimulation } from '@/platforms/fujifilm/simulation'; +import { + isStringFujifilmSimulationLabel, +} from '@/platforms/fujifilm/simulation'; import IconRecipe from '@/components/icons/IconRecipe'; const MAX_RECIPE_LINES = 8; @@ -32,7 +34,7 @@ export default function RecipeImageResponse({ let recipeLines = recipeData && film ? generateRecipeText({ - recipe: recipeData, + data: recipeData, film, }, true) : []; @@ -109,7 +111,7 @@ export default function RecipeImageResponse({ flexGrow: 1, }}> {text} - {isStringFilmSimulation(text) && film && + {isStringFujifilmSimulationLabel(text) && film &&
uniqueTags: Tags uniqueRecipes: Recipes + uniqueFilms: Films hasAiTextGeneration?: boolean textFieldsToAutoGenerate?: AiAutoGeneratedField[], imageThumbnailBase64?: string @@ -65,6 +68,7 @@ export default function UploadPageClient({ initialPhotoForm={initialPhotoForm} uniqueTags={uniqueTags} uniqueRecipes={uniqueRecipes} + uniqueFilms={uniqueFilms} aiContent={hasAiTextGeneration ? aiContent : undefined} shouldStripGpsData={shouldStripGpsData} onTitleChange={setUpdatedTitle} diff --git a/src/photo/actions.ts b/src/photo/actions.ts index b3843331..cfeda1c2 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -60,7 +60,6 @@ import { convertUploadToPhoto } from './storage'; import { UrlAddStatus } from '@/admin/AdminUploadsClient'; import { convertStringToArray } from '@/utility/string'; import { after } from 'next/server'; -import { FilmSimulation } from '@/film'; // Private actions @@ -315,7 +314,7 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) => export const getPhotosNeedingRecipeTitleCountAction = async ( recipeData: string, - film: FilmSimulation, + film: string, photoIdToExclude?: string, ) => runAuthenticatedAdminServerAction(async () => diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index 9af7bf0c..cf5ff252 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -13,7 +13,7 @@ import { } from '@/photo'; import { Cameras, createCameraKey } from '@/camera'; import { Tags } from '@/tag'; -import { FilmSimulation, Films } from '@/film'; +import { Films } from '@/film'; import { ADMIN_SQL_DEBUG_ENABLED } from '@/app/config'; import { GetPhotosOptions, @@ -65,7 +65,8 @@ const createPhotosTable = () => ) `; -// Wrapper for most queries for JIT table creation/migration running +// Safe wrapper for most queries with JIT table creation/migration +// Catch up to 3 migrations in older installations const safelyQueryPhotos = async ( callback: () => Promise, debugMessage: string, @@ -78,6 +79,7 @@ const safelyQueryPhotos = async ( try { result = await callback(); } catch (e: any) { + // Catch 1st migration let migration = migrationForError(e); if (migration) { console.log(`Running Migration ${migration.label} ...`); @@ -85,15 +87,26 @@ const safelyQueryPhotos = async ( try { result = await callback(); } catch (e: any) { - // Catch potential second migration, - // which otherwise would not be caught + // Catch 2nd migration migration = migrationForError(e); if (migration) { console.log(`Running Migration ${migration.label} ...`); await migration.run(); result = await callback(); } else { - throw e; + try { + result = await callback(); + } catch (e: any) { + // Catch 3rd migration + migration = migrationForError(e); + if (migration) { + console.log(`Running Migration ${migration.label} ...`); + await migration.run(); + result = await callback(); + } else { + throw e; + } + } } } } else if (/relation "photos" does not exist/i.test(e.message)) { @@ -367,7 +380,7 @@ export const getUniqueRecipes = async () => export const getRecipeTitleForData = async ( data: string | object, - film: FilmSimulation, + film: string, ) => // Includes legacy check on pre-stringified JSON safelyQueryPhotos(() => sql` @@ -382,7 +395,7 @@ export const getRecipeTitleForData = async ( export const getPhotosNeedingRecipeTitleCount = async ( data: string, - film: FilmSimulation, + film: string, photoIdToExclude?: string, ) => safelyQueryPhotos(() => sql` @@ -398,7 +411,7 @@ export const getPhotosNeedingRecipeTitleCount = async ( export const updateAllMatchingRecipeTitles = ( title: string, data: string, - film: FilmSimulation, + film: string, ) => safelyQueryPhotos(() => sql` UPDATE photos @@ -417,7 +430,7 @@ export const getUniqueFilms = async () => ORDER BY film ASC `.then(({ rows }): Films => rows .map(({ film, count }) => ({ - film: film as FilmSimulation, + film, count: parseInt(count, 10), }))) , 'getUniqueFilms'); diff --git a/src/photo/form/ApplyRecipesGloballyCheckbox.tsx b/src/photo/form/ApplyRecipesGloballyCheckbox.tsx index 6bd5f7ce..38754a77 100644 --- a/src/photo/form/ApplyRecipesGloballyCheckbox.tsx +++ b/src/photo/form/ApplyRecipesGloballyCheckbox.tsx @@ -1,7 +1,6 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import { ComponentProps, useEffect, useState } from 'react'; import { getPhotosNeedingRecipeTitleCountAction } from '../actions'; -import { FilmSimulation } from '@/film'; export default function ApplyRecipeTitleGloballyCheckbox({ photoId, @@ -16,7 +15,7 @@ export default function ApplyRecipeTitleGloballyCheckbox({ recipeTitle?: string hasRecipeTitleChanged?: boolean recipeData?: string - film?: FilmSimulation + film?: string onMatchResults: (didFindMatchingPhotos: boolean) => void }) { const [matchingPhotosCount, setMatchingPhotosCount] = useState(); diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 8ea391a0..75ce5d8c 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -41,9 +41,11 @@ import ErrorNote from '@/components/ErrorNote'; import { convertRecipesForForm, Recipes } from '@/recipe'; import deepEqual from 'fast-deep-equal/es6/react'; import ApplyRecipeTitleGloballyCheckbox from './ApplyRecipesGloballyCheckbox'; -import { FilmSimulation } from '@/film'; +import { convertFilmsForForm, Films } from '@/film'; import IconFavs from '@/components/icons/IconFavs'; import IconHidden from '@/components/icons/IconHidden'; +import { isMakeFujifilm } from '@/platforms/fujifilm'; +import PhotoFilmIcon from '@/film/PhotoFilmIcon'; const THUMBNAIL_SIZE = 300; @@ -54,6 +56,7 @@ export default function PhotoForm({ updatedBlurData, uniqueTags, uniqueRecipes, + uniqueFilms, aiContent, shouldStripGpsData, onTitleChange, @@ -66,6 +69,7 @@ export default function PhotoForm({ updatedBlurData?: string uniqueTags?: Tags uniqueRecipes?: Recipes + uniqueFilms?: Films aiContent?: AiContent shouldStripGpsData?: boolean onTitleChange?: (updatedTitle: string) => void @@ -326,12 +330,14 @@ export default function PhotoForm({ {FORM_METADATA_ENTRIES( convertTagsForForm(uniqueTags), convertRecipesForForm(uniqueRecipes), + convertFilmsForForm(uniqueFilms, isMakeFujifilm(formData.make)), aiContent !== undefined, shouldStripGpsData, ) .map(([key, { label, note, + noteShort, required, selectOptions, selectOptionsDefaultLabel, @@ -359,6 +365,7 @@ export default function PhotoForm({ : '' ), note, + noteShort, error: formErrors[key], value: staticValue ?? formData[key] ?? '', isModified: ( @@ -406,6 +413,16 @@ export default function PhotoForm({ }; switch (key) { + case 'film': + return + + } + {...fieldProps} + />; case 'applyRecipeTitleGlobally': return ; diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 19cada9b..3b4f1633 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -16,14 +16,12 @@ import { import { roundToNumber } from '@/utility/number'; import { convertStringToArray, parameterize } from '@/utility/string'; import { generateNanoid } from '@/utility/nanoid'; -import { - FILM_SIMULATION_FORM_INPUT_OPTIONS, -} from '@/platforms/fujifilm/simulation'; -import { FilmSimulation } from '@/film'; import { GEO_PRIVACY_ENABLED } from '@/app/config'; import { TAG_FAVS, getValidationMessageForTags } from '@/tag'; import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; +import { ReactNode } from 'react'; +import { FujifilmSimulation } from '@/platforms/fujifilm/simulation'; type VirtualFields = 'favorite' | @@ -44,6 +42,8 @@ export type FieldSetType = export type AnnotatedTag = { value: string, + label?: string, + icon?: ReactNode annotation?: string, annotationAria?: string, }; @@ -51,6 +51,7 @@ export type AnnotatedTag = { export type FormMeta = { label: string note?: string + noteShort?: string required?: boolean excludeFromInsert?: boolean readOnly?: boolean @@ -82,6 +83,7 @@ const STRING_MAX_LENGTH_LONG = 1000; const FORM_METADATA = ( tagOptions?: AnnotatedTag[], recipeOptions?: AnnotatedTag[], + filmOptions?: AnnotatedTag[], aiTextGeneration?: boolean, shouldStripGpsData?: boolean, ): Record => ({ @@ -121,16 +123,16 @@ const FORM_METADATA = ( model: { label: 'camera model' }, film: { label: 'film', - selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS, - selectOptionsDefaultLabel: 'Unknown', - shouldHide: ({ make }) => make !== MAKE_FUJIFILM, + note: 'Intended for Fujifilm cameras and analog scans', + noteShort: 'Fujifilm cameras / analog scans', + tagOptions: filmOptions, + tagOptionsLimit: 1, shouldNotOverwriteWithNullDataOnSync: true, }, recipeTitle: { label: 'recipe title', tagOptions: recipeOptions, tagOptionsLimit: 1, - tagOptionsLimitValidationMessage: 'Photos can only have one recipe', spellCheck: false, capitalize: false, shouldHide: ({ make }) => make !== MAKE_FUJIFILM, @@ -274,7 +276,7 @@ export const convertPhotoToFormData = (photo: Photo): PhotoFormData => { export const convertExifToFormData = ( data: ExifData, - film?: FilmSimulation, + film?: FujifilmSimulation, recipeData?: FujifilmRecipe, ): Omit< Record, @@ -345,7 +347,7 @@ export const convertFormDataToPhotoDbInsert = ( return { ...(photoForm as PhotoFormData & { - film?: FilmSimulation + film?: FujifilmSimulation recipeData?: FujifilmRecipe }), ...!photoForm.id && { id: generateNanoid() }, diff --git a/src/photo/index.ts b/src/photo/index.ts index 1b0d6a33..3e25eb9d 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -1,6 +1,6 @@ import { formatFocalLength } from '@/focal'; import { getNextImageUrlForRequest } from '@/platforms/next-image'; -import { FilmSimulation, photoHasFilmData } from '@/film'; +import { photoHasFilmData } from '@/film'; import { HIGH_DENSITY_GRID, IS_PREVIEW, @@ -22,6 +22,7 @@ import camelcaseKeys from 'camelcase-keys'; import { isBefore } from 'date-fns'; import type { Metadata } from 'next'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; +import { FujifilmSimulation } from '@/platforms/fujifilm/simulation'; // INFINITE SCROLL: FEED export const INFINITE_SCROLL_FEED_INITIAL = @@ -65,7 +66,7 @@ export interface PhotoExif { exposureCompensation?: number latitude?: number longitude?: number - film?: FilmSimulation + film?: FujifilmSimulation recipeData?: string takenAt?: string takenAtNaive?: string diff --git a/src/photo/server.ts b/src/photo/server.ts index 22ab8376..b09a50fa 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -7,11 +7,11 @@ import { convertFormDataToPhotoDbInsert, } from '@/photo/form'; import { + FujifilmSimulation, getFujifilmSimulationFromMakerNote, } from '@/platforms/fujifilm/simulation'; import { ExifData, ExifParserFactory } from 'ts-exif-parser'; import { PhotoFormData } from './form'; -import { FilmSimulation } from '@/film'; import sharp, { Sharp } from 'sharp'; import { GEO_PRIVACY_ENABLED, @@ -58,7 +58,7 @@ export const extractImageDataFromBlobPath = async ( const extension = getExtensionFromStorageUrl(url); let exifData: ExifData | undefined; - let film: FilmSimulation | undefined; + let film: FujifilmSimulation | undefined; let recipe: FujifilmRecipe | undefined; let blurData: string | undefined; let imageResizedBase64: string | undefined; diff --git a/src/platforms/fujifilm/index.ts b/src/platforms/fujifilm/index.ts index 1fe6ad9c..858a7ba2 100644 --- a/src/platforms/fujifilm/index.ts +++ b/src/platforms/fujifilm/index.ts @@ -20,7 +20,10 @@ const BYTES_PER_TAG = 12; const BYTES_PER_TAG_VALUE = 4; export const isExifForFujifilm = (data: ExifData) => - data.tags?.Make === MAKE_FUJIFILM; + data.tags?.Make?.toLocaleUpperCase() === MAKE_FUJIFILM; + +export const isMakeFujifilm = (make?: string) => + make?.toLocaleUpperCase() === MAKE_FUJIFILM; export const parseFujifilmMakerNote = ( bytes: Buffer, diff --git a/src/platforms/fujifilm/simulation.ts b/src/platforms/fujifilm/simulation.ts index df387d6c..1778bbee 100644 --- a/src/platforms/fujifilm/simulation.ts +++ b/src/platforms/fujifilm/simulation.ts @@ -80,7 +80,7 @@ interface FujifilmSimulationLabel { large: string } -const FILM_SIMULATION_LABELS: Record< +const FUJIFILM_SIMULATION_LABELS: Record< FujifilmSimulation, FujifilmSimulationLabel > = { @@ -206,27 +206,31 @@ const FILM_SIMULATION_LABELS: Record< }, }; -export const FILM_SIMULATION_FORM_INPUT_OPTIONS = Object - .entries(FILM_SIMULATION_LABELS) +export const FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS = Object + .entries(FUJIFILM_SIMULATION_LABELS) .map(([value, label]) => ( { value, label: label.large } as { value: FujifilmSimulation, label: string } )) .sort((a, b) => a.label.localeCompare(b.label)); -const ALL_POSSIBLE_FILM_SIMULATION_LABELS = Object - .values(FILM_SIMULATION_LABELS) +const ALL_POSSIBLE_FUJIFILM_SIMULATION_LABELS = Object + .values(FUJIFILM_SIMULATION_LABELS) .flatMap(({ small, medium, large }) => [ small.toLocaleLowerCase(), medium.toLocaleLowerCase(), large.toLocaleLowerCase(), ]); -export const isStringFilmSimulation = (film: string) => - ALL_POSSIBLE_FILM_SIMULATION_LABELS.includes(film.toLocaleLowerCase()); +export const isStringFujifilmSimulation = (film?: string) => + film !== undefined && + Object.keys(FUJIFILM_SIMULATION_LABELS).includes(film); -export const labelForFilm = (film: FujifilmSimulation) => - FILM_SIMULATION_LABELS[film]; +export const isStringFujifilmSimulationLabel = (film: string) => + ALL_POSSIBLE_FUJIFILM_SIMULATION_LABELS.includes(film.toLocaleLowerCase()); + +export const labelForFujifilmSimulation = (film: FujifilmSimulation) => + FUJIFILM_SIMULATION_LABELS[film]; export const getFujifilmSimulationFromMakerNote = ( bytes: Buffer, diff --git a/src/recipe/PhotoRecipeOverlay.tsx b/src/recipe/PhotoRecipeOverlay.tsx index 1a140874..d5107273 100644 --- a/src/recipe/PhotoRecipeOverlay.tsx +++ b/src/recipe/PhotoRecipeOverlay.tsx @@ -15,16 +15,16 @@ import { generateRecipeText, RecipeProps, } from '.'; -import { labelForFilm } from '@/platforms/fujifilm/simulation'; import { TbChecklist } from 'react-icons/tb'; import CopyButton from '@/components/CopyButton'; import { pathForRecipe } from '@/app/paths'; import LinkWithStatus from '@/components/LinkWithStatus'; +import { labelForFilm } from '@/film'; export default function PhotoRecipeOverlay({ ref, title, - recipe, + data, film, onClose, }: RecipeProps & { @@ -43,9 +43,9 @@ export default function PhotoRecipeOverlay({ colorChromeFXBlue, bwAdjustment, bwMagentaGreen, - } = recipe; + } = data; - const whiteBalanceTypeFormatted = formatWhiteBalance(recipe); + const whiteBalanceTypeFormatted = formatWhiteBalance(data); const renderDataSquare = ( value: ReactNode, @@ -122,7 +122,7 @@ export default function PhotoRecipeOverlay({ label={`${title ? `${formatRecipe(title).toLocaleUpperCase()} recipe` : 'Recipe'}`} - text={generateRecipeText({ title, recipe, film }).join('\n')} + text={generateRecipeText({ title, data, film }).join('\n')} iconSize={17} className={clsx( 'translate-y-[0.5px]', @@ -171,7 +171,7 @@ export default function PhotoRecipeOverlay({ 'col-span-8', )} {renderDataSquare( - formatNoiseReduction(recipe), + formatNoiseReduction(data), 'ISO NR', 'col-span-4', )} @@ -195,7 +195,7 @@ export default function PhotoRecipeOverlay({ )} {/* ROW */} {renderDataSquare( - formatGrain(recipe), + formatGrain(data), 'grain', 'col-span-6', )} diff --git a/src/recipe/RecipeHeader.tsx b/src/recipe/RecipeHeader.tsx index 0613238a..ebbb80e3 100644 --- a/src/recipe/RecipeHeader.tsx +++ b/src/recipe/RecipeHeader.tsx @@ -35,7 +35,7 @@ export default function RecipeHeader({ photo?.film ) ? setRecipeModalProps?.({ title: photo.recipeTitle, - recipe: photo.recipeData, + data: photo.recipeData, film: photo.film, iso: photo.isoFormatted, exposure: photo.exposureTimeFormatted, diff --git a/src/recipe/index.ts b/src/recipe/index.ts index 0d6f102d..d3735c92 100644 --- a/src/recipe/index.ts +++ b/src/recipe/index.ts @@ -7,8 +7,7 @@ import { formatCountDescriptive, } from '@/utility/string'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; -import { FilmSimulation } from '@/film'; -import { labelForFilm } from '@/platforms/fujifilm/simulation'; +import { labelForFilm } from '@/film'; export type RecipeWithCount = { recipe: string @@ -19,8 +18,8 @@ export type Recipes = RecipeWithCount[] export interface RecipeProps { title?: string - recipe: FujifilmRecipe - film: FilmSimulation + data: FujifilmRecipe + film: string iso?: string exposure?: string } @@ -56,7 +55,7 @@ export const descriptionForRecipePhotos = ( export const generateRecipeText = ({ title, - recipe, + data, film, }: RecipeProps, abbreviate?: boolean, @@ -64,53 +63,51 @@ abbreviate?: boolean, const lines = [ `${labelForFilm(film).small.toLocaleUpperCase()}`, // eslint-disable-next-line max-len - `${formatWhiteBalance(recipe).toLocaleUpperCase()} ${formatWhiteBalanceColor(recipe)}`, + `${formatWhiteBalance(data).toLocaleUpperCase()} ${formatWhiteBalanceColor(data)}`, ]; if (abbreviate) { // eslint-disable-next-line max-len - lines.push(`DR${recipe.dynamicRange.development} NR${formatNoiseReduction(recipe)}`); + lines.push(`DR${data.dynamicRange.development} NR${formatNoiseReduction(data)}`); } else { lines.push( - `DYNAMIC RANGE ${recipe.dynamicRange.development}`, - `NOISE REDUCTION ${formatNoiseReduction(recipe)}`, + `DYNAMIC RANGE ${data.dynamicRange.development}`, + `NOISE REDUCTION ${formatNoiseReduction(data)}`, ); } - if (recipe.highlight || recipe.shadow) { + if (data.highlight || data.shadow) { lines.push(abbreviate - ? `HIGH${addSign(recipe.highlight)} SHAD${addSign(recipe.shadow)}` - // eslint-disable-next-line max-len - : `HIGHLIGHT ${addSign(recipe.highlight)} SHADOW ${addSign(recipe.shadow)}`, + ? `HIGH${addSign(data.highlight)} SHAD${addSign(data.shadow)}` + : `HIGHLIGHT ${addSign(data.highlight)} SHADOW ${addSign(data.shadow)}`, ); } lines.push(abbreviate // eslint-disable-next-line max-len - ? `COL${addSign(recipe.color)} SHARP${addSign(recipe.sharpness)} CLAR${addSign(recipe.clarity)}` + ? `COL${addSign(data.color)} SHARP${addSign(data.sharpness)} CLAR${addSign(data.clarity)}` // eslint-disable-next-line max-len - : `COLOR ${addSign(recipe.color)} SHARPEN ${addSign(recipe.sharpness)} CLARITY ${addSign(recipe.clarity)}`, + : `COLOR ${addSign(data.color)} SHARPEN ${addSign(data.sharpness)} CLARITY ${addSign(data.clarity)}`, ); - if (recipe.colorChromeEffect) { + if (data.colorChromeEffect) { lines.push(abbreviate - ? `CHROME ${recipe.colorChromeEffect.toLocaleUpperCase()}` - : `COLOR CHROME ${recipe.colorChromeEffect.toLocaleUpperCase()}`, + ? `CHROME ${data.colorChromeEffect.toLocaleUpperCase()}` + : `COLOR CHROME ${data.colorChromeEffect.toLocaleUpperCase()}`, ); } - if (recipe.colorChromeFXBlue) { + if (data.colorChromeFXBlue) { lines.push(abbreviate - ? `FX BLUE ${recipe.colorChromeFXBlue.toLocaleUpperCase()}` - : `CHROME FX BLUE ${recipe.colorChromeFXBlue.toLocaleUpperCase()}`, + ? `FX BLUE ${data.colorChromeFXBlue.toLocaleUpperCase()}` + : `CHROME FX BLUE ${data.colorChromeFXBlue.toLocaleUpperCase()}`, ); } - if (recipe.grainEffect.roughness !== 'off') { - lines.push(`GRAIN ${formatGrain(recipe, abbreviate)}`); + if (data.grainEffect.roughness !== 'off') { + lines.push(`GRAIN ${formatGrain(data, abbreviate)}`); } - if (recipe.bwAdjustment || recipe.bwMagentaGreen) { + if (data.bwAdjustment || data.bwMagentaGreen) { lines.push(abbreviate + ? `BW ADJ${addSign(data.bwAdjustment)} M/G${addSign(data.bwMagentaGreen)}` // eslint-disable-next-line max-len - ? `BW ADJ${addSign(recipe.bwAdjustment)} M/G${addSign(recipe.bwMagentaGreen)}` - // eslint-disable-next-line max-len - : `BW ADJUSTMENT ${addSign(recipe.bwAdjustment)} MAGENTA/GREEN ${addSign(recipe.bwMagentaGreen)}`, + : `BW ADJUSTMENT ${addSign(data.bwAdjustment)} MAGENTA/GREEN ${addSign(data.bwMagentaGreen)}`, ); } diff --git a/src/state/AppState.ts b/src/state/AppState.ts index 0ce7039e..590a6ebc 100644 --- a/src/state/AppState.ts +++ b/src/state/AppState.ts @@ -39,7 +39,7 @@ export type AppStateContextType = { setUserEmail?: Dispatch> isUserSignedIn?: boolean isUserSignedInEager?: boolean - clearAuthStateAndRedirect?: () => void + clearAuthStateAndRedirectIfNecessary?: () => void // ADMIN isCheckingAuth?: boolean adminUpdateTimes?: Date[] diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index 60fca69e..7a18fd76 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -21,7 +21,7 @@ import { hasAuthEmailCookie, } from '@/auth/client'; import { useRouter, usePathname } from 'next/navigation'; -import { isPathAdmin, PATH_SIGN_IN } from '@/app/paths'; +import { isPathAdmin, PATH_ROOT } from '@/app/paths'; import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload'; import { RecipeProps } from '@/recipe'; import { getCountsForCategoriesCachedAction } from '@/category/actions'; @@ -105,6 +105,8 @@ export default function AppStateProvider({ setIsUserSignedInEager(hasAuthEmailCookie()); if (!authError) { setUserEmail(auth?.user?.email ?? undefined); + } else { + setIsUserSignedInEager(false); } }, [auth, authError]); const isUserSignedIn = Boolean(userEmail); @@ -133,11 +135,11 @@ export default function AppStateProvider({ setAdminUpdateTimes(updates => [...updates, new Date()]) , []); - const clearAuthStateAndRedirect = useCallback(() => { + const clearAuthStateAndRedirectIfNecessary = useCallback(() => { setUserEmail(undefined); setIsUserSignedInEager(false); clearAuthEmailCookie(); - if (isPathAdmin(pathname)) { router.push(PATH_SIGN_IN); } + if (isPathAdmin(pathname)) { router.push(PATH_ROOT); } }, [router, pathname]); // Returns false when upload is cancelled @@ -187,7 +189,7 @@ export default function AppStateProvider({ setUserEmail, isUserSignedIn, isUserSignedInEager, - clearAuthStateAndRedirect, + clearAuthStateAndRedirectIfNecessary, // ADMIN adminUpdateTimes, registerAdminUpdate,