Fazekit

Code

"use client";
import * as React from "react";
import { animate, AnimatePresence, motion, MotionConfig, useMotionValue, useMotionValueEvent, useReducedMotion, type MotionValue } from "motion/react";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Braces, Check, Download, Layers, Pause, Play, Redo2, SlidersHorizontal, Undo2, Upload } from "lucide-react";
import { cn } from "@/lib/utils";
import { Artwork } from "./artwork";
import { BACKDROPS, DESIGNS, SWATCHES } from "./data";
import { downloadText, serializeArtwork, slugify } from "./export";
import { Inspector } from "./inspector";
import { LabelCanvas, type Box } from "./label-canvas";
import { LayersPanel } from "./layers-panel";
import { Mockup3D } from "./mockup-3d";
import { CONTAINER_ORDER, CONTAINERS } from "./shapes";
import { ARTBOARD, type ContainerKind, type Design, type Layer, type LayerKind } from "./types";
import { focusRing, IconButton, Kbd, Segmented, StudioMark, useIsDesktop } from "./ui";

export type { Design, Layer, ContainerKind };

export type LabelMockupStudioAppProps = {
  designs?: Design[];
  initialDesignId?: string;
  swatches?: string[];
  backdrops?: string[];
  onChange?: (design: Design) => void;
  onExport?: (svg: string, design: Design) => void;
  className?: string;
};

const HISTORY = 20;
let seq = 0;

function textColorFor(bg: string) {
  const m = /^#?([0-9a-f]{6})$/i.exec(bg.trim());
  if (!m) return "#111827";
  const v = parseInt(m[1], 16);
  const lum = (0.299 * ((v >> 16) & 255) + 0.587 * ((v >> 8) & 255) + 0.114 * (v & 255)) / 255;
  return lum > 0.6 ? "#1f2937" : "#ffffff";
}

function newLayer(kind: LayerKind, bg: string): Layer {
  const id = `layer-${++seq}`;
  const base = { id, kind, rotation: 0, visible: true, locked: false } as const;
  if (kind === "text") return { ...base, name: "Text", x: 50, y: 50, w: 140, h: 20, props: { text: "New text", font: "sans", size: 14, weight: 700, tracking: 0.04, color: textColorFor(bg), align: "middle" } };
  if (kind === "shape") return { ...base, name: "Shape", x: 90, y: 35, w: 60, h: 50, props: { shape: "rect", fill: "#be123c", stroke: "none", strokeWidth: 0, radius: 8 } };
  if (kind === "pattern") return { ...base, name: "Pattern", x: 0, y: 0, w: ARTBOARD.w, h: ARTBOARD.h, props: { pattern: "dots", color: textColorFor(bg), opacity: 0.25, scale: 1 } };
  return { ...base, name: "Badge", x: 100, y: 40, w: 40, h: 40, props: { text: "NEW", shape: "seal", fill: "#c9a24a", color: "#3b2a0b" } };
}

type Tab = "layers" | "edit" | "export";

