{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "event-scheduler", "type": "registry:ui", "title": "Event Scheduler", "description": "Inline timeline scheduler — tap a time, type a title, press Enter. Spring animations, sorted list, sound feedback.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/event-scheduler.tsx", "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Event Scheduler — inline timeline creation.\n *\n * Today's events at a glance. Tap a time, type a title,\n * press Enter. The event springs into the list. Delete\n * with a tap. No forms, no modals, no dropdowns.\n *\n * The schedule is the interface.\n */\n\n/* ── Types ── */\n\nexport interface SchedulerEvent {\n id: string;\n title: string;\n hour: number;\n minute: number;\n}\n\nexport interface EventSchedulerProps {\n events?: SchedulerEvent[];\n onAdd?: (event: SchedulerEvent) => void;\n onRemove?: (id: string) => void;\n sound?: boolean;\n}\n\n/* ── Constants ── */\n\nconst HOURS = [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18];\nconst MINUTES = [0, 15, 30, 45];\n\n/* ── Helpers ── */\n\nfunction fmtHour(h: number): string {\n if (h === 0 || h === 12) return \"12\";\n return h > 12 ? String(h - 12) : String(h);\n}\n\nfunction fmtPeriod(h: number): string {\n return h >= 12 ? \"pm\" : \"am\";\n}\n\nfunction fmtTime(h: number, m: number): string {\n return `${fmtHour(h)}:${String(m).padStart(2, \"0\")} ${fmtPeriod(h)}`;\n}\n\nfunction sortKey(e: SchedulerEvent): number {\n return e.hour * 60 + e.minute;\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 EventScheduler({\n events: initialEvents = [],\n onAdd,\n onRemove,\n sound = true,\n}: EventSchedulerProps) {\n const [events, setEvents] = useState(\n [...initialEvents].sort((a, b) => sortKey(a) - sortKey(b)),\n );\n const [title, setTitle] = useState(\"\");\n const [selHour, setSelHour] = useState(10);\n const [selMinute, setSelMinute] = useState(0);\n const inputRef = useRef(null);\n const listRef = useRef(null);\n const lastSound = useRef(0);\n\n function tick() {\n if (sound) playTick(lastSound);\n }\n\n function handleAdd() {\n const trimmed = title.trim();\n if (!trimmed) return;\n tick();\n const ev: SchedulerEvent = {\n id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,\n title: trimmed,\n hour: selHour,\n minute: selMinute,\n };\n setEvents([...events, ev].sort((a, b) => sortKey(a) - sortKey(b)));\n onAdd?.(ev);\n setTitle(\"\");\n inputRef.current?.focus();\n /* Scroll the new event into view after render */\n requestAnimationFrame(() => {\n const el = listRef.current?.querySelector(`[data-eid=\"${ev.id}\"]`);\n if (el) {\n el.scrollIntoView({ behavior: \"smooth\", block: \"nearest\" });\n }\n });\n }\n\n function handleRemove(id: string) {\n tick();\n setEvents((prev) => prev.filter((e) => e.id !== id));\n onRemove?.(id);\n }\n\n const today = new Date();\n const dateStr = today.toLocaleDateString(\"en-US\", {\n weekday: \"short\",\n month: \"short\",\n day: \"numeric\",\n });\n\n return (\n \n {/* Header */}\n \n \n Today\n \n \n {dateStr}\n \n \n\n {/* Event list */}\n \n {events.length === 0 && (\n \n Nothing scheduled\n \n )}\n\n \n {events.map((event) => (\n \n {/* Time */}\n \n {fmtTime(event.hour, event.minute)}\n \n\n {/* Title */}\n \n {event.title}\n \n\n {/* Delete */}\n handleRemove(event.id)}\n className=\"text-neutral-200 dark:text-neutral-800 hover:text-red-500 dark:hover:text-red-400\"\n style={{\n background: \"transparent\",\n border: \"none\",\n cursor: \"pointer\",\n padding: \"2px 0\",\n fontSize: 14,\n lineHeight: 1,\n transition: \"color 0.15s\",\n }}\n >\n ×\n \n \n ))}\n \n \n\n {/* Creation zone */}\n \n {/* Input row */}\n \n setTitle(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n handleAdd();\n }\n }}\n placeholder=\"New event…\"\n className=\"text-neutral-600 dark:text-neutral-400 placeholder:text-neutral-400 dark:placeholder:text-neutral-600\"\n style={{\n flex: 1,\n background: \"transparent\",\n border: \"none\",\n outline: \"none\",\n fontSize: 13,\n fontWeight: 400,\n fontFamily: \"inherit\",\n }}\n />\n \n Add\n \n \n\n {/* Selected time hint */}\n \n {fmtTime(selHour, selMinute)}\n \n\n {/* Hours */}\n \n {HOURS.map((h) => (\n {\n tick();\n setSelHour(h);\n }}\n className={cn(\n \"transition-colors duration-150\",\n selHour === h\n ? \"bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300\"\n : \"bg-transparent text-neutral-300 dark:text-neutral-700\",\n )}\n style={{\n padding: \"5px 7px\",\n borderRadius: 6,\n fontSize: 11,\n fontWeight: 500,\n fontVariantNumeric: \"tabular-nums\",\n cursor: \"pointer\",\n border: \"none\",\n flexShrink: 0,\n whiteSpace: \"nowrap\",\n }}\n >\n {fmtHour(h)}\n \n {fmtPeriod(h)}\n \n \n ))}\n \n\n {/* Minutes */}\n \n {MINUTES.map((m) => (\n {\n tick();\n setSelMinute(m);\n }}\n className={cn(\n \"transition-colors duration-150\",\n selMinute === m\n ? \"bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300\"\n : \"bg-transparent text-neutral-300 dark:text-neutral-700\",\n )}\n style={{\n padding: \"5px 8px\",\n borderRadius: 6,\n fontSize: 11,\n fontWeight: 500,\n fontVariantNumeric: \"tabular-nums\",\n cursor: \"pointer\",\n border: \"none\",\n }}\n >\n :{String(m).padStart(2, \"0\")}\n \n ))}\n \n \n \n );\n}\n\nexport default EventScheduler;\n", "type": "registry:ui", "target": "components/ruixen/event-scheduler.tsx" } ] }