Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 126 additions & 125 deletions src/components/PresetSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EditRecipe>) => 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);
Expand All @@ -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 (
<div
className={cn(
"border-2 flex-shrink-0 rounded-sm transition-colors",
active ? "border-film-600" : "border-[var(--muted)] opacity-60",
)}
style={{ width: w, height: h }}
/>
);
}

export default function PresetSelector({ recipe, onChange }: Props) {
const [search, setSearch] = useState("");

Expand Down Expand Up @@ -87,13 +72,19 @@ export default function PresetSelector({ recipe, onChange }: Props) {
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search size={14} className="text-[var(--muted)]" />
</div>
<input
type="text"
placeholder="Search formats..."
value={search}
onChange={(e) => 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"
/>
<Tooltip
block
content="Search presets by platform or format name."
>
<input
type="text"
placeholder="Search formats..."
value={search}
onChange={(e) => 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"
/>
</Tooltip>
</div>

<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
Expand All @@ -106,83 +97,83 @@ export default function PresetSelector({ recipe, onChange }: Props) {
const active = recipe.preset === preset.id;

return (
<button
<Tooltip
key={preset.id}
type="button"
onClick={() => handlePresetSelect(preset.id)}
title={`${preset.label} — ${preset.width}×${preset.height} — ${getOrientationLabel(preset.width, preset.height)}`}
aria-label={`Select ${preset.label} preset, ${preset.width} by ${preset.height} pixels`}
aria-pressed={active}
className={cn(
"min-h-[44px] min-w-[44px] 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",
)}
block
content={`${preset.label}: ${preset.width}×${preset.height} for ${preset.platform}.`}
>
<RatioBox
width={preset.width}
height={preset.height}
active={active}
/>

<div className="min-w-0 w-full">
<p
className={cn(
"text-sm font-heading font-bold leading-tight",
active ? "text-film-700" : "text-[var(--text)]",
)}
>
{preset.label}
</p>

<p className="mt-0.5 text-[11px] leading-tight text-[var(--muted)]">
{preset.platform}
</p>
</div>
</button>
<button
type="button"
onClick={() => handlePresetSelect(preset.id)}
aria-label={`${preset.label} preset, ${preset.width} by ${preset.height} pixels`}
aria-pressed={active}
className={presetButtonClass(active)}
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center">
<AspectRatioThumbnail
width={preset.width}
height={preset.height}
active={active}
/>
</span>

<div className="min-w-0 w-full">
<p
className={cn(
"text-sm font-heading font-bold leading-tight",
active ? "text-film-700" : "text-[var(--text)]",
)}
>
{preset.label}
</p>

<p className="mt-0.5 text-[11px] leading-tight text-[var(--muted)] line-clamp-2">
{preset.platform}
</p>
</div>
</button>
</Tooltip>
);
})
)}

<button
type="button"
title="Custom — Set your own dimensions"
aria-label="Select custom dimensions preset"
aria-pressed={recipe.preset === "custom"}
onClick={() => handlePresetSelect("custom")}
className={cn(
"min-h-[44px] min-w-[44px] 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]",
recipe.preset === "custom"
? "border-film-500 bg-film-50"
: "border-[var(--border)] bg-[var(--surface)] hover:border-film-300 hover:bg-film-50/30",
)}
>
<Settings2
size={20}
className={cn(
"shrink-0",
recipe.preset === "custom"
? "text-film-600"
: "text-[var(--muted)]",
)}
/>
<div className="min-w-0 w-full">
<p
className={cn(
"text-sm font-heading font-bold",
recipe.preset === "custom"
? "text-film-700"
: "text-[var(--text)]",
<Tooltip block content="Set your own output width and height in pixels.">
<button
type="button"
aria-label="Select custom dimensions preset"
aria-pressed={recipe.preset === "custom"}
onClick={() => handlePresetSelect("custom")}
className={presetButtonClass(recipe.preset === "custom")}
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center">
{recipe.preset === "custom" ? (
<AspectRatioThumbnail
width={recipe.customWidth}
height={recipe.customHeight}
active
/>
) : (
<Settings2 size={18} className="text-[var(--muted)]" />
)}
>
Custom
</p>
<p className="mt-0.5 text-[11px] leading-tight text-[var(--muted)]">
Set your own
</p>
</div>
</button>
</span>

<div className="min-w-0 w-full">
<p
className={cn(
"text-sm font-heading font-bold",
recipe.preset === "custom"
? "text-film-700"
: "text-[var(--text)]",
)}
>
Custom
</p>
<p className="mt-0.5 text-[11px] leading-tight text-[var(--muted)]">
Set your own
</p>
</div>
</button>
</Tooltip>
</div>

{recipe.preset === "custom" && (
Expand All @@ -194,16 +185,21 @@ export default function PresetSelector({ recipe, onChange }: Props) {
>
Width (px)
</label>
<input
id="custom-width"
type="number"
min={16}
max={7680}
step={2}
value={recipe.customWidth}
onChange={(e) => 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"
/>
<Tooltip
block
content="Output width in pixels. Must be between 16 and 7680."
>
<input
id="custom-width"
type="number"
min={16}
max={7680}
step={2}
value={recipe.customWidth}
onChange={(e) => 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"
/>
</Tooltip>
</div>

<div className="mt-5 flex flex-col items-center justify-center">
Expand All @@ -219,23 +215,28 @@ export default function PresetSelector({ recipe, onChange }: Props) {
>
Height (px)
</label>
<input
id="custom-height"
type="number"
min={16}
max={7680}
step={2}
value={recipe.customHeight}
onChange={(e) => 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"
/>
<Tooltip
block
content="Output height in pixels. Must be between 16 and 7680."
>
<input
id="custom-height"
type="number"
min={16}
max={7680}
step={2}
value={recipe.customHeight}
onChange={(e) => 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"
/>
</Tooltip>
</div>

<div className="hidden h-full flex-col justify-end sm:flex">
<span className="mb-1.5 block text-center text-[10px] font-heading font-semibold uppercase tracking-wider text-[var(--muted)]">
Ratio
</span>
<div className="flex h-[38px] items-center rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 text-xs font-medium text-film-700">
<div className="flex h-[38px] items-center justify-center rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 text-xs font-medium text-film-700">
{getOrientationLabel(
recipe.customWidth || 0,
recipe.customHeight || 0,
Expand Down
83 changes: 83 additions & 0 deletions src/components/ui/AspectRatioThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
width={THUMBNAIL_BOUNDS}
height={THUMBNAIL_BOUNDS}
viewBox={`0 0 ${THUMBNAIL_BOUNDS} ${THUMBNAIL_BOUNDS}`}
className={cn("shrink-0", className)}
aria-hidden="true"
>
<rect
x={x}
y={y}
width={w}
height={h}
rx={1.5}
className={cn(
"transition-[fill,stroke,opacity] duration-150",
active
? "fill-film-500/20 stroke-film-600"
: "fill-[var(--bg)] stroke-[var(--muted)] opacity-60"
)}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}