From 087a5e223c33cd927dc9a1d08ae9880bbb6f4395 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 1 Apr 2025 20:43:11 -0500 Subject: [PATCH] Finalize film dropdown icon --- src/components/FieldSetWithStatus.tsx | 3 + src/components/TagInput.tsx | 33 ++- src/film/PhotoFilmIcon.tsx | 258 +++++++++++---------- src/film/{index.ts => index.tsx} | 29 +-- src/image-response/RecipeImageResponse.tsx | 4 +- src/photo/form/PhotoForm.tsx | 11 + src/photo/form/index.ts | 4 +- src/platforms/fujifilm/simulation.ts | 5 +- 8 files changed, 195 insertions(+), 152 deletions(-) rename src/film/{index.ts => index.tsx} (79%) diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index 5b023b66..ee48dbf3 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -27,6 +27,7 @@ export default function FieldSetWithStatus({ tagOptions, tagOptionsLimit, tagOptionsLimitValidationMessage, + tagOptionsDefaultIcon, placeholder, loading, required, @@ -53,6 +54,7 @@ export default function FieldSetWithStatus({ tagOptions?: AnnotatedTag[] tagOptionsLimit?: number tagOptionsLimitValidationMessage?: string + tagOptionsDefaultIcon?: ReactNode placeholder?: string loading?: boolean required?: boolean @@ -199,6 +201,7 @@ export default function FieldSetWithStatus({ name={id} value={value} options={tagOptions} + defaultIcon={tagOptionsDefaultIcon} onChange={onChange} showMenuOnDelete={tagOptionsLimit === 1} className={clsx(Boolean(error) && 'error')} diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index ead6c722..7c5cf4b1 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -1,7 +1,14 @@ import { AnnotatedTag } from '@/photo/form'; import { convertStringToArray, parameterize } from '@/utility/string'; import { clsx } from 'clsx/lite'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; const KEY_KEYDOWN = 'keydown'; const CREATE_LABEL = 'Create'; @@ -13,6 +20,7 @@ export default function TagInput({ name, value = '', options = [], + defaultIcon, onChange, showMenuOnDelete, className, @@ -25,6 +33,7 @@ export default function TagInput({ name: string value?: string options?: AnnotatedTag[] + defaultIcon?: ReactNode onChange?: (value: string) => void showMenuOnDelete?: boolean className?: string @@ -243,14 +252,19 @@ export default function TagInput({ limit, ]); - const formatValue = useCallback((value: string) => { + const renderOptionContent = useCallback((value: string) => { const option = options.find(option => option.value === value); + const icon = option?.icon ?? defaultIcon; return <> - {option?.icon} - {option?.label ?? value} + + {option?.label ?? value} + + {icon && + {icon} + } ; }, - [options]); + [options, defaultIcon]); return (
removeOption(option)} > - {formatValue(value)} + {renderOptionContent(value)} )} setSelectedOptionIndex(index)} > - - {formatValue(value)} + + {renderOptionContent(value)} {annotation && + +; + export default function PhotoFilmIcon({ film, height = INTRINSIC_HEIGHT, @@ -17,6 +22,130 @@ export default function PhotoFilmIcon({ className?: string style?: CSSProperties }) { + const simulationIcon = useMemo(() => { + // 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 + + + ; + } + }, [film]); + + 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/index.ts b/src/film/index.tsx similarity index 79% rename from src/film/index.ts rename to src/film/index.tsx index a04a6b10..3433e99d 100644 --- a/src/film/index.ts +++ b/src/film/index.tsx @@ -16,6 +16,7 @@ import { import { formatCount } from '@/utility/string'; import { formatCountDescriptive } from '@/utility/string'; import { AnnotatedTag } from '@/photo/form'; +import PhotoFilmIcon from './PhotoFilmIcon'; export type FilmSimulation = FujifilmSimulation; @@ -92,24 +93,26 @@ export const convertFilmsForForm = ( includeAllFujifilmSimulations?: boolean, ): AnnotatedTag[] => { const films = includeAllFujifilmSimulations - ? FILM_SIMULATION_FORM_INPUT_OPTIONS.map(({ value }) => ({ - value, - label: labelForFilm(value).large, - } as AnnotatedTag)) + ? FILM_SIMULATION_FORM_INPUT_OPTIONS + .map(({ value }) => ({ value } as AnnotatedTag)) : []; _films.forEach(({ film, count }) => { const index = films.findIndex(f => f.value === film); - const annotation = formatCount(count); - const annotationAria = formatCountDescriptive(count); - if (index !== -1) { - films[index] = { - ...films[index], - annotation, - annotationAria, - }; + const fujifilmSimulation = FILM_SIMULATION_FORM_INPUT_OPTIONS + .find(f => f.value === film); + const meta = { + annotation: formatCount(count), + annotationAria: formatCountDescriptive(count), + ...fujifilmSimulation && { + label: labelForFilm(film).large, + icon: , + }, + }; + if (index === -1) { + films.push({ value: film, ...meta }); } else { - films.push({ value: film, annotation, annotationAria }); + films[index] = { ...films[index], ...meta }; } }); diff --git a/src/image-response/RecipeImageResponse.tsx b/src/image-response/RecipeImageResponse.tsx index f3caddf1..c666839e 100644 --- a/src/image-response/RecipeImageResponse.tsx +++ b/src/image-response/RecipeImageResponse.tsx @@ -6,7 +6,7 @@ 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 { isStringFilmSimulationLabel } from '@/platforms/fujifilm/simulation'; import IconRecipe from '@/components/icons/IconRecipe'; const MAX_RECIPE_LINES = 8; @@ -109,7 +109,7 @@ export default function RecipeImageResponse({ flexGrow: 1, }}> {text} - {isStringFilmSimulation(text) && film && + {isStringFilmSimulationLabel(text) && film &&
+ + } + {...fieldProps} + />; case 'applyRecipeTitleGlobally': return +export const isStringFilmSimulation = (film?: string) => + film && Object.keys(FILM_SIMULATION_LABELS).includes(film); + +export const isStringFilmSimulationLabel = (film: string) => ALL_POSSIBLE_FILM_SIMULATION_LABELS.includes(film.toLocaleLowerCase()); export const labelForFilm = (film: FujifilmSimulation) =>