import type { Dispatch } from "react"; import { cn } from "../lib/cn"; import type { EditorAction, EditorState } from "../hooks/useEditor"; import type { Adjustments, ExportFormat, ExportResult } from "../types"; import { NEUTRAL_ADJUSTMENTS } from "../types"; import type { LoadedImage } from "../lib/image"; import { buildFilename, formatBytes, megapixels, triggerDownload } from "../lib/image"; import { Button, Section, Segmented } from "./ui"; import { Slider } from "./Slider"; import { toast } from "./toast"; import { ContrastIcon, CopyIcon, CropIcon, DownloadIcon, DropletIcon, FlipHorizontalIcon, FlipVerticalIcon, ResizeIcon, RotateLeftIcon, RotateRightIcon, SlidersIcon, SpinnerIcon, SunIcon, WandIcon, } from "./icons"; const EFFECTS: { label: string; value: Adjustments }[] = [ { label: "None", value: NEUTRAL_ADJUSTMENTS }, { label: "Vivid", value: { ...NEUTRAL_ADJUSTMENTS, saturate: 1.4, contrast: 1.1 } }, { label: "Fade", value: { ...NEUTRAL_ADJUSTMENTS, contrast: 0.85, brightness: 1.08, saturate: 0.9 } }, { label: "B&W", value: { ...NEUTRAL_ADJUSTMENTS, grayscale: 1, contrast: 1.05 } }, { label: "Noir", value: { ...NEUTRAL_ADJUSTMENTS, grayscale: 1, contrast: 1.3, brightness: 0.95 } }, { label: "Sepia", value: { ...NEUTRAL_ADJUSTMENTS, sepia: 0.75, contrast: 1.05, brightness: 1.02 } }, ]; function sameAdjustments(a: Adjustments, b: Adjustments): boolean { return ( a.brightness === b.brightness && a.contrast === b.contrast && a.saturate === b.saturate && a.grayscale === b.grayscale && a.sepia === b.sepia && a.hueRotate === b.hueRotate ); } interface PanelProps { state: EditorState; dispatch: Dispatch; } const ASPECTS: { label: string; value: number | null }[] = [ { label: "Original", value: null }, { label: "1:1", value: 1 }, { label: "4:3", value: 4 / 3 }, { label: "3:2", value: 3 / 2 }, { label: "16:9", value: 16 / 9 }, { label: "9:16", value: 9 / 16 }, ]; function aspectActive(current: number | null, value: number | null): boolean { if (value === null) return current === null; if (current === null) return false; return Math.abs(current - value) < 1e-3; } export function CropPanel({ state, dispatch }: PanelProps) { return (
}>
{ASPECTS.map((a) => ( ))}
`${v.toFixed(2)}×`} onChange={(v) => dispatch({ type: "setZoom", zoom: v })} />
); } export function TransformPanel({ state, dispatch }: PanelProps) { return (
}>
`${Math.round(v)}°`} onChange={(v) => dispatch({ type: "setRotation", rotation: v })} />
); } export function EffectsPanel({ state, dispatch }: PanelProps) { const a = state.adjustments; return (
}>
{EFFECTS.map((e) => ( ))}
); } export function AdjustPanel({ state, dispatch }: PanelProps) { const a = state.adjustments; const dirty = !sameAdjustments(a, NEUTRAL_ADJUSTMENTS); const pct = (v: number) => `${Math.round(v * 100)}%`; return (
} action={ dirty ? ( ) : null } > Brightness } value={a.brightness} min={0} max={2} step={0.01} resetTo={1} display={pct} onChange={(v) => dispatch({ type: "setAdjustment", key: "brightness", value: v })} /> Contrast } value={a.contrast} min={0} max={2} step={0.01} resetTo={1} display={pct} onChange={(v) => dispatch({ type: "setAdjustment", key: "contrast", value: v })} /> Saturation } value={a.saturate} min={0} max={2} step={0.01} resetTo={1} display={pct} onChange={(v) => dispatch({ type: "setAdjustment", key: "saturate", value: v })} />
); } const FORMAT_OPTIONS: { value: ExportFormat; label: string }[] = [ { value: "jpeg", label: "JPEG" }, { value: "webp", label: "WebP" }, { value: "png", label: "PNG" }, { value: "avif", label: "AVIF" }, ]; interface ExportPanelProps extends PanelProps { image: LoadedImage; onExport: () => void; exporting: boolean; result: ExportResult | null; resultUrl: string | null; error: string | null; } function InfoRow({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function applyMaxEdge( dims: { width: number; height: number } | null, maxEdge: number | null, ): { width: number; height: number } | null { if (!dims || !maxEdge) return dims; const long = Math.max(dims.width, dims.height); if (long <= maxEdge) return dims; const scale = maxEdge / long; return { width: Math.round(dims.width * scale), height: Math.round(dims.height * scale) }; } export function ExportPanel({ state, dispatch, image, onExport, exporting, result, resultUrl, error, }: ExportPanelProps) { const lossy = state.format !== "png"; const limitOn = state.maxEdge !== null; const baseDims = state.output ?? (state.croppedAreaPixels ? { width: Math.round(state.croppedAreaPixels.width), height: Math.round(state.croppedAreaPixels.height), } : null); const outDims = applyMaxEdge(baseDims, state.maxEdge); function downloadAgain() { if (!result) return; triggerDownload(result.blob, buildFilename(image.file.name, state.templateId, state.format)); toast.success("Saved to downloads"); } async function copyToClipboard() { if (!result) return; try { await navigator.clipboard.write([new ClipboardItem({ [result.blob.type]: result.blob })]); toast.success("Copied to clipboard"); } catch { toast.error("Clipboard unavailable"); } } return (
}> dispatch({ type: "setFormat", format: f })} />
(lossy ? `${v}` : "lossless")} onChange={(v) => dispatch({ type: "setQuality", quality: v })} />
Limit long edge
{ const n = Math.round(Number(e.target.value)); dispatch({ type: "setMaxEdge", maxEdge: Number.isFinite(n) && n > 0 ? Math.max(16, n) : 16 }); }} className="w-16 rounded-md border border-zinc-700/60 bg-zinc-800 px-2 py-1 text-right text-xs tabular-nums text-zinc-100 outline-none focus:border-accent-500/50 disabled:opacity-40" /> px
{error && (

{error}

)} {result && !error && (
{resultUrl && ( Export preview )}

{formatBytes(result.bytes)} · {result.width} × {result.height}

{result.format.toUpperCase()} · {result.encoder} encoder · {result.engine} engine

)}
); }