{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "three-dwall-calendar", "type": "registry:ui", "title": "Three D Wall Calendar", "description": "Wall-mounted calendar with physical depth — paper stack layers, page-flip transitions, cells that lift on hover with shadow.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/three-dwall-calendar.tsx", "content": "\"use client\";\n\nimport { useRef, useState, useMemo } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * 3D Wall Calendar — a calendar that hangs on a wall.\n *\n * Paper stack beneath the card suggests physical pages.\n * Month transitions flip like tearing off a sheet.\n * Hovered days lift off the surface — shadow deepens,\n * the cell floats. Today is always slightly raised.\n * Select a day: its events appear below with depth.\n *\n * The depth IS the interface.\n */\n\n/* ── Types ── */\n\nexport interface WallEvent {\n id: string;\n title: string;\n date: string; // \"YYYY-MM-DD\"\n}\n\nexport interface ThreeDWallCalendarProps {\n events?: WallEvent[];\n onAdd?: (event: WallEvent) => void;\n onRemove?: (id: string) => void;\n sound?: boolean;\n}\n\n/* ── Constants ── */\n\nconst CELL = 42;\nconst GAP = 2;\nconst STEP = CELL + GAP;\nconst DOW = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"];\n\n/* ── Helpers ── */\n\nfunction pad2(n: number): string {\n return String(n).padStart(2, \"0\");\n}\n\nfunction toKey(y: number, m: number, d: number): string {\n return `${y}-${pad2(m + 1)}-${pad2(d)}`;\n}\n\n/* ── Audio ── */\n\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\n\nfunction audioCtx() {\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 rate = ac.sampleRate;\n const len = Math.floor(rate * 0.003);\n const buf = ac.createBuffer(1, len, rate);\n const ch = buf.getChannelData(0);\n for (let i = 0; i < len; i++) {\n const t = i / len;\n ch[i] = (Math.random() * 2 - 1) * (1 - t) ** 4;\n }\n _buf = buf;\n return buf;\n}\n\nfunction playTick(last: React.MutableRefObject) {\n const now = performance.now();\n if (now - last.current < 80) return;\n last.current = now;\n try {\n const ac = audioCtx();\n const buf = ensureBuf(ac);\n const src = ac.createBufferSource();\n const gain = ac.createGain();\n src.buffer = buf;\n src.playbackRate.value = 1.15;\n gain.gain.value = 0.03;\n src.connect(gain);\n gain.connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Component ── */\n\nexport function ThreeDWallCalendar({\n events: initialEvents = [],\n onAdd,\n onRemove,\n sound = true,\n}: ThreeDWallCalendarProps) {\n const [events, setEvents] = useState(initialEvents);\n const [month, setMonth] = useState(() => new Date().getMonth());\n const [year, setYear] = useState(() => new Date().getFullYear());\n const [selectedDay, setSelectedDay] = useState(null);\n const [hoveredDay, setHoveredDay] = useState(null);\n const [title, setTitle] = useState(\"\");\n const [direction, setDirection] = useState(1);\n const inputRef = useRef(null);\n const lastSound = useRef(0);\n\n function tick() {\n if (sound) playTick(lastSound);\n }\n\n /* ── Calendar math ── */\n\n const daysInMonth = new Date(year, month + 1, 0).getDate();\n const firstOffset = (new Date(year, month, 1).getDay() + 6) % 7;\n const weeks = Math.ceil((firstOffset + daysInMonth) / 7);\n\n const now = new Date();\n const todayDay =\n now.getMonth() === month && now.getFullYear() === year\n ? now.getDate()\n : null;\n\n /* ── Event lookup ── */\n\n const prefix = `${year}-${pad2(month + 1)}`;\n const eventsByDay = useMemo(() => {\n const map = new Map();\n for (const e of events) {\n if (e.date.startsWith(prefix)) {\n const d = parseInt(e.date.slice(8), 10);\n if (d >= 1 && d <= daysInMonth) {\n const arr = map.get(d) || [];\n arr.push(e);\n map.set(d, arr);\n }\n }\n }\n return map;\n }, [events, prefix, daysInMonth]);\n\n const eventDaySet = new Set(eventsByDay.keys());\n\n /* ── Month label ── */\n\n const monthName = new Date(year, month).toLocaleDateString(\"en-US\", {\n month: \"long\",\n });\n\n /* ── Navigation ── */\n\n function goMonth(delta: number) {\n tick();\n setDirection(delta);\n let m = month + delta;\n let y = year;\n if (m < 0) {\n m = 11;\n y--;\n } else if (m > 11) {\n m = 0;\n y++;\n }\n setMonth(m);\n setYear(y);\n setSelectedDay(null);\n setHoveredDay(null);\n setTitle(\"\");\n }\n\n /* ── Selected day ── */\n\n const selEvents =\n selectedDay !== null ? eventsByDay.get(selectedDay) || [] : [];\n const selLabel =\n selectedDay !== null\n ? new Date(year, month, selectedDay).toLocaleDateString(\"en-US\", {\n weekday: \"long\",\n month: \"short\",\n day: \"numeric\",\n })\n : \"\";\n\n /* ── Add / Remove ── */\n\n function handleAdd() {\n const trimmed = title.trim();\n if (!trimmed || selectedDay === null) return;\n tick();\n const ev: WallEvent = {\n id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,\n title: trimmed,\n date: toKey(year, month, selectedDay),\n };\n setEvents((prev) => [...prev, ev]);\n onAdd?.(ev);\n setTitle(\"\");\n inputRef.current?.focus();\n }\n\n function handleRemove(id: string) {\n tick();\n setEvents((prev) => prev.filter((e) => e.id !== id));\n onRemove?.(id);\n }\n\n /* ── Event count ── */\n\n function eventCount(d: number): number {\n return eventsByDay.get(d)?.length || 0;\n }\n\n const gridW = 7 * STEP - GAP;\n\n return (\n
\n {/* ── Paper stack layers ── */}\n \n \n\n {/* ── Main card ── */}\n \n {/* Header */}\n \n goMonth(-1)}\n className={cn(\n \"text-neutral-400 dark:text-neutral-600\",\n \"hover:text-neutral-700 dark:hover:text-neutral-300\",\n \"hover:bg-neutral-100 dark:hover:bg-neutral-800/50\",\n \"transition-colors duration-150\",\n )}\n style={{\n background: \"transparent\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 16,\n lineHeight: 1,\n padding: \"6px 10px\",\n borderRadius: 8,\n }}\n >\n ‹\n \n\n \n \n {monthName}\n \n \n {year}\n \n
\n\n goMonth(1)}\n className={cn(\n \"text-neutral-400 dark:text-neutral-600\",\n \"hover:text-neutral-700 dark:hover:text-neutral-300\",\n \"hover:bg-neutral-100 dark:hover:bg-neutral-800/50\",\n \"transition-colors duration-150\",\n )}\n style={{\n background: \"transparent\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 16,\n lineHeight: 1,\n padding: \"6px 10px\",\n borderRadius: 8,\n }}\n >\n ›\n \n \n\n {/* Perspective wrapper for page-flip effect */}\n
\n \n 0 ? 20 : -20,\n opacity: 0,\n scale: 0.97,\n }}\n animate={{ rotateX: 0, opacity: 1, scale: 1 }}\n exit={{\n rotateX: direction > 0 ? -20 : 20,\n opacity: 0,\n scale: 0.97,\n }}\n transition={{\n type: \"spring\",\n damping: 30,\n stiffness: 300,\n }}\n style={{\n transformOrigin: \"center center\",\n backfaceVisibility: \"hidden\",\n }}\n >\n {/* Day-of-week headers */}\n \n {DOW.map((d) => (\n \n {d}\n
\n ))}\n \n\n {/* Day grid with week dividers */}\n
\n {/* Week dividers — ruled paper lines */}\n {Array.from({ length: weeks - 1 }).map((_, i) => (\n \n ))}\n\n {/* Day cells */}\n \n {/* Leading empties */}\n {Array.from({ length: firstOffset }).map((_, i) => (\n
\n ))}\n\n {/* Days */}\n {Array.from({ length: daysInMonth }).map((_, i) => {\n const d = i + 1;\n const isSel = d === selectedDay;\n const isHov = d === hoveredDay;\n const isToday = d === todayDay;\n const hasEv = eventDaySet.has(d);\n const count = eventCount(d);\n\n /* Shadow depth based on state */\n const shadow = isSel\n ? \"0 6px 20px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.08)\"\n : isToday\n ? \"0 3px 10px rgba(0,0,0,0.08)\"\n : isHov\n ? \"0 4px 14px rgba(0,0,0,0.1)\"\n : \"none\";\n\n return (\n {\n tick();\n setSelectedDay(isSel ? null : d);\n setTitle(\"\");\n }}\n onMouseEnter={() => setHoveredDay(d)}\n onMouseLeave={() => setHoveredDay(null)}\n className={cn(\n \"transition-colors duration-150\",\n isSel\n ? \"bg-neutral-200 dark:bg-neutral-800\"\n : isToday\n ? \"bg-neutral-100 dark:bg-neutral-900\"\n : isHov\n ? \"bg-neutral-100/70 dark:bg-neutral-800/50\"\n : \"bg-transparent\",\n )}\n style={{\n width: CELL,\n height: CELL,\n borderRadius: 10,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n justifyContent: \"center\",\n cursor: \"pointer\",\n border: \"none\",\n boxShadow: shadow,\n transition: \"box-shadow 0.2s ease-out\",\n position: \"relative\",\n padding: 0,\n gap: 1,\n }}\n >\n \n {d}\n \n\n {/* Event bar — marker line, like a physical calendar */}\n {hasEv && (\n \n {Array.from({\n length: Math.min(count, 3),\n }).map((_, idx) => (\n \n ))}\n
\n )}\n \n );\n })}\n
\n \n \n \n \n\n {/* ── Selected day panel ── */}\n \n {selectedDay !== null && (\n \n \n {/* Date label */}\n \n {selLabel}\n \n\n {/* Events */}\n {selEvents.length === 0 && (\n \n No events\n \n )}\n\n 3\n ? \"linear-gradient(to bottom, black 0%, black calc(100% - 16px), transparent)\"\n : undefined,\n WebkitMaskImage:\n selEvents.length > 3\n ? \"linear-gradient(to bottom, black 0%, black calc(100% - 16px), transparent)\"\n : undefined,\n }}\n >\n \n {selEvents.map((ev) => (\n \n {/* Depth accent — shadow line */}\n \n \n {ev.title}\n \n handleRemove(ev.id)}\n className={cn(\n \"text-neutral-200 dark:text-neutral-800\",\n \"hover:text-red-500 dark:hover:text-red-400\",\n \"transition-colors duration-150\",\n )}\n style={{\n background: \"transparent\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 14,\n lineHeight: 1,\n padding: \"2px 0\",\n flexShrink: 0,\n }}\n >\n ×\n \n \n ))}\n \n \n\n {/* Inline creation */}\n 0 ? 8 : 0,\n }}\n >\n setTitle(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n handleAdd();\n }\n }}\n placeholder=\"Add event…\"\n className=\"text-neutral-600 dark:text-neutral-400\"\n style={{\n flex: 1,\n background: \"transparent\",\n border: \"none\",\n outline: \"none\",\n fontSize: 13,\n fontFamily: \"inherit\",\n }}\n />\n \n Add\n \n \n \n \n )}\n \n \n \n );\n}\n\nexport default ThreeDWallCalendar;\n", "type": "registry:ui", "target": "components/ruixen/three-dwall-calendar.tsx" } ] }