From 7842e57a59f19e134eb7793cb7c707efacbb370f Mon Sep 17 00:00:00 2001 From: Jay Gaikar Date: Fri, 22 May 2026 18:17:08 +0530 Subject: [PATCH] feat: add visual aspect ratio thumbnails to presets --- src/components/PresetSelector.tsx | 251 +++++++++++---------- src/components/ui/AspectRatioThumbnail.tsx | 83 +++++++ 2 files changed, 209 insertions(+), 125 deletions(-) create mode 100644 src/components/ui/AspectRatioThumbnail.tsx diff --git a/src/components/PresetSelector.tsx b/src/components/PresetSelector.tsx index 53821604..d4b7d193 100644 --- a/src/components/PresetSelector.tsx +++ b/src/components/PresetSelector.tsx @@ -7,12 +7,24 @@ import { Search, Settings2 } from "lucide-react"; import { PRESETS } from "@/lib/presets"; import { EditRecipe } from "@/lib/types"; import { cn } from "@/lib/utils"; +import { Tooltip } from "@/components/ui/Tooltip"; +import AspectRatioThumbnail from "@/components/ui/AspectRatioThumbnail"; interface Props { recipe: EditRecipe; onChange: (patch: Partial) => void; } +function presetButtonClass(active: boolean) { + return cn( + "min-h-[44px] min-w-[44px] w-full flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border text-center", + "transition-all duration-150 cursor-pointer hover:scale-[1.02] active:scale-[0.98]", + active + ? "border-film-500 bg-film-50" + : "border-[var(--border)] bg-[var(--surface)] hover:border-film-300 hover:bg-film-50/30", + ); +} + function getOrientationLabel(width: number, height: number): string { const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b)); const divisor = gcd(width, height); @@ -22,33 +34,6 @@ function getOrientationLabel(width: number, height: number): string { return `${orientation} (${ratio})`; } -function RatioBox({ - width, - height, - active, -}: { - width: number; - height: number; - active: boolean; -}) { - const MAX = 32; - const ratio = width / height; - const [w, h] = - ratio >= 1 - ? [MAX, Math.max(4, Math.round(MAX / ratio))] - : [Math.max(4, Math.round(MAX * ratio)), MAX]; - - return ( -
- ); -} - export default function PresetSelector({ recipe, onChange }: Props) { const [search, setSearch] = useState(""); @@ -87,13 +72,19 @@ export default function PresetSelector({ recipe, onChange }: Props) {
- setSearch(e.target.value)} - className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] py-2 pl-9 pr-3 text-sm font-heading text-[var(--text)] transition-shadow focus:outline-none focus:ring-2 focus:ring-film-400" - /> + + setSearch(e.target.value)} + aria-label="Search output size presets" + className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] py-2 pl-9 pr-3 text-sm font-heading text-[var(--text)] transition-shadow focus:outline-none focus:ring-2 focus:ring-film-400" + /> +
@@ -106,83 +97,83 @@ export default function PresetSelector({ recipe, onChange }: Props) { const active = recipe.preset === preset.id; return ( - + + ); }) )} -
- + + +
+

+ Custom +

+

+ Set your own +

+
+ + {recipe.preset === "custom" && ( @@ -194,16 +185,21 @@ export default function PresetSelector({ recipe, onChange }: Props) { > Width (px) - handleWidthChange(Number(e.target.value))} - className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400" - /> + + handleWidthChange(Number(e.target.value))} + className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400" + /> +
@@ -219,23 +215,28 @@ export default function PresetSelector({ recipe, onChange }: Props) { > Height (px) - handleHeightChange(Number(e.target.value))} - className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400" - /> + + handleHeightChange(Number(e.target.value))} + className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400" + /> +
Ratio -
+
{getOrientationLabel( recipe.customWidth || 0, recipe.customHeight || 0, diff --git a/src/components/ui/AspectRatioThumbnail.tsx b/src/components/ui/AspectRatioThumbnail.tsx new file mode 100644 index 00000000..5fb627f7 --- /dev/null +++ b/src/components/ui/AspectRatioThumbnail.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +/** Max width/height of the preview area (matches w-6 h-6 slot). */ +export const THUMBNAIL_BOUNDS = 24; +const MIN_EDGE = 4; + +/** + * Scale pixel dimensions to fit inside a square box while preserving aspect ratio. + * Uses contain-fit (same proportions as the real export size). + */ +export function getAspectRatioDimensions( + width: number, + height: number, + maxSize = THUMBNAIL_BOUNDS +): { width: number; height: number; x: number; y: number } { + if (width <= 0 || height <= 0) { + const fallback = Math.round(maxSize * 0.65); + const offset = (maxSize - fallback) / 2; + return { width: fallback, height: fallback, x: offset, y: offset }; + } + + const scale = Math.min(maxSize / width, maxSize / height); + const w = Math.max(MIN_EDGE, Math.round(width * scale)); + const h = Math.max(MIN_EDGE, Math.round(height * scale)); + + return { + width: w, + height: h, + x: (maxSize - w) / 2, + y: (maxSize - h) / 2, + }; +} + +interface AspectRatioThumbnailProps { + width: number; + height: number; + active?: boolean; + className?: string; +} + +/** + * Small centered aspect-ratio preview for compact preset cards. + * Rendered as an inline SVG rect within a fixed 24×24px box. + */ +export default function AspectRatioThumbnail({ + width, + height, + active = false, + className, +}: AspectRatioThumbnailProps) { + const { width: w, height: h, x, y } = getAspectRatioDimensions( + width, + height + ); + + return ( + + ); +}