{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "notification-inbox-popover", "type": "registry:ui", "title": "Notification Inbox Popover", "description": "Wave-hover inbox with cosine proximity effect, mark all read, and glass morphism.", "dependencies": [ "motion" ], "registryDependencies": [], "files": [ { "path": "registry/ruixenui/notification-inbox-popover.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback, useEffect } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\n\n/**\n * Notification Inbox — Rauno Freiberg craft.\n *\n * Wave-proximity hover: cursor position drives nearby rows to lift,\n * sharpen, and gain weight through cosine falloff — computed per-frame\n * via direct DOM writes, zero re-renders. Hidden scrollbar with\n * edge-mask fading. Staggered mark-all-read cascade.\n * The interaction IS the design.\n */\n\n/* ── Audio — 3ms noise burst singleton ── */\n\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\n\nfunction getAudio(): AudioContext {\n if (!_ctx) {\n _ctx = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n }\n if (_ctx.state === \"suspended\") _ctx.resume();\n return _ctx;\n}\n\nfunction ensureBuf(ac: AudioContext): AudioBuffer {\n if (_buf && _buf.sampleRate === ac.sampleRate) return _buf;\n const len = Math.floor(ac.sampleRate * 0.003);\n const buf = ac.createBuffer(1, len, ac.sampleRate);\n const ch = buf.getChannelData(0);\n for (let i = 0; i < len; i++)\n ch[i] = (Math.random() * 2 - 1) * (1 - i / len) ** 4;\n _buf = buf;\n return buf;\n}\n\nfunction tick() {\n try {\n const ac = getAudio();\n const src = ac.createBufferSource();\n const g = ac.createGain();\n src.buffer = ensureBuf(ac);\n g.gain.value = 0.06;\n src.connect(g).connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Types ── */\n\ninterface InboxItem {\n id: string;\n title: string;\n body: string;\n time: string;\n read?: boolean;\n}\n\ninterface NotificationInboxPopoverProps {\n items?: InboxItem[];\n onMarkRead?: (id: string) => void;\n onMarkAllRead?: () => void;\n sound?: boolean;\n}\n\n/* ── Default data ── */\n\nconst SEED: InboxItem[] = [\n {\n id: \"1\",\n title: \"Deployment complete\",\n body: \"v2.4.1 deployed to production successfully\",\n time: \"2m\",\n read: false,\n },\n {\n id: \"2\",\n title: \"Review requested\",\n body: \"Alex requested your review on PR #482\",\n time: \"8m\",\n read: false,\n },\n {\n id: \"3\",\n title: \"Build passed\",\n body: \"Pipeline #846 completed successfully\",\n time: \"24m\",\n read: false,\n },\n {\n id: \"4\",\n title: \"Comment added\",\n body: \"Sarah commented on your issue #291\",\n time: \"1h\",\n read: true,\n },\n {\n id: \"5\",\n title: \"Invitation\",\n body: \"You were invited to join Team Alpha\",\n time: \"3h\",\n read: true,\n },\n {\n id: \"6\",\n title: \"Weekly report\",\n body: \"Your analytics summary is ready to view\",\n time: \"6h\",\n read: true,\n },\n {\n id: \"7\",\n title: \"Security alert\",\n body: \"New login detected from unknown device\",\n time: \"1d\",\n read: false,\n },\n];\n\n/* ── Scoped CSS ── */\n\nconst STYLE = `\n.ni{\n --ni-glass:rgba(255,255,255,.72);\n --ni-border:rgba(0,0,0,.06);\n --ni-shadow:0 0 0 .5px rgba(0,0,0,.04),0 2px 6px rgba(0,0,0,.04),0 8px 28px rgba(0,0,0,.07);\n --ni-ink:0,0,0;\n --ni-dot:#007AFF;\n --ni-dot-bg:rgba(0,122,255,.1)\n}\n.dark .ni,[data-theme=\"dark\"] .ni{\n --ni-glass:rgba(30,30,32,.82);\n --ni-border:rgba(255,255,255,.06);\n --ni-shadow:0 0 0 .5px rgba(255,255,255,.04),0 2px 6px rgba(0,0,0,.18),0 8px 28px rgba(0,0,0,.28);\n --ni-ink:255,255,255;\n --ni-dot:#0A84FF;\n --ni-dot-bg:rgba(10,132,255,.12)\n}\n.ni-list{scrollbar-width:none;-ms-overflow-style:none}\n.ni-list::-webkit-scrollbar{display:none}\n`.replace(/\\n/g, \"\");\n\n/* ── Proximity math ── */\n\nconst RADIUS = 80;\nconst LIFT = 3.5;\nconst SCALE = 0.01;\n\nfunction cosine(cy: number, ry: number): number {\n const d = Math.abs(cy - ry);\n return d > RADIUS ? 0 : (1 + Math.cos((d / RADIUS) * Math.PI)) / 2;\n}\n\n/* ── Component ── */\n\nexport function NotificationInboxPopover({\n items: ext,\n onMarkRead,\n onMarkAllRead,\n sound = true,\n}: NotificationInboxPopoverProps) {\n const [internal, setInternal] = useState(() => ext ?? SEED);\n const items = ext ?? internal;\n const unread = items.filter((n) => !n.read).length;\n\n const listRef = useRef(null);\n const rows = useRef>(new Map());\n const raf = useRef(0);\n const cy = useRef(null);\n const hovering = useRef(false);\n\n /* ── Per-frame proximity — direct DOM, zero re-renders ── */\n\n const paint = useCallback(() => {\n const list = listRef.current;\n if (!list) return;\n\n const lr = list.getBoundingClientRect();\n const cursor = cy.current;\n\n rows.current.forEach((el) => {\n const rr = el.getBoundingClientRect();\n const center = rr.top - lr.top + rr.height / 2;\n const p = cursor !== null ? cosine(cursor, center) : 0;\n\n /* transform + shadow */\n el.style.transform =\n p > 0.01 ? `translateY(${-LIFT * p}px) scale(${1 + SCALE * p})` : \"\";\n el.style.boxShadow =\n p > 0.06\n ? `0 ${(p * 6).toFixed(1)}px ${(p * 16).toFixed(0)}px rgba(var(--ni-ink),${(p * 0.04).toFixed(3)})`\n : \"none\";\n\n /* subtle background tint at proximity peak */\n el.style.background =\n p > 0.1 ? `rgba(var(--ni-ink),${(p * 0.022).toFixed(3)})` : \"\";\n\n /* title weight + opacity */\n const isRead = el.dataset.read === \"1\";\n const t = el.querySelector(\"[data-t]\") as HTMLElement | null;\n if (t) {\n t.style.fontWeight = String(Math.round((isRead ? 400 : 480) + p * 80));\n t.style.color = `rgba(var(--ni-ink),${(isRead ? 0.34 + p * 0.24 : 0.6 + p * 0.28).toFixed(2)})`;\n }\n\n /* body opacity */\n const b = el.querySelector(\"[data-b]\") as HTMLElement | null;\n if (b) {\n b.style.color = `rgba(var(--ni-ink),${(0.32 + p * 0.13).toFixed(2)})`;\n }\n });\n\n if (hovering.current) raf.current = requestAnimationFrame(paint);\n }, []);\n\n /* ── Mouse handlers — ref writes only, no setState ── */\n\n const onMove = useCallback(\n (e: React.MouseEvent) => {\n const lr = listRef.current?.getBoundingClientRect();\n if (!lr) return;\n cy.current = e.clientY - lr.top;\n if (!hovering.current) {\n hovering.current = true;\n raf.current = requestAnimationFrame(paint);\n }\n },\n [paint],\n );\n\n const onLeave = useCallback(() => {\n hovering.current = false;\n cy.current = null;\n cancelAnimationFrame(raf.current);\n /* reset every row to neutral */\n rows.current.forEach((el) => {\n el.style.transform = \"\";\n el.style.boxShadow = \"none\";\n el.style.background = \"\";\n const t = el.querySelector(\"[data-t]\") as HTMLElement | null;\n const b = el.querySelector(\"[data-b]\") as HTMLElement | null;\n if (t) {\n t.style.fontWeight = \"\";\n t.style.color = \"\";\n }\n if (b) b.style.color = \"\";\n });\n }, []);\n\n /* cleanup on unmount */\n useEffect(() => () => cancelAnimationFrame(raf.current), []);\n\n /* ── Actions ── */\n\n const markRead = useCallback(\n (id: string) => {\n if (sound) tick();\n if (ext) {\n onMarkRead?.(id);\n } else {\n setInternal((p) =>\n p.map((n) => (n.id === id ? { ...n, read: true } : n)),\n );\n onMarkRead?.(id);\n }\n },\n [ext, onMarkRead, sound],\n );\n\n const markAllRead = useCallback(() => {\n if (sound) tick();\n if (ext) {\n onMarkAllRead?.();\n } else {\n const ids = items.filter((n) => !n.read).map((n) => n.id);\n ids.forEach((id, i) => {\n setTimeout(() => {\n setInternal((p) =>\n p.map((n) => (n.id === id ? { ...n, read: true } : n)),\n );\n if (sound && i > 0) tick();\n }, i * 40);\n });\n onMarkAllRead?.();\n }\n }, [ext, items, onMarkAllRead, sound]);\n\n return (\n \n