export function LabelMockupStudioApp({ designs: initialDesigns = DESIGNS, initialDesignId, swatches = SWATCHES, backdrops = BACKDROPS, onChange, onExport, className }: LabelMockupStudioAppProps) {
  const reduced = useReducedMotion() ?? false;
  const desktop = useIsDesktop();
  const pid = React.useId().replace(/[^a-zA-Z0-9]/g, "");

  const [designs, setDesigns] = React.useState<Design[]>(initialDesigns);
  const [activeId, setActiveId] = React.useState(initialDesignId ?? initialDesigns[0]?.id ?? "");
  const design = designs.find((d) => d.id === activeId) ?? designs[0];
  const [past, setPast] = React.useState<Design[]>([]);
  const [future, setFuture] = React.useState<Design[]>([]);
  const lastKey = React.useRef<{ key: string; at: number } | null>(null);
  const [selectedId, setSelectedId] = React.useState<string | null>(null);
  const [editingId, setEditingId] = React.useState<string | null>(null);
  const [backdrop, setBackdrop] = React.useState(backdrops[0] ?? "#e9e4dc");
  const [tab, setTab] = React.useState<Tab>("edit");
  const [toast, setToast] = React.useState<{ id: number; text: string } | null>(null);
  const [bigStep, setBigStep] = React.useState(false);
  const [playing, setPlaying] = React.useState(true);
  const autoTurn = playing && !reduced;

  const rootRef = React.useRef<HTMLDivElement>(null);
  const exportRef = React.useRef<SVGSVGElement>(null);

  // Turn: motion value drives the wrap; React state mirrors it for the strip renderer.
  const turnMv = useMotionValue(24);
  React.useEffect(() => {
    if (!autoTurn) return;
    const from = turnMv.get();
    const c = animate(turnMv, from + 360, { duration: 9, ease: "linear", repeat: Infinity });
    return () => c.stop();
  }, [autoTurn, turnMv]);

  const say = (text: string) => setToast({ id: ++seq, text });
  React.useEffect(() => {
    if (!toast) return;
    const t = window.setTimeout(() => setToast(null), 2200);
    return () => window.clearTimeout(t);
  }, [toast]);

  const selected = design.layers.find((l) => l.id === selectedId) ?? null;

  /* ------------------------------- history -------------------------------- */

  const write = (next: Design) => {
    setDesigns((ds) => ds.map((d) => (d.id === next.id ? next : d)));
    onChange?.(next);
  };
  const pushPast = (cur: Design) => {
    setPast((p) => [...p.slice(-(HISTORY - 1)), cur]);
    setFuture([]);
  };
  /** Apply a change with an undo step. Changes with the same `key` within 1 s merge into one step. */
  const commit = (fn: (d: Design) => Design, key?: string) => {
    const now = performance.now();
    const merge = key && lastKey.current?.key === key && now - lastKey.current.at < 1000;
    if (!merge) pushPast(design);
    lastKey.current = key ? { key, at: now } : null;
    write(fn(design));
  };
  const live = (fn: (d: Design) => Design) => write(fn(design));
  const beginGesture = () => {
    pushPast(design);
    lastKey.current = null;
  };
  const undo = () => {
    if (!past.length) return;
    const prev = past[past.length - 1];
    setPast((p) => p.slice(0, -1));
    setFuture((f) => [design, ...f].slice(0, HISTORY));
    write(prev);
    lastKey.current = null;
    say(`Undo · ${past.length - 1} left`);
  };
  const redo = () => {
    if (!future.length) return;
    const next = future[0];
    setFuture((f) => f.slice(1));
    setPast((p) => [...p.slice(-(HISTORY - 1)), design]);
    write(next);
    lastKey.current = null;
    say("Redo");
  };

  const mapLayer = (id: string, fn: (l: Layer) => Layer) => (d: Design) => ({ ...d, layers: d.layers.map((l) => (l.id === id ? fn(l) : l)) });

  /* -------------------------------- actions ------------------------------- */

  const addLayer = (kind: LayerKind) => {
    const l = newLayer(kind, design.background);
    commit((d) => ({ ...d, layers: kind === "pattern" ? [l, ...d.layers] : [...d.layers, l] }));
    setSelectedId(l.id);
    if (!desktop) setTab("edit");
    say(`${kind[0].toUpperCase()}${kind.slice(1)} layer added`);
    if (kind === "text") window.setTimeout(() => setEditingId(l.id), 60);
  };
  const removeLayer = (id: string) => {
    const l = design.layers.find((x) => x.id === id);
    if (!l) return;
    if (l.locked) return lockedNotice(l);
    commit((d) => ({ ...d, layers: d.layers.filter((x) => x.id !== id) }));
    setSelectedId(null);
    say(`Deleted ${l.name ?? l.kind} · Ctrl+Z to undo`);
  };
  const duplicate = (id: string) => {
    const i = design.layers.findIndex((x) => x.id === id);
    if (i < 0) return;
    const src = design.layers[i];
    const copy: Layer = { ...src, id: `layer-${++seq}`, name: `${src.name ?? src.kind} copy`, x: src.x + 6, y: src.y + 6, locked: false, props: { ...src.props } };
    commit((d) => ({ ...d, layers: [...d.layers.slice(0, i + 1), copy, ...d.layers.slice(i + 1)] }));
    setSelectedId(copy.id);
    say("Duplicated");
  };
  const nudge = (id: string, dx: number, dy: number) => {
    const l = design.layers.find((x) => x.id === id);
    if (!l) return;
    if (l.locked) return lockedNotice(l);
    commit(mapLayer(id, (x) => ({ ...x, x: x.x + dx, y: x.y + dy })), `nudge-${id}`);
  };
  const arrange = (id: string, dir: 1 | -1) => {
    const i = design.layers.findIndex((x) => x.id === id);
    const j = i + dir;
    if (i < 0 || j < 0 || j >= design.layers.length) return;
    commit((d) => {
      const ls = [...d.layers];
      [ls[i], ls[j]] = [ls[j], ls[i]];
      return { ...d, layers: ls };
    });
  };
  const lockedNotice = (l: Layer) => say(`${l.name ?? "Layer"} is locked · unlock it in Layers`);

  const exportSvg = () => {
    const el = exportRef.current;
    if (!el) return;
    const svg = serializeArtwork(el, design);
    onExport?.(svg, design);
    const ok = downloadText(svg, `${slugify(design.name)}-label.svg`, "image/svg+xml");
    say(ok ? "SVG exported" : "Export ready");
  };
  const copyJson = async () => {
    const json = JSON.stringify(design, null, 2);
    try {
      await navigator.clipboard.writeText(json);
      say("Design JSON copied");
    } catch {
      say("Clipboard not available here");
    }
  };

  const switchDesign = (id: string) => {
    if (id === activeId) return;
    setActiveId(id);
    setPast([]);
    setFuture([]);
    setSelectedId(null);
    setEditingId(null);
    lastKey.current = null;
  };

  /* ------------------------------- shortcuts ------------------------------ */

  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.defaultPrevented) return;
      const root = rootRef.current;
      const t = e.target as HTMLElement | null;
      if (!root || (t && t !== document.body && !root.contains(t))) return;
      const typing = !!t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable);
      const mod = e.metaKey || e.ctrlKey;
      const k = e.key.toLowerCase();
      if (mod && k === "z" && !typing) {
        e.preventDefault();
        if (e.shiftKey) redo();
        else undo();
      } else if (mod && k === "y" && !typing) {
        e.preventDefault();
        redo();
      } else if (mod && k === "d") {
        e.preventDefault();
        if (selectedId) duplicate(selectedId);
      } else if (!typing && !mod && (e.key === "Delete" || e.key === "Backspace") && selectedId) {
        e.preventDefault();
        removeLayer(selectedId);
      } else if (!typing && e.key === "Escape") {
        setSelectedId(null);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  });

  /* --------------------------------- pieces ------------------------------- */

  const canvas = (
    <LabelCanvas
      design={design}
      selectedId={selectedId}
      editingId={editingId}
      pid={`${pid}c`}
      onSelect={(id) => {
        setSelectedId(id);
        if (editingId && id !== editingId) setEditingId(null);
      }}
      onGestureStart={beginGesture}
      onChangeBox={(id, box: Box, props) => live(mapLayer(id, (l) => ({ ...l, ...box, props: props ?? l.props })))}
      onNudge={nudge}
      onDelete={removeLayer}
      onStartEdit={(id) => setEditingId(id)}
      onEndEdit={(id, text) => {
        setEditingId(null);
        const l = design.layers.find((x) => x.id === id);
        if (text !== null && l && text !== l.props.text) commit(mapLayer(id, (x) => ({ ...x, props: { ...x.props, text } })));
      }}
      onLocked={lockedNotice}
      onAddText={() => addLayer("text")}
    />
  );

  const layersPanel = (
    <LayersPanel
      layers={design.layers}
      selectedId={selectedId}
      compact={!desktop}
      onSelect={(id) => {
        setSelectedId(id);
        if (!desktop) setTab("edit");
      }}
      onToggle={(id, key) => {
        const l = design.layers.find((x) => x.id === id);
        commit(mapLayer(id, (x) => ({ ...x, [key]: !x[key] })));
        if (l) say(`${l.name ?? l.kind} ${key === "visible" ? (l.visible ? "hidden" : "shown") : l.locked ? "unlocked" : "locked"}`);
      }}
      onReorder={(ids) => commit((d) => ({ ...d, layers: ids.map((id) => d.layers.find((l) => l.id === id)!).filter(Boolean) }))}
      onAdd={addLayer}
    />
  );

  const inspector = (
    <Inspector
      design={design}
      layer={selected}
      swatches={swatches}
      onLayer={(patch, key) => selected && commit(mapLayer(selected.id, (l) => ({ ...l, ...patch })), key)}
      onProps={(patch, key) => selected && commit(mapLayer(selected.id, (l) => ({ ...l, props: { ...l.props, ...patch } })), key)}
      onDesign={(patch, key) => commit((d) => ({ ...d, ...patch }), key)}
      onDuplicate={() => selected && duplicate(selected.id)}
      onDelete={() => selected && removeLayer(selected.id)}
      onArrange={(dir) => selected && arrange(selected.id, dir)}
      onToggleLock={() => selected && commit(mapLayer(selected.id, (l) => ({ ...l, locked: !l.locked })))}
    />
  );

  const turnControls = <TurnControls turnMv={turnMv} playing={autoTurn} disabled={reduced} onToggle={() => setPlaying((p) => !p)} onScrub={() => setPlaying(false)} />;

  const containerPicker = (
    <Segmented
      label="Container"
      pillId={`${pid}-cont`}
      value={design.container}
      onChange={(c) => commit((d) => ({ ...d, container: c }), "container")}
      options={CONTAINER_ORDER.map((c) => ({ value: c, label: CONTAINERS[c].label, title: `${CONTAINERS[c].label} · ${CONTAINERS[c].size}` }))}
    />
  );

  const backdropPicker = (
    <div role="radiogroup" aria-label="Backdrop" className="flex items-center gap-1">
      {backdrops.map((b) => (
        <button
          key={b}
          type="button"
          role="radio"
          aria-checked={backdrop === b}
          aria-label={`Backdrop ${b}`}
          onClick={() => setBackdrop(b)}
          className={cn("size-5 rounded-full border border-black/10 shadow-sm dark:border-white/20", backdrop === b && "ring-2 ring-[var(--lm-accent)] ring-offset-2 ring-offset-background", focusRing)}
          style={{ background: b }}
        />
      ))}
    </div>
  );

  const stage = (
    <div className="relative h-full min-h-0 overflow-hidden" style={{ background: `radial-gradient(120% 90% at 50% 18%, color-mix(in oklab, ${backdrop} 88%, white), ${backdrop} 55%, color-mix(in oklab, ${backdrop} 78%, black))` }}>
      <div aria-hidden className="absolute inset-x-0 bottom-0 h-[26%] bg-gradient-to-b from-transparent to-black/10" />
      <div className="absolute inset-0 grid place-items-center p-3 pb-16 lg:pb-14">
        <LiveMockup turnMv={turnMv} design={design} reduced={reduced} className="h-full max-h-full w-auto max-w-full drop-shadow-[0_18px_30px_rgba(0,0,0,0.18)]" />
      </div>
    </div>
  );

  const exportActions = (
    <>
      <button type="button" onClick={copyJson} className={cn("inline-flex h-9 items-center gap-1.5 rounded-xl border bg-card px-3 text-[12.5px] font-medium", focusRing)}>
        <Braces className="size-3.5" aria-hidden /> Copy JSON
      </button>
      <button type="button" onClick={exportSvg} className={cn("inline-flex h-9 items-center gap-1.5 rounded-xl bg-[var(--lm-accent)] px-3 text-[12.5px] font-semibold text-white shadow-md shadow-violet-600/25", focusRing)}>
        <Download className="size-3.5" aria-hidden /> Export SVG
      </button>
    </>
  );

  const designPicker = (
    <label className="relative flex h-9 min-w-0 items-center rounded-xl border bg-card pl-3 pr-2 focus-within:ring-2 focus-within:ring-ring">
      <span className="sr-only">Design</span>
      <select value={activeId} onChange={(e) => switchDesign(e.target.value)} className="h-full min-w-0 max-w-[180px] cursor-pointer appearance-none bg-transparent pr-5 text-[12.5px] font-medium outline-none">
        {designs.map((d) => (
          <option key={d.id} value={d.id}>
            {d.name}
          </option>
        ))}
      </select>
      <ArrowDown aria-hidden className="pointer-events-none absolute right-2 size-3 text-muted-foreground" />
    </label>
  );

  const undoRedo = (
    <div className="flex items-center">
      <IconButton label="Undo (Ctrl+Z)" onClick={undo} disabled={!past.length}>
        <Undo2 className="size-4" aria-hidden />
      </IconButton>
      <IconButton label="Redo (Ctrl+Shift+Z)" onClick={redo} disabled={!future.length}>
        <Redo2 className="size-4" aria-hidden />
      </IconButton>
    </div>
  );

  const nudgePad = selected && (
    <div className="flex items-center gap-3 rounded-2xl border bg-card p-2.5">
      <div className="grid grid-cols-3 gap-1" role="group" aria-label="Nudge selected layer">
        <span />
        <NudgeBtn label="Nudge up" onClick={() => nudge(selected.id, 0, bigStep ? -10 : -1)}>
          <ArrowUp className="size-4" aria-hidden />
        </NudgeBtn>
        <span />
        <NudgeBtn label="Nudge left" onClick={() => nudge(selected.id, bigStep ? -10 : -1, 0)}>
          <ArrowLeft className="size-4" aria-hidden />
        </NudgeBtn>
        <NudgeBtn label="Nudge down" onClick={() => nudge(selected.id, 0, bigStep ? 10 : 1)}>
          <ArrowDown className="size-4" aria-hidden />
        </NudgeBtn>
        <NudgeBtn label="Nudge right" onClick={() => nudge(selected.id, bigStep ? 10 : 1, 0)}>
          <ArrowRight className="size-4" aria-hidden />
        </NudgeBtn>
      </div>
      <div className="min-w-0 flex-1 space-y-2">
        <p className="truncate text-[12.5px] font-medium">{selected.name ?? selected.kind}</p>
        <Segmented label="Nudge step" pillId={`${pid}-step`} value={bigStep ? "10" : "1"} onChange={(v) => setBigStep(v === "10")} size="md" options={[{ value: "1", label: "±1" }, { value: "10", label: "±10" }]} />
        <p className="text-[11px] tabular-nums text-muted-foreground">
          x {Math.round(selected.x)} · y {Math.round(selected.y)}
        </p>
      </div>
    </div>
  );

  return (
    <MotionConfig reducedMotion="user">
      <div
        ref={rootRef}
        className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased [--lm-accent:#7c3aed] dark:[--lm-accent:#a78bfa]", className)}
      >
        <header className="flex h-14 shrink-0 items-center gap-2 border-b px-3 sm:px-4">
          <StudioMark />
          <div className="hidden min-w-0 leading-tight sm:block">
            <h1 className="truncate text-sm font-semibold tracking-tight">Label Studio</h1>
            <p className="truncate text-[11px] text-muted-foreground">
              {CONTAINERS[design.container].label} · {CONTAINERS[design.container].size}
            </p>
          </div>
          <div className="ml-1 min-w-0 sm:ml-3">{designPicker}</div>
          <div className="ml-auto flex items-center gap-1.5">
            {undoRedo}
            {desktop && (
              <>
                <span className="mx-1 hidden items-center gap-1 text-[11px] text-muted-foreground xl:flex">
                  <Kbd>⌘Z</Kbd> <Kbd>⌘D</Kbd> <Kbd>Del</Kbd> <Kbd>↑↓←→</Kbd>
                </span>
                {exportActions}
              </>
            )}
          </div>
        </header>

        {desktop ? (
          <div className="grid min-h-0 flex-1 grid-cols-[236px_minmax(0,1fr)_296px]">
            <aside aria-label="Layers" className="min-h-0 border-r bg-muted/20">
              {layersPanel}
            </aside>
            <main className="flex min-h-0 flex-col">
              <section aria-label="Flat artwork" className="shrink-0 border-b bg-[radial-gradient(circle,var(--color-border)_1px,transparent_1px)] [background-size:14px_14px] px-6 py-3">
                <div className="mb-1 flex items-center justify-between text-[11.5px] text-muted-foreground">
                  <span className="font-medium text-foreground">Flat artwork</span>
                  <span className="tabular-nums">
                    {ARTBOARD.w} × {ARTBOARD.h} · drag, double-click text to edit
                  </span>
                </div>
                <div className="mx-auto max-w-[430px]">{canvas}</div>
              </section>
              <section aria-label="Mockup" className="relative min-h-0 flex-1">
                {stage}
                <div className="absolute inset-x-3 bottom-3 flex flex-wrap items-center gap-3 rounded-2xl border bg-background/85 p-2 shadow-lg backdrop-blur-md">
                  {containerPicker}
                  <div className="min-w-[180px] flex-1">{turnControls}</div>
                  {backdropPicker}
                </div>
              </section>
            </main>
            <aside aria-label="Inspector" className="min-h-0 overflow-y-auto overscroll-contain border-l p-3">
              {inspector}
            </aside>
          </div>
        ) : (
          <div className="flex min-h-0 flex-1 flex-col">
            <section aria-label="Mockup" className="relative h-[40%] shrink-0">
              {stage}
              <div className="absolute inset-x-2 bottom-2 space-y-2 rounded-2xl border bg-background/85 p-2 shadow-lg backdrop-blur-md">
                <div className="flex items-center gap-2">
                  <div className="min-w-0 flex-1">{turnControls}</div>
                </div>
              </div>
            </section>
            <div className="shrink-0 border-y px-2 py-2">
              <div role="tablist" aria-label="Tools" className="flex rounded-xl bg-muted p-1">
                {(
                  [
                    ["layers", "Layers", Layers],
                    ["edit", "Edit", SlidersHorizontal],
                    ["export", "Export", Upload],
                  ] as const
                ).map(([t, label, I]) => (
                  <button
                    key={t}
                    type="button"
                    role="tab"
                    aria-selected={tab === t}
                    onClick={() => setTab(t)}
                    className={cn("relative inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg text-[13px] font-medium", tab === t ? "text-foreground" : "text-muted-foreground", focusRing)}
                  >
                    {tab === t && <motion.span layoutId={`${pid}-tab`} className="absolute inset-0 rounded-lg bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
                    <I className="relative size-4" aria-hidden />
                    <span className="relative">{label}</span>
                  </button>
                ))}
              </div>
            </div>
            <div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
              {tab === "layers" && <div className="h-full">{layersPanel}</div>}
              {tab === "edit" && (
                <div className="space-y-3 p-3">
                  <div className="rounded-2xl border bg-muted/30 p-2">{canvas}</div>
                  <div className="overflow-x-auto">{containerPicker}</div>
                  {nudgePad}
                  {inspector}
                </div>
              )}
              {tab === "export" && (
                <div className="space-y-3 p-3">
                  <div className="space-y-2 rounded-2xl border bg-card p-3">
                    <p className="text-[12px] font-semibold">Container</p>
                    {containerPicker}
                    <p className="pt-1 text-[12px] font-semibold">Backdrop</p>
                    {backdropPicker}
                  </div>
                  <div className="space-y-2 rounded-2xl border bg-card p-3">
                    <p className="text-[12px] font-semibold">Export</p>
                    <p className="text-[12px] text-muted-foreground">
                      Flat artwork as a standalone SVG ({ARTBOARD.w} × {ARTBOARD.h} units, 100 mm wide), or the design as JSON.
                    </p>
                    <div className="flex flex-wrap gap-2 [&>button]:h-11 [&>button]:flex-1">{exportActions}</div>
                  </div>
                </div>
              )}
            </div>
          </div>
        )}

        {/* Pure artwork used for export */}
        <div hidden aria-hidden>
          <svg ref={exportRef} viewBox={`0 0 ${ARTBOARD.w} ${ARTBOARD.h}`} width={ARTBOARD.w} height={ARTBOARD.h}>
            <Artwork design={design} pid={`${pid}x`} />
          </svg>
        </div>

        <AnimatePresence>
          {toast && (
            <motion.div
              key={toast.id}
              role="status"
              initial={{ opacity: 0, y: -10, scale: 0.96 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={{ opacity: 0, y: -8 }}
              className="pointer-events-none absolute left-1/2 top-16 z-50 flex max-w-[92%] -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full bg-foreground px-4 py-2 text-[12.5px] font-medium text-background shadow-xl"
            >
              <Check className="size-4 shrink-0" aria-hidden /> <span className="truncate">{toast.text}</span>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </MotionConfig>
  );
}

function NudgeBtn({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) {
  return (
    <button type="button" aria-label={label} onClick={onClick} className={cn("grid size-11 place-items-center rounded-xl border bg-background active:scale-95", focusRing)}>
      {children}
    </button>
  );
}

function useTurn(turnMv: MotionValue<number>) {
  const [turn, setTurn] = React.useState(() => turnMv.get());
  useMotionValueEvent(turnMv, "change", (v) => setTurn(v));
  return turn;
}

function LiveMockup({ turnMv, ...rest }: { turnMv: MotionValue<number>; design: Design; reduced: boolean; className?: string }) {
  const turn = useTurn(turnMv);
  return <Mockup3D turn={turn} {...rest} />;
}

function TurnControls({ turnMv, playing, disabled, onToggle, onScrub }: { turnMv: MotionValue<number>; playing: boolean; disabled: boolean; onToggle: () => void; onScrub: () => void }) {
  const turn = useTurn(turnMv);
  const deg = ((Math.round(turn) % 360) + 360) % 360;
  return (
    <div className="flex items-center gap-2">
      <button
        type="button"
        onClick={onToggle}
        aria-pressed={playing}
        aria-label={playing ? "Pause turning" : "Play turning"}
        disabled={disabled}
        className={cn("grid size-9 shrink-0 place-items-center rounded-full bg-foreground text-background shadow-md disabled:opacity-40", focusRing)}
      >
        {playing ? <Pause className="size-4" aria-hidden /> : <Play className="ml-0.5 size-4" aria-hidden />}
      </button>
      <label className="flex min-w-0 flex-1 items-center gap-2">
        <span className="sr-only">Turn</span>
        <input
          type="range"
          min={0}
          max={359}
          step={1}
          value={deg}
          onChange={(e) => {
            onScrub();
            turnMv.set(Number(e.target.value));
          }}
          aria-valuetext={`${deg} degrees`}
          className="h-2 w-full min-w-0 cursor-pointer appearance-none rounded-full bg-foreground/15 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:bg-[var(--lm-accent)] [&::-webkit-slider-thumb]:shadow"
        />
        <span className="w-9 shrink-0 text-right text-[11.5px] tabular-nums text-muted-foreground">{deg}°</span>
      </label>
    </div>
  );
}

More in Media

View all →