Skip to content
63 changes: 49 additions & 14 deletions frontend/src/components/MetadataSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -26,15 +26,15 @@ const formatAperture = (val: string): string => {
if (match) {
return `f/${match[2]}`;
}
return val;
return trimmed;
};

const formatShutterSpeed = (val: string): string => {
const trimmed = val.trim();
if (/^\d+(\/\d+)?$/.test(trimmed) || /^\d+\.\d+$/.test(trimmed)) {
return `${trimmed}s`;
}
return val;
return trimmed;
};

const formatISO = (val: string): string => {
Expand All @@ -43,23 +43,23 @@ const formatISO = (val: string): string => {
if (match) {
return `ISO${match[2]}`;
}
return val;
return trimmed;
};

const formatTemp = (val: string): string => {
const trimmed = val.trim();
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return `${trimmed}℃`;
}
return val;
return trimmed;
};

const formatTime = (val: string): string => {
const trimmed = val.trim();
if (/^\d+(\.\d+)?$/.test(trimmed)) {
return `${trimmed}min`;
}
return val;
return trimmed;
};

export interface MetadataSettingsPanelProps {
Expand Down Expand Up @@ -95,23 +95,58 @@ 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
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 availableDilutions = Array.from(new Set(matchingRecipesForDev.map(r => r.dilution))).filter(Boolean).sort();
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(customSort);

// 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(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(customSort);

return { availableFilms, availableDevelopers, availableDilutions, availableTemps, availableTimes };
}, [recipes, exif.film, exif.developer, exif.dilution]);
}, [recipes, exif.film, exif.developer, exif.dilution, exif.temperature]);

return (
<div className="sidebar-section metadata-settings-section">
Expand Down
109 changes: 72 additions & 37 deletions frontend/src/components/ToggleInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChangeEvent, FocusEvent } from 'react';
import { ChangeEvent, FocusEvent, MouseEvent, useState } from 'react';

const EyeIcon = ({ visible }: { visible: boolean }) => (
visible ? (
Expand All @@ -20,40 +20,75 @@ export interface ToggleInputProps {
onBlur?: (e: FocusEvent<HTMLInputElement>) => void;
}

export const ToggleInput = ({ label, id, value, onChange, visible, onToggleVisibility, hideInput, suggestions, onBlur }: ToggleInputProps) => (
<div className="input-group">
<div className="toggle-input-header">
<label htmlFor={id} className="toggle-input-label">{label}</label>
<button
type="button"
onClick={onToggleVisibility}
className={`toggle-visibility-btn ${visible ? 'visible' : ''}`}
title={visible ? `Hide ${label} from frame` : `Show ${label} on frame`}
aria-label={visible ? `Hide ${label} from frame` : `Show ${label} on frame`}
aria-pressed={visible}
>
<EyeIcon visible={visible} />
</button>
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<string | null>(null);
const hasSuggestions = suggestions && suggestions.length > 0;

const handleMouseDown = (e: MouseEvent<HTMLInputElement>) => {
if (hasSuggestions && document.activeElement !== e.currentTarget) {
setTempValue("");
}
};

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setTempValue(null);
onChange(e);
};

const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
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) {
onBlur(e);
}
setTempValue(null);
};

const displayValue = tempValue !== null ? tempValue : value;

return (
<div className="input-group">
<div className="toggle-input-header">
<label htmlFor={id} className="toggle-input-label">{label}</label>
<button
type="button"
onClick={onToggleVisibility}
className={`toggle-visibility-btn ${visible ? 'visible' : ''}`}
title={visible ? `Hide ${label} from frame` : `Show ${label} on frame`}
aria-label={visible ? `Hide ${label} from frame` : `Show ${label} on frame`}
aria-pressed={visible}
>
<EyeIcon visible={visible} />
</button>
</div>
{!hideInput && (
<>
<input
id={id}
type="text"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
onMouseDown={handleMouseDown}
className={`toggle-input-field ${!visible ? 'hidden' : ''}`}
list={hasSuggestions ? `${id}-datalist` : undefined}
/>
{hasSuggestions && (
<datalist id={`${id}-datalist`}>
{suggestions.map((s) => (
<option key={s} value={s} />
))}
</datalist>
)}
</>
)}
</div>
{!hideInput && (
<>
<input
id={id}
type="text"
value={value}
onChange={onChange}
onBlur={onBlur}
className={`toggle-input-field ${!visible ? 'hidden' : ''}`}
list={suggestions && suggestions.length > 0 ? `${id}-datalist` : undefined}
/>
{suggestions && suggestions.length > 0 && (
<datalist id={`${id}-datalist`}>
{suggestions.map((s) => (
<option key={s} value={s} />
))}
</datalist>
)}
</>
)}
</div>
);
);
};
Loading