{
  "name": "contact-sheet",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Contact Sheet\n * @version: 1.0.0\n * @date: 2026-10-22\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { Check, ZoomIn } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/** How much the loupe magnifies, and its size in px. */\nconst ZOOM = 3;\nconst LOUPE = 136;\nconst DRAW_EASE = [0.6, 0, 0.3, 1] as const;\n\nexport interface ContactFrame {\n  /** A 3:2 image. */\n  src: string;\n  alt: string;\n  /** Where this frame sits in the shot, 0 to 1. Frames from one scene differ a little. */\n  x?: number;\n  y?: number;\n  /** 1 shows the whole image. */\n  zoom?: number;\n}\n\ntype Mark = \"pick\" | \"reject\";\n\nexport interface ContactSheetProps {\n  frames?: ContactFrame[];\n  /** Frames per strip. */\n  perStrip?: number;\n  /** Printed on the film edge before each frame number. */\n  firstFrame?: number;\n  title?: string;\n  defaultMarks?: Record<number, Mark>;\n  onExport?: (picked: number[]) => void;\n  className?: string;\n}\n\nconst unsplash = (id: string) =>\n  `https://images.unsplash.com/photo-${id}?w=900&h=600&fit=crop&auto=format&q=75`;\n\nconst LAKE = unsplash(\"1501785888041-af3ef285b470\");\nconst RIDGE = unsplash(\"1506905925346-21bda4d32df4\");\nconst FOG = unsplash(\"1470071459604-3b5ec3a7fe05\");\nconst PINES = unsplash(\"1441974231531-c6227db76b6e\");\nconst VALLEY = unsplash(\"1469474968028-56623f02e42e\");\nconst PORTRAIT = unsplash(\"1534528741775-53994a69daeb\");\n\nconst DEMO_FRAMES: ContactFrame[] = [\n  { src: LAKE, alt: \"Lake under mountains, wide\", zoom: 1 },\n  {\n    src: LAKE,\n    alt: \"Lake under mountains, closer\",\n    zoom: 1.25,\n    x: 0.4,\n    y: 0.6,\n  },\n  { src: FOG, alt: \"Hills in fog\", zoom: 1 },\n  { src: FOG, alt: \"Hills in fog, tighter\", zoom: 1.3, x: 0.7, y: 0.5 },\n  { src: PORTRAIT, alt: \"Portrait, head and shoulders\", zoom: 1, y: 0.3 },\n  { src: PORTRAIT, alt: \"Portrait, eyes closed in\", zoom: 1.5, x: 0.5, y: 0.3 },\n  { src: RIDGE, alt: \"Ridge line at dusk\", zoom: 1 },\n  { src: RIDGE, alt: \"Ridge line, the far peaks\", zoom: 1.35, x: 0.85, y: 0.4 },\n  { src: PINES, alt: \"Pine forest, looking up\", zoom: 1 },\n  {\n    src: PINES,\n    alt: \"Pine forest, light through trunks\",\n    zoom: 1.3,\n    x: 0.3,\n    y: 0.7,\n  },\n  { src: VALLEY, alt: \"Valley road\", zoom: 1 },\n  { src: VALLEY, alt: \"Valley road, the bend\", zoom: 1.4, x: 0.6, y: 0.75 },\n];\n\n/** A small seeded random, so every frame keeps its own wobble. */\nconst seeded = (seed: number) => {\n  let s = seed * 9301 + 49_297;\n  return () => {\n    s = (s * 9301 + 49_297) % 233_280;\n    return s / 233_280;\n  };\n};\n\n/**\n * A china-marker loop: a lopsided ellipse that runs past where it started,\n * drawn in a 100 x 100 box stretched over the frame.\n */\nfunction loopPath(seed: number) {\n  const rand = seeded(seed + 1);\n  const start = -2.2 + rand() * 0.6;\n  const sweep = Math.PI * 2 + 0.35 + rand() * 0.3;\n  const tilt = (rand() - 0.5) * 0.12;\n  const steps = 56;\n  const points: string[] = [];\n  for (let i = 0; i <= steps; i++) {\n    const t = i / steps;\n    const a = start + sweep * t;\n    // The hand drifts outward as it goes round, so the ends don't meet.\n    const grow = 1 + t * 0.06;\n    const wobble = 1 + Math.sin(a * 3 + seed) * 0.015;\n    const rx = 52 * grow * wobble;\n    const ry = 55 * grow * wobble;\n    const x = 50 + Math.cos(a) * rx - Math.sin(a) * ry * tilt;\n    const y = 50 + Math.sin(a) * ry + Math.cos(a) * rx * tilt;\n    points.push(`${i === 0 ? \"M\" : \"L\"}${x.toFixed(2)} ${y.toFixed(2)}`);\n  }\n  return points.join(\" \");\n}\n\nfunction MarkStroke({\n  mark,\n  index,\n  reduceMotion,\n}: {\n  mark: Mark;\n  index: number;\n  reduceMotion: boolean;\n}) {\n  const draw = (delay: number) => ({\n    initial: { pathLength: 0, opacity: 1 },\n    animate: { pathLength: 1, opacity: 1 },\n    transition: reduceMotion\n      ? { duration: 0 }\n      : {\n          pathLength: { duration: 0.32, ease: DRAW_EASE, delay },\n          opacity: { duration: 0 },\n        },\n  });\n  const paths =\n    mark === \"pick\"\n      ? [loopPath(index)]\n      : [\"M14 16 Q 48 46 86 84\", \"M84 14 Q 54 50 16 86\"];\n  return (\n    <motion.svg\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute -inset-1.5 z-10 h-[calc(100%+12px)] w-[calc(100%+12px)] overflow-visible text-red-600 dark:text-red-500\"\n      exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.15 } }}\n      preserveAspectRatio=\"none\"\n      viewBox=\"0 0 100 100\"\n    >\n      {paths.map((d, i) => (\n        <motion.path\n          d={d}\n          fill=\"none\"\n          key={d}\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={3}\n          vectorEffect=\"non-scaling-stroke\"\n          {...draw(i * 0.2)}\n        />\n      ))}\n    </motion.svg>\n  );\n}\n\n/** Image layer for a frame: the shot, framed and zoomed. */\nconst frameStyle = (frame: ContactFrame): CSSProperties => ({\n  backgroundImage: `url(\"${frame.src}\")`,\n  backgroundSize: `${(frame.zoom ?? 1) * 100}%`,\n  backgroundPosition: `${(frame.x ?? 0.5) * 100}% ${(frame.y ?? 0.5) * 100}%`,\n});\n\ninterface Lens {\n  frame: ContactFrame;\n  /** Loupe center, in the sheet's px. */\n  left: number;\n  top: number;\n  /** Background for the loupe. */\n  size: string;\n  position: string;\n}\n\n/** What the loupe shows over `frame`, for a pointer at (px, py) in it. */\nfunction lensFor(\n  frame: ContactFrame,\n  rect: DOMRect,\n  px: number,\n  py: number\n): Pick<Lens, \"size\" | \"position\"> {\n  const zoom = frame.zoom ?? 1;\n  const w = rect.width * zoom;\n  const h = rect.height * zoom;\n  const ix = px - (rect.width - w) * (frame.x ?? 0.5);\n  const iy = py - (rect.height - h) * (frame.y ?? 0.5);\n  return {\n    size: `${w * ZOOM}px ${h * ZOOM}px`,\n    position: `${LOUPE / 2 - ix * ZOOM}px ${LOUPE / 2 - iy * ZOOM}px`,\n  };\n}\n\nconst nextMark = (mark: Mark | undefined): Mark | undefined => {\n  if (!mark) {\n    return \"pick\";\n  }\n  return mark === \"pick\" ? \"reject\" : undefined;\n};\n\nfunction Frame({\n  frame,\n  index,\n  number,\n  mark,\n  loupe,\n  reduceMotion,\n  onMark,\n  onLens,\n  onLeave,\n}: {\n  frame: ContactFrame;\n  index: number;\n  number: number;\n  mark: Mark | undefined;\n  loupe: boolean;\n  reduceMotion: boolean;\n  onMark: (index: number, mark: Mark | undefined) => void;\n  onLens: (event: PointerEvent<HTMLButtonElement>, frame: ContactFrame) => void;\n  onLeave: () => void;\n}) {\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    const keys: Record<string, Mark | undefined> = {\n      p: mark === \"pick\" ? undefined : \"pick\",\n      x: mark === \"reject\" ? undefined : \"reject\",\n      Backspace: undefined,\n    };\n    if (event.key in keys) {\n      event.preventDefault();\n      onMark(index, keys[event.key]);\n    }\n  };\n  let state = \"\";\n  if (mark) {\n    state = mark === \"pick\" ? \", picked\" : \", struck\";\n  }\n  return (\n    <div className=\"relative\">\n      <button\n        aria-label={`Frame ${number}: ${frame.alt}${state}`}\n        className={cn(\n          \"relative block aspect-[3/2] w-full overflow-hidden bg-zinc-800 outline-none focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950\",\n          loupe && \"cursor-none\"\n        )}\n        onClick={() => {\n          // Lift the loupe so you see the mark go down.\n          onLeave();\n          onMark(index, nextMark(mark));\n        }}\n        onKeyDown={onKeyDown}\n        onPointerLeave={onLeave}\n        onPointerMove={(event) => onLens(event, frame)}\n        type=\"button\"\n      >\n        <span\n          className={cn(\n            \"absolute inset-0 bg-no-repeat transition-opacity duration-200\",\n            mark === \"reject\" && \"opacity-35\"\n          )}\n          style={frameStyle(frame)}\n        />\n      </button>\n      <AnimatePresence initial={false}>\n        {mark && (\n          <MarkStroke\n            index={index}\n            key={mark}\n            mark={mark}\n            reduceMotion={reduceMotion}\n          />\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/** A row of sprocket holes along the film edge. */\nfunction Sprockets() {\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"mx-1 h-[5px]\"\n      style={{\n        backgroundImage:\n          \"repeating-linear-gradient(to right, var(--paper) 0 7px, transparent 7px 14px)\",\n      }}\n    />\n  );\n}\n\n/**\n * A photographer's contact sheet, for picking from a shoot. Click a frame to\n * circle it in china marker, again to strike it out, and hover to look\n * through a loupe at the detail before you decide.\n */\nexport default function ContactSheet({\n  frames = DEMO_FRAMES,\n  perStrip = 4,\n  firstFrame = 12,\n  title = \"Roll 14 · Lisbon\",\n  defaultMarks = { 1: \"pick\", 5: \"reject\", 8: \"pick\" },\n  onExport,\n  className,\n}: ContactSheetProps) {\n  const [marks, setMarks] = useState<Record<number, Mark>>(defaultMarks);\n  const [lens, setLens] = useState<Lens | null>(null);\n  const [loupe, setLoupe] = useState(true);\n  const [exported, setExported] = useState(false);\n  const sheetRef = useRef<HTMLDivElement>(null);\n  const reduceMotion = Boolean(useReducedMotion());\n\n  const strips: number[][] = [];\n  for (let i = 0; i < frames.length; i += perStrip) {\n    strips.push(frames.slice(i, i + perStrip).map((_, k) => i + k));\n  }\n  const picked = Object.keys(marks)\n    .map(Number)\n    .filter((i) => marks[i] === \"pick\")\n    .sort((a, b) => a - b);\n  const struck = Object.values(marks).filter((m) => m === \"reject\").length;\n\n  const mark = (index: number, next: Mark | undefined) => {\n    setExported(false);\n    setMarks((current) => {\n      const copy = { ...current };\n      if (next) {\n        copy[index] = next;\n      } else {\n        Reflect.deleteProperty(copy, index);\n      }\n      return copy;\n    });\n  };\n\n  const onLens = (\n    event: PointerEvent<HTMLButtonElement>,\n    frame: ContactFrame\n  ) => {\n    const sheet = sheetRef.current?.getBoundingClientRect();\n    if (!(loupe && sheet) || event.pointerType !== \"mouse\") {\n      setLens(null);\n      return;\n    }\n    const rect = event.currentTarget.getBoundingClientRect();\n    const px = event.clientX - rect.left;\n    const py = event.clientY - rect.top;\n    setLens({\n      frame,\n      left: event.clientX - sheet.left,\n      top: event.clientY - sheet.top,\n      ...lensFor(frame, rect, px, py),\n    });\n  };\n\n  return (\n    <div\n      className={cn(\n        \"relative w-full max-w-2xl rounded-2xl border border-zinc-200 bg-white p-4 [--paper:#ffffff] sm:p-6 dark:border-zinc-800 dark:bg-zinc-900 dark:[--paper:#18181b]\",\n        className\n      )}\n      ref={sheetRef}\n    >\n      <div className=\"flex items-start justify-between gap-4\">\n        <div className=\"leading-tight\">\n          <p className=\"font-medium text-sm text-zinc-900 dark:text-zinc-100\">\n            {title}\n          </p>\n          <p className=\"mt-0.5 text-xs text-zinc-500 dark:text-zinc-400\">\n            Click to circle a frame, again to strike it.\n          </p>\n        </div>\n        <button\n          aria-pressed={loupe}\n          className={cn(\n            \"flex h-8 shrink-0 items-center gap-1.5 rounded-full px-3 font-medium text-xs outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-zinc-400\",\n            loupe\n              ? \"bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\"\n              : \"bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700\"\n          )}\n          onClick={() => setLoupe((on) => !on)}\n          type=\"button\"\n        >\n          <ZoomIn aria-hidden className=\"size-3.5\" />\n          Loupe\n        </button>\n      </div>\n\n      <div className=\"mt-4 space-y-3 sm:space-y-4\">\n        {strips.map((strip, s) => (\n          <div className=\"rounded-[3px] bg-zinc-950 py-1\" key={strip[0]}>\n            <Sprockets />\n            <div\n              className=\"mt-1 grid gap-1 px-1\"\n              style={{ gridTemplateColumns: `repeat(${perStrip}, 1fr)` }}\n            >\n              {strip.map((i) => (\n                <Frame\n                  frame={frames[i]}\n                  index={i}\n                  key={i}\n                  loupe={loupe}\n                  mark={marks[i]}\n                  number={firstFrame + i}\n                  onLeave={() => setLens(null)}\n                  onLens={onLens}\n                  onMark={mark}\n                  reduceMotion={reduceMotion}\n                />\n              ))}\n            </div>\n            <div\n              aria-hidden=\"true\"\n              className=\"mt-0.5 grid px-1 font-mono text-[8px] text-orange-300/70 leading-3 sm:text-[9px]\"\n              style={{ gridTemplateColumns: `repeat(${perStrip}, 1fr)` }}\n            >\n              {strip.map((i) => (\n                <span className=\"flex justify-between px-1\" key={i}>\n                  <span>{firstFrame + i}</span>\n                  <span>▸{firstFrame + i}A</span>\n                </span>\n              ))}\n            </div>\n            <Sprockets />\n            <span className=\"sr-only\">Strip {s + 1}</span>\n          </div>\n        ))}\n      </div>\n\n      <div className=\"mt-4 flex items-center justify-between gap-3\">\n        <p className=\"text-xs text-zinc-500 tabular-nums dark:text-zinc-400\">\n          {picked.length} picked · {struck} struck\n        </p>\n        <div className=\"flex items-center gap-1.5\">\n          <button\n            className=\"h-8 rounded-full px-3 font-medium text-xs text-zinc-600 outline-none transition-colors duration-150 hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-zinc-400 disabled:opacity-40 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n            disabled={Object.keys(marks).length === 0}\n            onClick={() => {\n              setMarks({});\n              setExported(false);\n            }}\n            type=\"button\"\n          >\n            Clear\n          </button>\n          <button\n            className={cn(\n              \"flex h-8 items-center gap-1.5 rounded-full px-3.5 font-medium text-xs tabular-nums outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 disabled:opacity-40 dark:focus-visible:ring-offset-zinc-900\",\n              exported\n                ? \"bg-emerald-600 text-white dark:bg-emerald-500 dark:text-emerald-950\"\n                : \"bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white\"\n            )}\n            disabled={picked.length === 0}\n            onClick={() => {\n              onExport?.(picked);\n              setExported(true);\n            }}\n            type=\"button\"\n          >\n            {exported && (\n              <Check aria-hidden className=\"size-3.5\" strokeWidth={2.5} />\n            )}\n            {exported ? \"Sent to edit\" : `Export ${picked.length}`}\n          </button>\n        </div>\n      </div>\n\n      <AnimatePresence>\n        {lens && (\n          <motion.div\n            animate={{ opacity: 1 }}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute z-20 rounded-full bg-zinc-900 bg-no-repeat shadow-[0_8px_24px_rgb(0_0_0/0.35),0_0_0_1px_rgb(255_255_255/0.5),0_0_0_4px_rgb(24_24_27/0.9)]\"\n            exit={{ opacity: 0 }}\n            initial={{ opacity: 0 }}\n            style={{\n              width: LOUPE,\n              height: LOUPE,\n              left: lens.left - LOUPE / 2,\n              top: lens.top - LOUPE / 2,\n              backgroundImage: `url(\"${lens.frame.src}\")`,\n              backgroundSize: lens.size,\n              backgroundPosition: lens.position,\n            }}\n            transition={{ duration: reduceMotion ? 0 : 0.12 }}\n          />\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/contact-sheet.tsx",
      "target": "components/kokonutui/contact-sheet.tsx"
    }
  ]
}