diff --git a/frontend/app/hitherdither/page.tsx b/frontend/app/hitherdither/page.tsx new file mode 100644 index 0000000..7a49309 --- /dev/null +++ b/frontend/app/hitherdither/page.tsx @@ -0,0 +1,449 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"; + +const ERROR_DIFFUSION = [ + { value: "floyd-steinberg", label: "Floyd-Steinberg" }, + { value: "atkinson", label: "Atkinson" }, + { value: "stucki", label: "Stucki" }, + { value: "burkes", label: "Burkes" }, + { value: "sierra", label: "Sierra" }, + { value: "sierra-two-row", label: "Sierra Two-Row" }, + { value: "sierra-lite", label: "Sierra Lite" }, + { value: "jarvis-judice-ninke", label: "Jarvis-Judice-Ninke" }, +]; + +const ORDERED = [ + { value: "bayer", label: "Bayer" }, + { value: "ordered", label: "Ordered" }, + { value: "cluster-dot", label: "Cluster Dot" }, + { value: "yliluoma", label: "Yliluoma" }, +]; + +const THRESHOLD_ALGORITHMS = ["bayer", "ordered", "cluster-dot"]; +const ORDER_ALGORITHMS = ["bayer", "ordered", "cluster-dot", "yliluoma"]; + +interface Design { + slug: string; + name: string; + image_url: string; + status: string; +} + +interface DitherResponse { + slug: string; + algorithm: string; + palette_mode: string; + num_colors: number; + colors_used: string[]; + cached: boolean; + image_url: string; +} + +export default function HitherditherPage() { + const searchParams = useSearchParams(); + + const [designs, setDesigns] = useState([]); + const [slug, setSlug] = useState(searchParams.get("slug") || ""); + const [algorithm, setAlgorithm] = useState("floyd-steinberg"); + const [palette, setPalette] = useState("auto"); + const [numColors, setNumColors] = useState(8); + const [threshold, setThreshold] = useState(64); + const [order, setOrder] = useState(8); + const [metadata, setMetadata] = useState(null); + const [ditherUrl, setDitherUrl] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Fetch designs on mount + useEffect(() => { + fetch(`${API_URL}/designs?status=active`) + .then((res) => (res.ok ? res.json() : Promise.reject(res))) + .then((data: Design[]) => setDesigns(data)) + .catch(() => setError("Failed to load designs")); + }, []); + + const showThreshold = THRESHOLD_ALGORITHMS.includes(algorithm); + const showOrder = ORDER_ALGORITHMS.includes(algorithm); + + const buildDitherParams = () => { + const params = new URLSearchParams({ + algorithm, + palette, + num_colors: String(numColors), + }); + if (showThreshold) params.set("threshold", String(threshold)); + if (showOrder) params.set("order", String(order)); + return params; + }; + + const handleApply = async () => { + if (!slug) return; + setIsLoading(true); + setError(null); + + try { + const params = buildDitherParams(); + params.set("format", "json"); + const response = await fetch( + `${API_URL}/designs/${slug}/dither?${params}` + ); + + if (!response.ok) { + let message = "Dithering failed"; + try { + const data = await response.json(); + message = data.detail || message; + } catch { + const text = await response.text(); + message = text || `Server error (${response.status})`; + } + throw new Error(message); + } + + const data: DitherResponse = await response.json(); + setMetadata(data); + + // Build image URL (without format=json) + const imgParams = buildDitherParams(); + setDitherUrl(`${API_URL}/designs/${slug}/dither?${imgParams}`); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

Hitherdither

+

+ Apply retro dithering effects to any design in the catalog +

+ +
+ {/* Left: Controls */} +
+ {/* Design Picker */} +
+ + +
+ + {/* Algorithm */} +
+ + +
+ + {/* Palette */} +
+ + +
+ + {/* Number of Colors */} +
+ + setNumColors(Number(e.target.value))} + className="w-full" + /> +
+ 2 + 32 +
+
+ + {/* Threshold (conditional) */} + {showThreshold && ( +
+ + setThreshold(Number(e.target.value))} + className="w-full" + /> +
+ 1 + 256 +
+
+ )} + + {/* Order (conditional) */} + {showOrder && ( +
+ + setOrder(Number(e.target.value))} + className="w-full" + /> +
+ 2 + 16 +
+
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* Apply Button */} + +
+ + {/* Right: Preview */} +
+

Preview

+ + {slug ? ( +
+ {/* Side-by-side comparison */} +
+
+

+ Original +

+
+ Original design +
+
+
+

+ Dithered +

+
+ {ditherUrl ? ( + Dithered result + ) : ( +
+

+ Click “Apply Dither” to see result +

+
+ )} +
+
+
+ + {/* Palette display */} + {metadata && ( +
+
+ {metadata.colors_used.map((color) => ( +
+
+ + {color} + +
+ ))} +
+

+ {metadata.num_colors} colors + {metadata.cached ? " · cached" : ""} +

+
+ )} + + {/* Download */} + {ditherUrl && ( + + + + + Download PNG + + )} +
+ ) : ( +
+

+ Select a design to get started +

+
+ )} +
+
+ + {/* Tips */} +
+

How It Works

+
    +
  • + • Error diffusion algorithms (Floyd-Steinberg, + Atkinson, etc.) spread quantization error to neighboring pixels for + natural-looking results +
  • +
  • + • Ordered algorithms (Bayer, Cluster Dot) use + fixed threshold patterns for a more structured, retro look +
  • +
  • + • Fewer colors produce more dramatic dithering effects — try 2-4 + colors for a classic screen-print aesthetic +
  • +
  • + • First-time dithering for a design may take 5-12 seconds; + subsequent requests with the same settings are cached +
  • +
+
+
+
+ ); +} diff --git a/frontend/components/SwagNavActions.tsx b/frontend/components/SwagNavActions.tsx new file mode 100644 index 0000000..e763089 --- /dev/null +++ b/frontend/components/SwagNavActions.tsx @@ -0,0 +1,52 @@ +'use client'; + +import Link from 'next/link'; + +export function SwagNavActions() { + return ( + <> + + Design + + + Upload + + + Hitherdither + + + Shop + + + + + + + + ); +}