From 603cac67b32294f4baa0e53ba2bbd4e34fff1960 Mon Sep 17 00:00:00 2001 From: amemya Date: Wed, 24 Jun 2026 19:37:48 +0900 Subject: [PATCH 1/8] fix: improve recipe metadata filtering and unit handling --- .../src/components/MetadataSettingsPanel.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/MetadataSettingsPanel.tsx b/frontend/src/components/MetadataSettingsPanel.tsx index 463a92d..ad2b826 100644 --- a/frontend/src/components/MetadataSettingsPanel.tsx +++ b/frontend/src/components/MetadataSettingsPanel.tsx @@ -97,21 +97,28 @@ export const MetadataSettingsPanel = ({ const { availableFilms, availableDevelopers, availableDilutions, availableTemps, availableTimes } = useMemo(() => { const availableFilms = Array.from(new Set(recipes.map(r => r.film))).filter(Boolean).sort(); - // Filter recipes matching the selected film - const matchingRecipes = recipes.filter(r => !exif.film || r.film === exif.film); + // Filter recipes matching the selected film (Fallback to all if invalid) + const isFilmValid = !!exif.film && recipes.some(r => r.film === exif.film); + const matchingRecipes = isFilmValid ? recipes.filter(r => r.film === exif.film) : recipes; const availableDevelopers = Array.from(new Set(matchingRecipes.map(r => r.developer))).filter(Boolean).sort(); // Filter matching developer - const matchingRecipesForDev = matchingRecipes.filter(r => !exif.developer || r.developer === exif.developer); + const isDevValid = !!exif.developer && matchingRecipes.some(r => r.developer === exif.developer); + const matchingRecipesForDev = isDevValid ? matchingRecipes.filter(r => r.developer === exif.developer) : matchingRecipes; const availableDilutions = Array.from(new Set(matchingRecipesForDev.map(r => r.dilution))).filter(Boolean).sort(); // Filter matching dilution - const matchingRecipesForDilution = matchingRecipesForDev.filter(r => !exif.dilution || r.dilution === exif.dilution); - const availableTemps = Array.from(new Set(matchingRecipesForDilution.map(r => r.temp))).filter(Boolean).sort(); - const availableTimes = Array.from(new Set(matchingRecipesForDilution.map(r => r.time))).filter(Boolean).sort(); + const isDilutionValid = !!exif.dilution && matchingRecipesForDev.some(r => r.dilution === exif.dilution); + const matchingRecipesForDilution = isDilutionValid ? matchingRecipesForDev.filter(r => r.dilution === exif.dilution) : matchingRecipesForDev; + const availableTemps = Array.from(new Set(matchingRecipesForDilution.map(r => formatTemp(r.temp)))).filter(Boolean).sort(); + + // Filter matching temperature to narrow down times + const isTempValid = !!exif.temperature && matchingRecipesForDilution.some(r => formatTemp(r.temp) === exif.temperature); + const matchingRecipesForTemp = isTempValid ? matchingRecipesForDilution.filter(r => formatTemp(r.temp) === exif.temperature) : matchingRecipesForDilution; + const availableTimes = Array.from(new Set(matchingRecipesForTemp.map(r => formatTime(r.time)))).filter(Boolean).sort(); return { availableFilms, availableDevelopers, availableDilutions, availableTemps, availableTimes }; - }, [recipes, exif.film, exif.developer, exif.dilution]); + }, [recipes, exif.film, exif.developer, exif.dilution, exif.temperature]); return (
From 38b1675b16fd8df57419f88b1dcaaee0c3ff1600 Mon Sep 17 00:00:00 2001 From: amemya Date: Wed, 24 Jun 2026 19:50:49 +0900 Subject: [PATCH 2/8] feat: show all datalist suggestions on click via mousedown/blur hack --- frontend/src/components/ToggleInput.tsx | 100 +++++++++++++++--------- 1 file changed, 63 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/ToggleInput.tsx b/frontend/src/components/ToggleInput.tsx index 0f6807d..6bed6c9 100644 --- a/frontend/src/components/ToggleInput.tsx +++ b/frontend/src/components/ToggleInput.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, FocusEvent } from 'react'; +import { ChangeEvent, FocusEvent, useState } from 'react'; const EyeIcon = ({ visible }: { visible: boolean }) => ( visible ? ( @@ -20,40 +20,66 @@ export interface ToggleInputProps { onBlur?: (e: FocusEvent) => void; } -export const ToggleInput = ({ label, id, value, onChange, visible, onToggleVisibility, hideInput, suggestions, onBlur }: ToggleInputProps) => ( -
-
- - +export const ToggleInput = ({ label, id, value, onChange, visible, onToggleVisibility, hideInput, suggestions, onBlur }: ToggleInputProps) => { + // Temporary override to show all datalist options on click. + // null = no override (use parent's value), "" = cleared for datalist display. + const [tempValue, setTempValue] = useState(null); + const hasSuggestions = suggestions && suggestions.length > 0; + + const handleMouseDown = () => { + if (hasSuggestions) { + setTempValue(""); + } + }; + + const handleChange = (e: ChangeEvent) => { + setTempValue(null); + onChange(e); + }; + + const handleBlur = (e: FocusEvent) => { + setTempValue(null); + onBlur?.(e); + }; + + const displayValue = tempValue !== null ? tempValue : value; + + return ( +
+
+ + +
+ {!hideInput && ( + <> + + {hasSuggestions && ( + + {suggestions.map((s) => ( + + )} + + )}
- {!hideInput && ( - <> - 0 ? `${id}-datalist` : undefined} - /> - {suggestions && suggestions.length > 0 && ( - - {suggestions.map((s) => ( - - )} - - )} -
-); + ); +}; From 9ae308793c7833858f8e15e104066620974bd894 Mon Sep 17 00:00:00 2001 From: amemya Date: Wed, 24 Jun 2026 20:03:53 +0900 Subject: [PATCH 3/8] fix: onBlur fake event value and temperature format comparison --- frontend/src/components/MetadataSettingsPanel.tsx | 4 ++-- frontend/src/components/ToggleInput.tsx | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/MetadataSettingsPanel.tsx b/frontend/src/components/MetadataSettingsPanel.tsx index ad2b826..9eef193 100644 --- a/frontend/src/components/MetadataSettingsPanel.tsx +++ b/frontend/src/components/MetadataSettingsPanel.tsx @@ -113,8 +113,8 @@ export const MetadataSettingsPanel = ({ const availableTemps = Array.from(new Set(matchingRecipesForDilution.map(r => formatTemp(r.temp)))).filter(Boolean).sort(); // Filter matching temperature to narrow down times - const isTempValid = !!exif.temperature && matchingRecipesForDilution.some(r => formatTemp(r.temp) === exif.temperature); - const matchingRecipesForTemp = isTempValid ? matchingRecipesForDilution.filter(r => formatTemp(r.temp) === exif.temperature) : matchingRecipesForDilution; + const isTempValid = !!exif.temperature && matchingRecipesForDilution.some(r => formatTemp(r.temp) === formatTemp(exif.temperature)); + const matchingRecipesForTemp = isTempValid ? matchingRecipesForDilution.filter(r => formatTemp(r.temp) === formatTemp(exif.temperature)) : matchingRecipesForDilution; const availableTimes = Array.from(new Set(matchingRecipesForTemp.map(r => formatTime(r.time)))).filter(Boolean).sort(); return { availableFilms, availableDevelopers, availableDilutions, availableTemps, availableTimes }; diff --git a/frontend/src/components/ToggleInput.tsx b/frontend/src/components/ToggleInput.tsx index 6bed6c9..31a2e67 100644 --- a/frontend/src/components/ToggleInput.tsx +++ b/frontend/src/components/ToggleInput.tsx @@ -38,8 +38,20 @@ export const ToggleInput = ({ label, id, value, onChange, visible, onToggleVisib }; const handleBlur = (e: FocusEvent) => { + if (onBlur) { + if (tempValue !== null) { + // Proxy the event to provide the correct value instead of the temporary empty string + const syntheticEvent = Object.create(e); + const syntheticTarget = Object.create(e.target); + Object.defineProperty(syntheticTarget, 'value', { get: () => value }); + Object.defineProperty(syntheticEvent, 'target', { get: () => syntheticTarget }); + Object.defineProperty(syntheticEvent, 'currentTarget', { get: () => syntheticTarget }); + onBlur(syntheticEvent); + } else { + onBlur(e); + } + } setTempValue(null); - onBlur?.(e); }; const displayValue = tempValue !== null ? tempValue : value; From 2a2d80bfb1ab82313a8c503102c6000dcb2c5ca2 Mon Sep 17 00:00:00 2001 From: amemya Date: Wed, 24 Jun 2026 20:23:26 +0900 Subject: [PATCH 4/8] fix: numerical sort for temp/time and improve ToggleInput datalist hack --- .../src/components/MetadataSettingsPanel.tsx | 12 ++++++++++-- frontend/src/components/ToggleInput.tsx | 16 +++------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/MetadataSettingsPanel.tsx b/frontend/src/components/MetadataSettingsPanel.tsx index 9eef193..12e316c 100644 --- a/frontend/src/components/MetadataSettingsPanel.tsx +++ b/frontend/src/components/MetadataSettingsPanel.tsx @@ -110,12 +110,20 @@ export const MetadataSettingsPanel = ({ // Filter matching dilution const isDilutionValid = !!exif.dilution && matchingRecipesForDev.some(r => r.dilution === exif.dilution); const matchingRecipesForDilution = isDilutionValid ? matchingRecipesForDev.filter(r => r.dilution === exif.dilution) : matchingRecipesForDev; - const availableTemps = Array.from(new Set(matchingRecipesForDilution.map(r => formatTemp(r.temp)))).filter(Boolean).sort(); + + const numericSort = (a: string, b: string) => { + const numA = parseFloat(a); + const numB = parseFloat(b); + if (!isNaN(numA) && !isNaN(numB)) return numA - numB; + return a.localeCompare(b); + }; + + const availableTemps = Array.from(new Set(matchingRecipesForDilution.map(r => formatTemp(r.temp)))).filter(Boolean).sort(numericSort); // Filter matching temperature to narrow down times const isTempValid = !!exif.temperature && matchingRecipesForDilution.some(r => formatTemp(r.temp) === formatTemp(exif.temperature)); const matchingRecipesForTemp = isTempValid ? matchingRecipesForDilution.filter(r => formatTemp(r.temp) === formatTemp(exif.temperature)) : matchingRecipesForDilution; - const availableTimes = Array.from(new Set(matchingRecipesForTemp.map(r => formatTime(r.time)))).filter(Boolean).sort(); + const availableTimes = Array.from(new Set(matchingRecipesForTemp.map(r => formatTime(r.time)))).filter(Boolean).sort(numericSort); return { availableFilms, availableDevelopers, availableDilutions, availableTemps, availableTimes }; }, [recipes, exif.film, exif.developer, exif.dilution, exif.temperature]); diff --git a/frontend/src/components/ToggleInput.tsx b/frontend/src/components/ToggleInput.tsx index 31a2e67..42592df 100644 --- a/frontend/src/components/ToggleInput.tsx +++ b/frontend/src/components/ToggleInput.tsx @@ -26,8 +26,8 @@ export const ToggleInput = ({ label, id, value, onChange, visible, onToggleVisib const [tempValue, setTempValue] = useState(null); const hasSuggestions = suggestions && suggestions.length > 0; - const handleMouseDown = () => { - if (hasSuggestions) { + const handleMouseDown = (e: React.MouseEvent) => { + if (hasSuggestions && document.activeElement !== e.currentTarget) { setTempValue(""); } }; @@ -39,17 +39,7 @@ export const ToggleInput = ({ label, id, value, onChange, visible, onToggleVisib const handleBlur = (e: FocusEvent) => { if (onBlur) { - if (tempValue !== null) { - // Proxy the event to provide the correct value instead of the temporary empty string - const syntheticEvent = Object.create(e); - const syntheticTarget = Object.create(e.target); - Object.defineProperty(syntheticTarget, 'value', { get: () => value }); - Object.defineProperty(syntheticEvent, 'target', { get: () => syntheticTarget }); - Object.defineProperty(syntheticEvent, 'currentTarget', { get: () => syntheticTarget }); - onBlur(syntheticEvent); - } else { - onBlur(e); - } + onBlur(e); } setTempValue(null); }; From bc4b244198528051509f97cf272bb609edda7533 Mon Sep 17 00:00:00 2001 From: amemya Date: Wed, 24 Jun 2026 20:36:27 +0900 Subject: [PATCH 5/8] fix: ToggleInput onBlur bug and metadata formatters whitespace absorption --- frontend/src/components/MetadataSettingsPanel.tsx | 12 ++++++------ frontend/src/components/ToggleInput.tsx | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/MetadataSettingsPanel.tsx b/frontend/src/components/MetadataSettingsPanel.tsx index 12e316c..6503b9e 100644 --- a/frontend/src/components/MetadataSettingsPanel.tsx +++ b/frontend/src/components/MetadataSettingsPanel.tsx @@ -17,7 +17,7 @@ const formatFocalLength = (val: string): string => { if (/^\d+(\.\d+)?(-\d+(\.\d+)?)?$/.test(trimmed)) { return `${trimmed}mm`; } - return val; + return trimmed; }; const formatAperture = (val: string): string => { @@ -26,7 +26,7 @@ const formatAperture = (val: string): string => { if (match) { return `f/${match[2]}`; } - return val; + return trimmed; }; const formatShutterSpeed = (val: string): string => { @@ -34,7 +34,7 @@ const formatShutterSpeed = (val: string): string => { if (/^\d+(\/\d+)?$/.test(trimmed) || /^\d+\.\d+$/.test(trimmed)) { return `${trimmed}s`; } - return val; + return trimmed; }; const formatISO = (val: string): string => { @@ -43,7 +43,7 @@ const formatISO = (val: string): string => { if (match) { return `ISO${match[2]}`; } - return val; + return trimmed; }; const formatTemp = (val: string): string => { @@ -51,7 +51,7 @@ const formatTemp = (val: string): string => { if (/^-?\d+(\.\d+)?$/.test(trimmed)) { return `${trimmed}℃`; } - return val; + return trimmed; }; const formatTime = (val: string): string => { @@ -59,7 +59,7 @@ const formatTime = (val: string): string => { if (/^\d+(\.\d+)?$/.test(trimmed)) { return `${trimmed}min`; } - return val; + return trimmed; }; export interface MetadataSettingsPanelProps { diff --git a/frontend/src/components/ToggleInput.tsx b/frontend/src/components/ToggleInput.tsx index 42592df..2d9c7a3 100644 --- a/frontend/src/components/ToggleInput.tsx +++ b/frontend/src/components/ToggleInput.tsx @@ -38,6 +38,9 @@ export const ToggleInput = ({ label, id, value, onChange, visible, onToggleVisib }; const handleBlur = (e: FocusEvent) => { + if (tempValue !== null) { + e.currentTarget.value = value; + } if (onBlur) { onBlur(e); } From 41d5b8d42bb74815eb738fb8fd1673a553fce1cf Mon Sep 17 00:00:00 2001 From: amemya Date: Thu, 25 Jun 2026 19:54:59 +0900 Subject: [PATCH 6/8] chore: standardize MouseEvent import --- frontend/src/components/ToggleInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ToggleInput.tsx b/frontend/src/components/ToggleInput.tsx index 2d9c7a3..c13d8dc 100644 --- a/frontend/src/components/ToggleInput.tsx +++ b/frontend/src/components/ToggleInput.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, FocusEvent, useState } from 'react'; +import { ChangeEvent, FocusEvent, MouseEvent, useState } from 'react'; const EyeIcon = ({ visible }: { visible: boolean }) => ( visible ? ( @@ -26,7 +26,7 @@ export const ToggleInput = ({ label, id, value, onChange, visible, onToggleVisib const [tempValue, setTempValue] = useState(null); const hasSuggestions = suggestions && suggestions.length > 0; - const handleMouseDown = (e: React.MouseEvent) => { + const handleMouseDown = (e: MouseEvent) => { if (hasSuggestions && document.activeElement !== e.currentTarget) { setTempValue(""); } From f6673f441afd51aa65c7f75eeca98376039a6c68 Mon Sep 17 00:00:00 2001 From: amemya Date: Thu, 25 Jun 2026 19:59:18 +0900 Subject: [PATCH 7/8] docs: clarify reason for mutating e.currentTarget.value in onBlur --- frontend/src/components/ToggleInput.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/components/ToggleInput.tsx b/frontend/src/components/ToggleInput.tsx index c13d8dc..5b55cc5 100644 --- a/frontend/src/components/ToggleInput.tsx +++ b/frontend/src/components/ToggleInput.tsx @@ -39,6 +39,10 @@ export const ToggleInput = ({ label, id, value, onChange, visible, onToggleVisib const handleBlur = (e: FocusEvent) => { if (tempValue !== null) { + // Synchronously restore the real value to the DOM node before calling onBlur. + // This ensures that if the parent's onBlur reads e.currentTarget.value, + // they get the actual value instead of the temporary cleared string. + // The display will be restored automatically by setTempValue(null) below. e.currentTarget.value = value; } if (onBlur) { From 35592a2e35d010d1ecd6464b6d6a879acb988bd1 Mon Sep 17 00:00:00 2001 From: amemya Date: Thu, 25 Jun 2026 20:08:23 +0900 Subject: [PATCH 8/8] feat: implement smart custom sort for dilutions, temps, and times --- .../src/components/MetadataSettingsPanel.tsx | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/MetadataSettingsPanel.tsx b/frontend/src/components/MetadataSettingsPanel.tsx index 6503b9e..3ab018d 100644 --- a/frontend/src/components/MetadataSettingsPanel.tsx +++ b/frontend/src/components/MetadataSettingsPanel.tsx @@ -95,6 +95,33 @@ export const MetadataSettingsPanel = ({ // Compute suggestion lists based on current inputs const { availableFilms, availableDevelopers, availableDilutions, availableTemps, availableTimes } = useMemo(() => { + const parseValue = (val: string): number => { + if (val.toLowerCase() === 'stock') return 0; + // Handle dilutions like "1+4" or "1:4" -> returns 4 + const dilutionMatch = val.match(/^\d+[+:](\d+(\.\d+)?)$/); + if (dilutionMatch) return parseFloat(dilutionMatch[1]); + // Handle time like "6:45" -> 6.75 + const timeMatch = val.match(/^(\d+):(\d{2})$/); + if (timeMatch) return parseInt(timeMatch[1], 10) + parseInt(timeMatch[2], 10) / 60; + return parseFloat(val); + }; + + const customSort = (a: string, b: string) => { + const numA = parseValue(a); + const numB = parseValue(b); + const aIsNum = !isNaN(numA); + const bIsNum = !isNaN(numB); + + if (aIsNum && bIsNum) { + if (numA !== numB) return numA - numB; + } else if (aIsNum && !bIsNum) { + return -1; // Numbers come before strings + } else if (!aIsNum && bIsNum) { + return 1; // Strings come after numbers + } + return a.localeCompare(b); + }; + const availableFilms = Array.from(new Set(recipes.map(r => r.film))).filter(Boolean).sort(); // Filter recipes matching the selected film (Fallback to all if invalid) @@ -105,25 +132,18 @@ export const MetadataSettingsPanel = ({ // Filter matching developer const isDevValid = !!exif.developer && matchingRecipes.some(r => r.developer === exif.developer); const matchingRecipesForDev = isDevValid ? matchingRecipes.filter(r => r.developer === exif.developer) : matchingRecipes; - const availableDilutions = Array.from(new Set(matchingRecipesForDev.map(r => r.dilution))).filter(Boolean).sort(); + const availableDilutions = Array.from(new Set(matchingRecipesForDev.map(r => r.dilution))).filter(Boolean).sort(customSort); // Filter matching dilution const isDilutionValid = !!exif.dilution && matchingRecipesForDev.some(r => r.dilution === exif.dilution); const matchingRecipesForDilution = isDilutionValid ? matchingRecipesForDev.filter(r => r.dilution === exif.dilution) : matchingRecipesForDev; - const numericSort = (a: string, b: string) => { - const numA = parseFloat(a); - const numB = parseFloat(b); - if (!isNaN(numA) && !isNaN(numB)) return numA - numB; - return a.localeCompare(b); - }; - - const availableTemps = Array.from(new Set(matchingRecipesForDilution.map(r => formatTemp(r.temp)))).filter(Boolean).sort(numericSort); + const availableTemps = Array.from(new Set(matchingRecipesForDilution.map(r => formatTemp(r.temp)))).filter(Boolean).sort(customSort); // Filter matching temperature to narrow down times const isTempValid = !!exif.temperature && matchingRecipesForDilution.some(r => formatTemp(r.temp) === formatTemp(exif.temperature)); const matchingRecipesForTemp = isTempValid ? matchingRecipesForDilution.filter(r => formatTemp(r.temp) === formatTemp(exif.temperature)) : matchingRecipesForDilution; - const availableTimes = Array.from(new Set(matchingRecipesForTemp.map(r => formatTime(r.time)))).filter(Boolean).sort(numericSort); + const availableTimes = Array.from(new Set(matchingRecipesForTemp.map(r => formatTime(r.time)))).filter(Boolean).sort(customSort); return { availableFilms, availableDevelopers, availableDilutions, availableTemps, availableTimes }; }, [recipes, exif.film, exif.developer, exif.dilution, exif.temperature]);