File
Blob: src/components/panels.tsx
| 1 | import type { Dispatch } from "react"; |
| 2 | import { cn } from "../lib/cn"; |
| 3 | import type { EditorAction, EditorState } from "../hooks/useEditor"; |
| 4 | import type { Adjustments, ExportFormat, ExportResult } from "../types"; |
| 5 | import { NEUTRAL_ADJUSTMENTS } from "../types"; |
| 6 | import type { LoadedImage } from "../lib/image"; |
| 7 | import { buildFilename, formatBytes, megapixels, triggerDownload } from "../lib/image"; |
| 8 | import { Button, Section, Segmented } from "./ui"; |
| 9 | import { Slider } from "./Slider"; |
| 10 | import { toast } from "./toast"; |
| 11 | import { |
| 12 | ContrastIcon, |
| 13 | CopyIcon, |
| 14 | CropIcon, |
| 15 | DownloadIcon, |
| 16 | DropletIcon, |
| 17 | FlipHorizontalIcon, |
| 18 | FlipVerticalIcon, |
| 19 | ResizeIcon, |
| 20 | RotateLeftIcon, |
| 21 | RotateRightIcon, |
| 22 | SlidersIcon, |
| 23 | SpinnerIcon, |
| 24 | SunIcon, |
| 25 | WandIcon, |
| 26 | } from "./icons"; |
| 27 | |
| 28 | const EFFECTS: { label: string; value: Adjustments }[] = [ |
| 29 | { label: "None", value: NEUTRAL_ADJUSTMENTS }, |
| 30 | { label: "Vivid", value: { ...NEUTRAL_ADJUSTMENTS, saturate: 1.4, contrast: 1.1 } }, |
| 31 | { label: "Fade", value: { ...NEUTRAL_ADJUSTMENTS, contrast: 0.85, brightness: 1.08, saturate: 0.9 } }, |
| 32 | { label: "B&W", value: { ...NEUTRAL_ADJUSTMENTS, grayscale: 1, contrast: 1.05 } }, |
| 33 | { label: "Noir", value: { ...NEUTRAL_ADJUSTMENTS, grayscale: 1, contrast: 1.3, brightness: 0.95 } }, |
| 34 | { label: "Sepia", value: { ...NEUTRAL_ADJUSTMENTS, sepia: 0.75, contrast: 1.05, brightness: 1.02 } }, |
| 35 | ]; |
| 36 | |
| 37 | function sameAdjustments(a: Adjustments, b: Adjustments): boolean { |
| 38 | return ( |
| 39 | a.brightness === b.brightness && |
| 40 | a.contrast === b.contrast && |
| 41 | a.saturate === b.saturate && |
| 42 | a.grayscale === b.grayscale && |
| 43 | a.sepia === b.sepia && |
| 44 | a.hueRotate === b.hueRotate |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | interface PanelProps { |
| 49 | state: EditorState; |
| 50 | dispatch: Dispatch<EditorAction>; |
| 51 | } |
| 52 | |
| 53 | const ASPECTS: { label: string; value: number | null }[] = [ |
| 54 | { label: "Original", value: null }, |
| 55 | { label: "1:1", value: 1 }, |
| 56 | { label: "4:3", value: 4 / 3 }, |
| 57 | { label: "3:2", value: 3 / 2 }, |
| 58 | { label: "16:9", value: 16 / 9 }, |
| 59 | { label: "9:16", value: 9 / 16 }, |
| 60 | ]; |
| 61 | |
| 62 | function aspectActive(current: number | null, value: number | null): boolean { |
| 63 | if (value === null) return current === null; |
| 64 | if (current === null) return false; |
| 65 | return Math.abs(current - value) < 1e-3; |
| 66 | } |
| 67 | |
| 68 | export function CropPanel({ state, dispatch }: PanelProps) { |
| 69 | return ( |
| 70 | <Section title="Crop & frame" icon={<CropIcon className="size-3.5" />}> |
| 71 | <div className="grid grid-cols-3 gap-1.5"> |
| 72 | {ASPECTS.map((a) => ( |
| 73 | <Button |
| 74 | key={a.label} |
| 75 | size="sm" |
| 76 | active={aspectActive(state.aspect, a.value)} |
| 77 | onClick={() => dispatch({ type: "setAspect", aspect: a.value })} |
| 78 | > |
| 79 | {a.label} |
| 80 | </Button> |
| 81 | ))} |
| 82 | </div> |
| 83 | <div className="mt-4"> |
| 84 | <Slider |
| 85 | label="Zoom" |
| 86 | value={state.zoom} |
| 87 | min={1} |
| 88 | max={8} |
| 89 | step={0.01} |
| 90 | resetTo={1} |
| 91 | display={(v) => `${v.toFixed(2)}×`} |
| 92 | onChange={(v) => dispatch({ type: "setZoom", zoom: v })} |
| 93 | /> |
| 94 | </div> |
| 95 | </Section> |
| 96 | ); |
| 97 | } |
| 98 | |
| 99 | export function TransformPanel({ state, dispatch }: PanelProps) { |
| 100 | return ( |
| 101 | <Section title="Transform" icon={<RotateRightIcon className="size-3.5" />}> |
| 102 | <div className="mb-4 grid grid-cols-2 gap-1.5"> |
| 103 | <Button size="sm" onClick={() => dispatch({ type: "rotateBy", delta: -90 })}> |
| 104 | <RotateLeftIcon className="size-4" /> -90° |
| 105 | </Button> |
| 106 | <Button size="sm" onClick={() => dispatch({ type: "rotateBy", delta: 90 })}> |
| 107 | <RotateRightIcon className="size-4" /> +90° |
| 108 | </Button> |
| 109 | <Button size="sm" active={state.flipH} onClick={() => dispatch({ type: "toggleFlipH" })}> |
| 110 | <FlipHorizontalIcon className="size-4" /> Flip H |
| 111 | </Button> |
| 112 | <Button size="sm" active={state.flipV} onClick={() => dispatch({ type: "toggleFlipV" })}> |
| 113 | <FlipVerticalIcon className="size-4" /> Flip V |
| 114 | </Button> |
| 115 | </div> |
| 116 | <Slider |
| 117 | label="Rotation" |
| 118 | value={state.rotation} |
| 119 | min={0} |
| 120 | max={360} |
| 121 | step={1} |
| 122 | resetTo={0} |
| 123 | display={(v) => `${Math.round(v)}°`} |
| 124 | onChange={(v) => dispatch({ type: "setRotation", rotation: v })} |
| 125 | /> |
| 126 | </Section> |
| 127 | ); |
| 128 | } |
| 129 | |
| 130 | export function EffectsPanel({ state, dispatch }: PanelProps) { |
| 131 | const a = state.adjustments; |
| 132 | return ( |
| 133 | <Section title="Effects" icon={<WandIcon className="size-3.5" />}> |
| 134 | <div className="grid grid-cols-3 gap-1.5"> |
| 135 | {EFFECTS.map((e) => ( |
| 136 | <Button |
| 137 | key={e.label} |
| 138 | size="sm" |
| 139 | active={sameAdjustments(a, e.value)} |
| 140 | onClick={() => dispatch({ type: "setAdjustments", adjustments: e.value })} |
| 141 | > |
| 142 | {e.label} |
| 143 | </Button> |
| 144 | ))} |
| 145 | </div> |
| 146 | </Section> |
| 147 | ); |
| 148 | } |
| 149 | |
| 150 | export function AdjustPanel({ state, dispatch }: PanelProps) { |
| 151 | const a = state.adjustments; |
| 152 | const dirty = !sameAdjustments(a, NEUTRAL_ADJUSTMENTS); |
| 153 | const pct = (v: number) => `${Math.round(v * 100)}%`; |
| 154 | |
| 155 | return ( |
| 156 | <Section |
| 157 | title="Adjust" |
| 158 | icon={<SlidersIcon className="size-3.5" />} |
| 159 | action={ |
| 160 | dirty ? ( |
| 161 | <button |
| 162 | onClick={() => dispatch({ type: "resetAdjustments" })} |
| 163 | className="text-xs text-accent-400 hover:underline" |
| 164 | > |
| 165 | Reset |
| 166 | </button> |
| 167 | ) : null |
| 168 | } |
| 169 | > |
| 170 | <Slider |
| 171 | label={ |
| 172 | <span className="flex items-center gap-1.5"> |
| 173 | <SunIcon className="size-3.5 text-zinc-500" /> Brightness |
| 174 | </span> |
| 175 | } |
| 176 | value={a.brightness} |
| 177 | min={0} |
| 178 | max={2} |
| 179 | step={0.01} |
| 180 | resetTo={1} |
| 181 | display={pct} |
| 182 | onChange={(v) => dispatch({ type: "setAdjustment", key: "brightness", value: v })} |
| 183 | /> |
| 184 | <Slider |
| 185 | label={ |
| 186 | <span className="flex items-center gap-1.5"> |
| 187 | <ContrastIcon className="size-3.5 text-zinc-500" /> Contrast |
| 188 | </span> |
| 189 | } |
| 190 | value={a.contrast} |
| 191 | min={0} |
| 192 | max={2} |
| 193 | step={0.01} |
| 194 | resetTo={1} |
| 195 | display={pct} |
| 196 | onChange={(v) => dispatch({ type: "setAdjustment", key: "contrast", value: v })} |
| 197 | /> |
| 198 | <Slider |
| 199 | label={ |
| 200 | <span className="flex items-center gap-1.5"> |
| 201 | <DropletIcon className="size-3.5 text-zinc-500" /> Saturation |
| 202 | </span> |
| 203 | } |
| 204 | value={a.saturate} |
| 205 | min={0} |
| 206 | max={2} |
| 207 | step={0.01} |
| 208 | resetTo={1} |
| 209 | display={pct} |
| 210 | onChange={(v) => dispatch({ type: "setAdjustment", key: "saturate", value: v })} |
| 211 | /> |
| 212 | </Section> |
| 213 | ); |
| 214 | } |
| 215 | |
| 216 | const FORMAT_OPTIONS: { value: ExportFormat; label: string }[] = [ |
| 217 | { value: "jpeg", label: "JPEG" }, |
| 218 | { value: "webp", label: "WebP" }, |
| 219 | { value: "png", label: "PNG" }, |
| 220 | { value: "avif", label: "AVIF" }, |
| 221 | ]; |
| 222 | |
| 223 | interface ExportPanelProps extends PanelProps { |
| 224 | image: LoadedImage; |
| 225 | onExport: () => void; |
| 226 | exporting: boolean; |
| 227 | result: ExportResult | null; |
| 228 | resultUrl: string | null; |
| 229 | error: string | null; |
| 230 | } |
| 231 | |
| 232 | function InfoRow({ label, value }: { label: string; value: string }) { |
| 233 | return ( |
| 234 | <div className="flex items-center justify-between text-xs"> |
| 235 | <dt className="text-zinc-400">{label}</dt> |
| 236 | <dd className="font-mono tabular-nums text-zinc-100">{value}</dd> |
| 237 | </div> |
| 238 | ); |
| 239 | } |
| 240 | |
| 241 | function applyMaxEdge( |
| 242 | dims: { width: number; height: number } | null, |
| 243 | maxEdge: number | null, |
| 244 | ): { width: number; height: number } | null { |
| 245 | if (!dims || !maxEdge) return dims; |
| 246 | const long = Math.max(dims.width, dims.height); |
| 247 | if (long <= maxEdge) return dims; |
| 248 | const scale = maxEdge / long; |
| 249 | return { width: Math.round(dims.width * scale), height: Math.round(dims.height * scale) }; |
| 250 | } |
| 251 | |
| 252 | export function ExportPanel({ |
| 253 | state, |
| 254 | dispatch, |
| 255 | image, |
| 256 | onExport, |
| 257 | exporting, |
| 258 | result, |
| 259 | resultUrl, |
| 260 | error, |
| 261 | }: ExportPanelProps) { |
| 262 | const lossy = state.format !== "png"; |
| 263 | const limitOn = state.maxEdge !== null; |
| 264 | |
| 265 | const baseDims = |
| 266 | state.output ?? |
| 267 | (state.croppedAreaPixels |
| 268 | ? { |
| 269 | width: Math.round(state.croppedAreaPixels.width), |
| 270 | height: Math.round(state.croppedAreaPixels.height), |
| 271 | } |
| 272 | : null); |
| 273 | const outDims = applyMaxEdge(baseDims, state.maxEdge); |
| 274 | |
| 275 | function downloadAgain() { |
| 276 | if (!result) return; |
| 277 | triggerDownload(result.blob, buildFilename(image.file.name, state.templateId, state.format)); |
| 278 | toast.success("Saved to downloads"); |
| 279 | } |
| 280 | |
| 281 | async function copyToClipboard() { |
| 282 | if (!result) return; |
| 283 | try { |
| 284 | await navigator.clipboard.write([new ClipboardItem({ [result.blob.type]: result.blob })]); |
| 285 | toast.success("Copied to clipboard"); |
| 286 | } catch { |
| 287 | toast.error("Clipboard unavailable"); |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | return ( |
| 292 | <div className="flex h-full flex-col"> |
| 293 | <Section title="Export" icon={<DownloadIcon className="size-3.5" />}> |
| 294 | <label className="mb-1.5 block text-xs text-zinc-400">Format</label> |
| 295 | <Segmented |
| 296 | ariaLabel="Export format" |
| 297 | value={state.format} |
| 298 | options={FORMAT_OPTIONS} |
| 299 | onChange={(f) => dispatch({ type: "setFormat", format: f })} |
| 300 | /> |
| 301 | <div className="mt-4"> |
| 302 | <Slider |
| 303 | label="Quality" |
| 304 | value={state.quality} |
| 305 | min={1} |
| 306 | max={100} |
| 307 | step={1} |
| 308 | disabled={!lossy} |
| 309 | resetTo={90} |
| 310 | display={(v) => (lossy ? `${v}` : "lossless")} |
| 311 | onChange={(v) => dispatch({ type: "setQuality", quality: v })} |
| 312 | /> |
| 313 | </div> |
| 314 | </Section> |
| 315 | |
| 316 | <Section title="Output"> |
| 317 | <dl className="space-y-2"> |
| 318 | <InfoRow label="Dimensions" value={outDims ? `${outDims.width} × ${outDims.height}` : "—"} /> |
| 319 | <InfoRow label="Megapixels" value={outDims ? megapixels(outDims.width, outDims.height) : "—"} /> |
| 320 | <InfoRow label="Source" value={`${image.width} × ${image.height}`} /> |
| 321 | </dl> |
| 322 | |
| 323 | <div className="mt-3 flex items-center justify-between"> |
| 324 | <span className="flex items-center gap-1.5 text-xs text-zinc-400"> |
| 325 | <ResizeIcon className="size-3.5" /> Limit long edge |
| 326 | </span> |
| 327 | <div className="flex items-center gap-2"> |
| 328 | <input |
| 329 | type="number" |
| 330 | min={16} |
| 331 | disabled={!limitOn} |
| 332 | value={limitOn ? state.maxEdge! : ""} |
| 333 | placeholder="—" |
| 334 | onChange={(e) => { |
| 335 | const n = Math.round(Number(e.target.value)); |
| 336 | dispatch({ type: "setMaxEdge", maxEdge: Number.isFinite(n) && n > 0 ? Math.max(16, n) : 16 }); |
| 337 | }} |
| 338 | 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" |
| 339 | /> |
| 340 | <span className="text-xs text-zinc-500">px</span> |
| 341 | <button |
| 342 | role="switch" |
| 343 | aria-checked={limitOn} |
| 344 | aria-label="Limit long edge" |
| 345 | onClick={() => dispatch({ type: "setMaxEdge", maxEdge: limitOn ? null : 1600 })} |
| 346 | className={cn( |
| 347 | "flex h-5 w-9 items-center rounded-full px-0.5 transition-colors", |
| 348 | limitOn ? "bg-accent-600" : "bg-zinc-700", |
| 349 | )} |
| 350 | > |
| 351 | <span |
| 352 | className={cn( |
| 353 | "size-4 rounded-full bg-white transition-transform", |
| 354 | limitOn ? "translate-x-4" : "translate-x-0", |
| 355 | )} |
| 356 | /> |
| 357 | </button> |
| 358 | </div> |
| 359 | </div> |
| 360 | </Section> |
| 361 | |
| 362 | <div className="mt-auto space-y-3 p-4"> |
| 363 | {error && ( |
| 364 | <p className="rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-400">{error}</p> |
| 365 | )} |
| 366 | |
| 367 | {result && !error && ( |
| 368 | <div className="space-y-2 rounded-lg border border-zinc-700/60 bg-zinc-800/60 p-2.5"> |
| 369 | <div className="flex items-center gap-3"> |
| 370 | {resultUrl && ( |
| 371 | <img |
| 372 | src={resultUrl} |
| 373 | alt="Export preview" |
| 374 | className="size-12 shrink-0 rounded-md border border-zinc-700/60 bg-checker object-cover" |
| 375 | /> |
| 376 | )} |
| 377 | <div className="min-w-0 flex-1"> |
| 378 | <p className="truncate text-xs font-medium text-zinc-100"> |
| 379 | {formatBytes(result.bytes)} · {result.width} × {result.height} |
| 380 | </p> |
| 381 | <p className="mt-0.5 text-[0.65rem] text-zinc-400"> |
| 382 | {result.format.toUpperCase()} · {result.encoder} encoder · {result.engine} engine |
| 383 | </p> |
| 384 | </div> |
| 385 | </div> |
| 386 | <div className="flex gap-1.5"> |
| 387 | <Button size="sm" variant="subtle" className="flex-1" onClick={downloadAgain}> |
| 388 | <DownloadIcon className="size-3.5" /> Save |
| 389 | </Button> |
| 390 | <Button size="sm" variant="subtle" className="flex-1" onClick={copyToClipboard}> |
| 391 | <CopyIcon className="size-3.5" /> Copy |
| 392 | </Button> |
| 393 | </div> |
| 394 | </div> |
| 395 | )} |
| 396 | |
| 397 | <Button |
| 398 | variant="primary" |
| 399 | size="md" |
| 400 | className="w-full" |
| 401 | disabled={exporting || !state.croppedAreaPixels} |
| 402 | onClick={onExport} |
| 403 | > |
| 404 | {exporting ? ( |
| 405 | <> |
| 406 | <SpinnerIcon className="size-4 animate-spin" /> Exporting… |
| 407 | </> |
| 408 | ) : ( |
| 409 | <> |
| 410 | <DownloadIcon className="size-4" /> Export & download |
| 411 | </> |
| 412 | )} |
| 413 | </Button> |
| 414 | </div> |
| 415 | </div> |
| 416 | ); |
| 417 | } |