Event Calendar
block dashboardMonth-view event calendar with stats strip, drag/shift range select, side rail (selected day or range summary), upcoming list, and per-cell/per-event context menus. events / onEventsChange binds the source of truth; eventTypes config maps each type key to its label/icon/color theme so swapping the visual palette is one prop. Built on the use-month-grid headless hook.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/event-calendar.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/event-calendar.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/event-calendar.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/event-calendar.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/event-calendar
Examples
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
CalendarEvent interface CalendarEvent {
id: string
title: string
/** YYYY-MM-DD */
date: string
/** HH:mm */
start: string
/** HH:mm */
end: string
type: string
description?: string
location?: string
attendees?: string[]
/** Free-form status tag rendered next to the type label in the dialog. */
status?: string
} EventTypeMeta interface EventTypeMeta {
label: string
/** Lucide icon component for the stat tile + chip + dialog header. */
icon: LucideIcon
/** Tailwind classes for the colored chip on event rows + dialog header.
* Shape: `bg-X/10 text-X ring-X/20` is what the default theme uses. */
chip: string
/** Tailwind class for the vertical bar to the left of side-rail rows. */
bar: string
/** Tailwind class for the legend / stat-tile dot. */
dot: string
/** Tailwind class for the soft watermark glow behind stat tiles. */
glow: string
/** Tailwind class for the small text accent on "Next: ..." date labels. */
text: string
/** Tailwind class for the stat-tile hover ring. */
ring: string
} npm dependencies
Files (1)
-
components/blocks/EventCalendar.tsx 46.2 kB
'use client' /** * EventCalendar -- a full month-view event calendar with stats strip, * drag/shift range select, side rail (selected day or range summary), * upcoming list, and per-event/per-cell context menus. * * Configurable surface: * - events / onEventsChange CalendarEvent[] the source of truth * - eventTypes Record<typeKey, EventTypeMeta> visual config per type * - initialDate YYYY-MM-DD which month opens first * - weekStartsOn 0 | 1 Sunday vs Monday * - loading skeleton state during fetch * - title / description / show* toggles per pane * * Render-prop slots: * - headerActions extra controls to the right of search/view/new * - eventDialog full override of the detail dialog body * - emptyDay node shown when a selected day has no events * * Callbacks: * - onEventClick(event) * - onEventCreate(dateKey) requested from "New event" or * the per-cell context menu * - onEventEdit / onEventDuplicate / onEventDelete(event) * - onRangeChange({ lo, hi }) fires when the selection range moves */ import * as React from 'react' import { ChevronLeft, ChevronRight, Plus, Clock, MapPin, Users, Search, CalendarDays, ListFilter, Sparkles, Copy, X, CalendarPlus, ArrowRight, MousePointer2, Pencil, Trash2, Eye, CopyPlus, Video, CheckCircle2, AlertCircle, Plane, type LucideIcon, } from 'lucide-react' import { useMonthGrid, dateFromKey } from '@/lib/use-month-grid' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger, ContextMenuLabel, } from '@/components/ui/context-menu' /** * Single event on the calendar. `type` is a free-form string (e.g. `'meeting'`, * `'task'`, anything) -- the matching key must exist in the `eventTypes` map * passed to <EventCalendar>. Unknown types fall through to a neutral style. */ export interface CalendarEvent { id: string title: string /** YYYY-MM-DD */ date: string /** HH:mm */ start: string /** HH:mm */ end: string type: string description?: string location?: string attendees?: string[] /** Free-form status tag rendered next to the type label in the dialog. */ status?: string } /** * Visual + semantic config per event type. The default theme uses OKLCH * tokens (chart-1..4, success, warning) so consumers get sensible colors * out of the box, but you can override per-type to wire any palette. */ export interface EventTypeMeta { label: string /** Lucide icon component for the stat tile + chip + dialog header. */ icon: LucideIcon /** Tailwind classes for the colored chip on event rows + dialog header. * Shape: `bg-X/10 text-X ring-X/20` is what the default theme uses. */ chip: string /** Tailwind class for the vertical bar to the left of side-rail rows. */ bar: string /** Tailwind class for the legend / stat-tile dot. */ dot: string /** Tailwind class for the soft watermark glow behind stat tiles. */ glow: string /** Tailwind class for the small text accent on "Next: ..." date labels. */ text: string /** Tailwind class for the stat-tile hover ring. */ ring: string } /** * Default event-type theme. Mirrors the boilerplate dashboard but routes * every color through OKLCH tokens (chart-1..4, success, warning) so it * follows light/dark mode and any consumer theme override. * * Override per-type or replace the whole map via `<EventCalendar eventTypes={...} />`. */ export const defaultEventTypes: Record<string, EventTypeMeta> = { meeting: { label: 'Meeting', icon: Video, chip: 'bg-chart-1/10 text-chart-1 ring-chart-1/20', bar: 'bg-chart-1', dot: 'bg-chart-1', glow: 'bg-chart-1/10', text: 'text-chart-1', ring: 'hover:ring-chart-1/30', }, task: { label: 'Task', icon: CheckCircle2, chip: 'bg-success/10 text-success ring-success/20', bar: 'bg-success', dot: 'bg-success', glow: 'bg-success/10', text: 'text-success', ring: 'hover:ring-success/30', }, reminder: { label: 'Reminder', icon: AlertCircle, chip: 'bg-warning/10 text-warning ring-warning/20', bar: 'bg-warning', dot: 'bg-warning', glow: 'bg-warning/10', text: 'text-warning', ring: 'hover:ring-warning/30', }, travel: { label: 'Travel', icon: Plane, chip: 'bg-chart-3/10 text-chart-3 ring-chart-3/20', bar: 'bg-chart-3', dot: 'bg-chart-3', glow: 'bg-chart-3/10', text: 'text-chart-3', ring: 'hover:ring-chart-3/30', }, } export interface EventCalendarProps { /** Source of truth. Pair with onEventsChange. */ events?: CalendarEvent[] onEventsChange?: (value: CalendarEvent[]) => void /** Visual + semantic theme per event type. Falls back to defaultEventTypes. */ eventTypes?: Record<string, EventTypeMeta> /** YYYY-MM-DD -- month to open first. Defaults to today. */ initialDate?: string /** 0 = Sunday (default), 1 = Monday. */ weekStartsOn?: 0 | 1 /** Show the skeleton placeholders instead of cells / rail rows. */ loading?: boolean title?: string description?: string /** Default = true. Stats strip across the top. */ showStats?: boolean /** Default = true. Side rail to the right of the grid. */ showSideRail?: boolean /** Default = true. Search input in the header. */ showSearch?: boolean /** Default = true. Month/Week/Day select. Week/Day not implemented yet * -- the select is purely visual until you wire up the modes. */ showViewSelect?: boolean /** Default = true. Legend strip under the grid. */ showLegend?: boolean /** Default = true. "Up next" pane under the side rail. */ showUpcoming?: boolean onEventClick?: (event: CalendarEvent) => void onEventCreate?: (dateKey: string) => void onEventEdit?: (event: CalendarEvent) => void onEventDuplicate?: (event: CalendarEvent) => void onEventDelete?: (event: CalendarEvent) => void onRangeChange?: (bounds: { lo: string; hi: string }) => void /** Render-prop override for the controls to the right of search/view. */ headerActions?: React.ReactNode /** Render-prop override for the dialog body. */ eventDialog?: (event: CalendarEvent) => React.ReactNode /** Render-prop override for the empty selected-day state. */ emptyDay?: React.ReactNode } export function EventCalendar({ events = [], onEventsChange: _onEventsChange, eventTypes = defaultEventTypes, initialDate, weekStartsOn = 0, loading = false, title = 'Calendar', description = 'Schedule, meetings, and deadlines. Drag or shift-click to select a range.', showStats = true, showSideRail = true, showSearch = true, showViewSelect = true, showLegend = true, showUpcoming = true, onEventClick, onEventCreate, onEventEdit, onEventDuplicate, onEventDelete, onRangeChange, headerActions, eventDialog, emptyDay, }: EventCalendarProps) { const [view, setView] = React.useState<'month' | 'week' | 'day'>('month') const [eventOpen, setEventOpen] = React.useState(false) const [selectedEvent, setSelectedEvent] = React.useState<CalendarEvent | null>(null) const [search, setSearch] = React.useState('') const { todayKey, cursor, monthLabel, gridDays, weekdays, rangeStart, rangeBounds, rangeDayCount, isRange, inRange, prevMonth, nextMonth, goToToday, selectWeekOf, clearRange, onCellMouseDown, onCellMouseEnter, endDrag, } = useMonthGrid({ initialDate, weekStartsOn }) React.useEffect(() => { onRangeChange?.(rangeBounds) // eslint-disable-next-line react-hooks/exhaustive-deps }, [rangeBounds.lo, rangeBounds.hi]) // Fall through to a neutral style for unknown event types so the calendar // doesn't crash when a consumer passes a type that's missing from the map. const neutralMeta: EventTypeMeta = { label: 'Event', icon: CalendarDays, chip: 'bg-muted text-muted-foreground ring-muted-foreground/20', bar: 'bg-muted-foreground', dot: 'bg-muted-foreground', glow: 'bg-muted-foreground/10', text: 'text-muted-foreground', ring: 'hover:ring-muted-foreground/30', } function metaFor(type: string): EventTypeMeta { return eventTypes[type] ?? neutralMeta } const typeKeys = React.useMemo(() => Object.keys(eventTypes), [eventTypes]) const filtered = React.useMemo(() => { const q = search.trim().toLowerCase() if (!q) return events return events.filter( (e) => e.title.toLowerCase().includes(q) || (e.description ?? '').toLowerCase().includes(q) || (e.location ?? '').toLowerCase().includes(q), ) }, [events, search]) const eventsByDate = React.useMemo(() => { const map = new Map<string, CalendarEvent[]>() for (const e of filtered) { if (!map.has(e.date)) map.set(e.date, []) map.get(e.date)!.push(e) } for (const arr of map.values()) arr.sort((a, b) => a.start.localeCompare(b.start)) return map }, [filtered]) const monthCounts = React.useMemo(() => { const y = cursor.getFullYear() const m = cursor.getMonth() const out: Record<string, number> & { total: number } = { total: 0 } for (const k of typeKeys) out[k] = 0 for (const e of events) { const d = new Date(e.date) if (d.getFullYear() === y && d.getMonth() === m) { if (out[e.type] !== undefined) out[e.type]!++ out.total++ } } return out }, [cursor, typeKeys, events]) const typeStats = React.useMemo(() => { const y = cursor.getFullYear() const m = cursor.getMonth() const out: Record<string, { weeks: number[]; next: CalendarEvent | null }> = {} for (const k of typeKeys) out[k] = { weeks: [0, 0, 0, 0, 0, 0], next: null } const firstOffset = new Date(y, m, 1).getDay() for (const e of events) { const d = new Date(e.date) if (d.getFullYear() === y && d.getMonth() === m && out[e.type]) { const week = Math.floor((d.getDate() - 1 + firstOffset) / 7) out[e.type]!.weeks[week]!++ } } for (const t of typeKeys) { const next = events .filter((e) => e.type === t && e.date >= todayKey) .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start))[0] out[t]!.next = next ?? null } return out }, [cursor, typeKeys, events, todayKey]) function fmtNextDate(key: string) { if (key === todayKey) return 'Today' return dateFromKey(key).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } const rangeEvents = React.useMemo(() => { const { lo, hi } = rangeBounds return filtered .filter((e) => e.date >= lo && e.date <= hi) .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start)) }, [filtered, rangeBounds]) const rangeTypeCounts = React.useMemo(() => { const init: Record<string, number> = {} for (const k of typeKeys) init[k] = 0 for (const e of rangeEvents) { if (init[e.type] !== undefined) init[e.type]!++ } return init }, [typeKeys, rangeEvents]) const selectedDayEvents = eventsByDate.get(rangeStart) ?? [] const upcoming = React.useMemo( () => filtered .filter((e) => e.date > todayKey) .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start)) .slice(0, 4), [filtered, todayKey], ) function openEvent(e: CalendarEvent) { setSelectedEvent(e) setEventOpen(true) onEventClick?.(e) } function copyDate(key: string) { if (typeof navigator !== 'undefined' && navigator.clipboard) { navigator.clipboard.writeText(key).catch(() => { /* clipboard may be denied; nothing to do */ }) } } function fmtDayLong(key: string) { return dateFromKey(key).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }) } function fmtDayShort(key: string) { return dateFromKey(key).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } function initials(name: string) { return name .split(' ') .map((n) => n[0]) .join('') .slice(0, 2) } function cellRangeClass(key: string, inMonth: boolean) { const { lo, hi } = rangeBounds if (!inRange(key)) return inMonth ? 'bg-background hover:bg-accent/40' : 'bg-muted/20 hover:bg-muted/30' if (key === lo && key === hi) return 'bg-accent/30 ring-1 ring-inset ring-primary/60' let cls = 'bg-primary/10 hover:bg-primary/15' if (key === lo) cls += ' ring-1 ring-inset ring-primary/60' if (key === hi) cls += ' ring-1 ring-inset ring-primary/60' return cls } return ( <div className="flex flex-col gap-5" onMouseUp={endDrag}> {/* Header */} <header className="flex flex-wrap items-end justify-between gap-4"> <div> <h1 className="text-2xl font-semibold tracking-tight">{title}</h1> {description && <p className="text-muted-foreground mt-1 text-sm">{description}</p>} </div> <div className="flex items-center gap-2"> {showSearch && ( <div className="relative"> <Search className="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search events" className="h-8 w-56 pl-8 text-xs" /> </div> )} {showViewSelect && ( <Select value={view} onValueChange={(v) => setView(v as 'month' | 'week' | 'day')}> <SelectTrigger className="h-8 w-24 text-xs"> <SelectValue /> </SelectTrigger> <SelectContent> <SelectItem value="month">Month</SelectItem> <SelectItem value="week">Week</SelectItem> <SelectItem value="day">Day</SelectItem> </SelectContent> </Select> )} {headerActions ?? ( <Button size="sm" className="gap-1.5" onClick={() => onEventCreate?.(rangeStart)}> <Plus className="size-3.5" /> New event </Button> )} </div> </header> {/* Stats strip */} {showStats && ( <div className={`grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-${Math.min(typeKeys.length, 4)}`}> {typeKeys.map((key) => { const Icon = eventTypes[key]!.icon return ( <button key={key} type="button" className={[ 'group bg-card/40 hover:bg-card relative isolate flex flex-col overflow-hidden rounded-xl border p-4 text-left ring-1 ring-transparent backdrop-blur transition-all ring-inset hover:-translate-y-px hover:shadow-lg', eventTypes[key]!.ring, ].join(' ')} onClick={() => setSearch( eventTypes[key]!.label.toLowerCase() === search.toLowerCase() ? '' : eventTypes[key]!.label.toLowerCase(), ) } > <Icon className={[ 'pointer-events-none absolute -top-4 -right-4 size-24 opacity-[0.06] transition-opacity group-hover:opacity-[0.12]', eventTypes[key]!.text, ].join(' ')} /> <div className={[ 'pointer-events-none absolute -right-12 -bottom-12 size-32 rounded-full blur-2xl', eventTypes[key]!.glow, ].join(' ')} /> <div className="flex items-center justify-between gap-2"> <div className="flex items-center gap-1.5"> <span className={['size-1.5 rounded-full', eventTypes[key]!.dot].join(' ')} /> <p className="text-muted-foreground text-[10px] font-medium tracking-[0.14em] uppercase"> {eventTypes[key]!.label} </p> </div> <div className={[ 'flex size-7 items-center justify-center rounded-md ring-1 ring-inset', eventTypes[key]!.chip, ].join(' ')} > <Icon className="size-3.5" /> </div> </div> <div className="mt-2 flex items-baseline gap-1.5"> <p className="text-3xl leading-none font-semibold tabular-nums">{monthCounts[key] ?? 0}</p> <p className="text-muted-foreground text-[10px] tracking-wider uppercase">this month</p> </div> <div className="mt-3 flex h-6 items-end gap-1"> {typeStats[key]!.weeks.slice(0, 6).map((n, i) => ( <span key={i} className={[ 'flex-1 rounded-sm transition-colors', n > 0 ? eventTypes[key]!.bar : 'bg-muted/40', n > 0 ? 'opacity-70 group-hover:opacity-100' : '', ].join(' ')} style={{ height: n > 0 ? `${Math.min(100, 30 + n * 35)}%` : '20%' }} title={`Week ${i + 1}: ${n} ${eventTypes[key]!.label.toLowerCase()}${n === 1 ? '' : 's'}`} /> ))} </div> <div className="border-border/50 mt-3 min-h-[2.25rem] border-t pt-2"> {typeStats[key]!.next ? ( <> <p className="text-muted-foreground text-[10px] tracking-wider uppercase"> Next <span className={eventTypes[key]!.text}>{fmtNextDate(typeStats[key]!.next!.date)}</span> </p> <p className="mt-0.5 truncate text-xs font-medium">{typeStats[key]!.next!.title}</p> </> ) : ( <> <p className="text-muted-foreground text-[10px] tracking-wider uppercase">No upcoming</p> <p className="text-muted-foreground/70 mt-0.5 text-xs">All clear</p> </> )} </div> </button> ) })} </div> )} {/* Main: calendar + side rail */} <div className={['grid gap-4', showSideRail ? 'lg:grid-cols-[minmax(0,1fr)_320px]' : ''].join(' ')}> {/* Month grid */} <div className="bg-card/40 overflow-hidden rounded-xl border"> <div className="bg-muted/30 flex flex-wrap items-center justify-between gap-3 border-b px-4 py-2.5"> <div className="flex items-center gap-2"> <Button variant="outline" size="icon" className="size-7" onClick={prevMonth}> <ChevronLeft className="size-4" /> </Button> <Button variant="outline" size="icon" className="size-7" onClick={nextMonth}> <ChevronRight className="size-4" /> </Button> <Button variant="ghost" size="sm" className="h-7 text-xs" onClick={goToToday}> Today </Button> <h2 className="ml-2 text-sm font-semibold">{monthLabel}</h2> </div> <div className="text-muted-foreground flex items-center gap-3 text-[11px]"> {isRange && ( <div className="bg-primary/10 text-primary ring-primary/20 flex items-center gap-1.5 rounded-full px-2 py-0.5 ring-1 ring-inset"> <MousePointer2 className="size-3" /> <span> {rangeDayCount} days, {rangeEvents.length} events </span> <button className="hover:text-foreground ml-0.5" onClick={clearRange}> <X className="size-3" /> </button> </div> )} <div className="flex items-center gap-1.5"> <Sparkles className="size-3" /> <span> {monthCounts.total} event{monthCounts.total === 1 ? '' : 's'} this month </span> </div> </div> </div> <div className="bg-muted/10 text-muted-foreground grid grid-cols-7 border-b text-[10px] tracking-wider uppercase"> {weekdays.map((w) => ( <div key={w} className="px-2 py-2 font-medium"> {w} </div> ))} </div> <div className="grid grid-cols-7 select-none"> {loading ? ( Array.from({ length: 42 }).map((_, i) => ( <div key={i} className="h-24 border-r border-b p-1.5 last:border-r-0"> <Skeleton className="h-3 w-6" /> <Skeleton className="mt-2 h-3 w-full" /> </div> )) ) : ( gridDays.map((d, i) => ( <ContextMenu key={d.key}> <ContextMenuTrigger asChild> <button type="button" className={[ 'group focus-visible:ring-ring relative flex h-24 flex-col gap-1 border-r border-b p-1.5 text-left transition-colors focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none', (i + 1) % 7 === 0 ? 'border-r-0' : '', i >= 35 ? 'border-b-0' : '', cellRangeClass(d.key, d.inMonth), !d.inMonth && !inRange(d.key) ? 'text-muted-foreground/60' : '', ].join(' ')} onMouseDown={(e) => onCellMouseDown(d.key, e)} onMouseEnter={() => onCellMouseEnter(d.key)} > <div className="flex items-center justify-between"> <span className={[ 'inline-flex size-5 items-center justify-center rounded-full text-[11px] tabular-nums', d.key === todayKey ? 'bg-primary text-primary-foreground font-semibold' : '', d.key !== todayKey && d.inMonth ? 'text-foreground' : '', !d.inMonth ? 'text-muted-foreground/60' : '', ].join(' ')} > {d.date.getDate()} </span> {(eventsByDate.get(d.key)?.length ?? 0) > 0 && ( <span className="text-muted-foreground text-[9px] tabular-nums"> {eventsByDate.get(d.key)!.length} </span> )} </div> <div className="flex flex-col gap-0.5"> {(eventsByDate.get(d.key) ?? []).slice(0, 2).map((e) => ( <ContextMenu key={e.id}> <ContextMenuTrigger asChild> <span className={[ 'flex cursor-pointer items-center gap-1 truncate rounded px-1 py-0.5 text-[10px] ring-1 ring-inset', metaFor(e.type).chip, ].join(' ')} onMouseDown={(ev) => ev.stopPropagation()} onClick={(ev) => { ev.stopPropagation() openEvent(e) }} onContextMenu={(ev) => ev.stopPropagation()} > <span className="tabular-nums opacity-70">{e.start}</span> <span className="truncate">{e.title}</span> </span> </ContextMenuTrigger> <ContextMenuContent className="w-52"> <ContextMenuLabel className="text-muted-foreground flex items-center gap-1.5 text-[11px]"> <span className={['size-1.5 rounded-full', metaFor(e.type).dot].join(' ')} /> <span className="truncate">{e.title}</span> </ContextMenuLabel> <ContextMenuSeparator /> <ContextMenuItem className="gap-2" onSelect={() => openEvent(e)}> <Eye className="size-3.5" /> View details <ContextMenuShortcut>Enter</ContextMenuShortcut> </ContextMenuItem> <ContextMenuItem className="gap-2" onSelect={() => onEventEdit?.(e)}> <Pencil className="size-3.5" /> Edit </ContextMenuItem> <ContextMenuItem className="gap-2" onSelect={() => onEventDuplicate?.(e)}> <CopyPlus className="size-3.5" /> Duplicate </ContextMenuItem> <ContextMenuItem className="gap-2" onSelect={() => copyDate(e.date)}> <Copy className="size-3.5" /> Copy date </ContextMenuItem> <ContextMenuSeparator /> <ContextMenuItem className="text-destructive focus:text-destructive gap-2" onSelect={() => onEventDelete?.(e)} > <Trash2 className="size-3.5" /> Cancel event </ContextMenuItem> </ContextMenuContent> </ContextMenu> ))} {(eventsByDate.get(d.key)?.length ?? 0) > 2 && ( <span className="text-muted-foreground px-1 text-[10px]"> +{eventsByDate.get(d.key)!.length - 2} more </span> )} </div> </button> </ContextMenuTrigger> <ContextMenuContent className="w-56"> <ContextMenuLabel className="text-muted-foreground text-[11px]">{fmtDayLong(d.key)}</ContextMenuLabel> <ContextMenuSeparator /> <ContextMenuItem className="gap-2" onSelect={() => onEventCreate?.(d.key)}> <CalendarPlus className="size-3.5" /> New event <ContextMenuShortcut>N</ContextMenuShortcut> </ContextMenuItem> <ContextMenuItem className="gap-2" onSelect={() => selectWeekOf(d.key)}> <CalendarDays className="size-3.5" /> Select this week </ContextMenuItem> <ContextMenuSeparator /> <ContextMenuItem className="gap-2" onSelect={() => copyDate(d.key)}> <Copy className="size-3.5" /> Copy date <ContextMenuShortcut className="tabular-nums">{d.key}</ContextMenuShortcut> </ContextMenuItem> <ContextMenuItem className="gap-2" onSelect={goToToday}> <ArrowRight className="size-3.5" /> Go to today </ContextMenuItem> {isRange && ( <ContextMenuItem className="text-destructive focus:text-destructive gap-2" onSelect={clearRange}> <X className="size-3.5" /> Clear range </ContextMenuItem> )} </ContextMenuContent> </ContextMenu> )) )} </div> {showLegend && ( <div className="bg-muted/20 text-muted-foreground flex flex-wrap items-center gap-3 border-t px-4 py-2 text-[10px]"> <ListFilter className="size-3" /> {typeKeys.map((key) => ( <div key={key} className="flex items-center gap-1.5"> <span className={['size-2 rounded-full', eventTypes[key]!.dot].join(' ')} /> {eventTypes[key]!.label} </div> ))} <span className="ml-auto text-[10px]">Tip: drag or shift-click to range. Right-click for actions.</span> </div> )} </div> {/* Side rail */} {showSideRail && ( <aside className="flex flex-col gap-4"> {!isRange ? ( <div className="bg-card/40 overflow-hidden rounded-xl border"> <div className="bg-muted/30 border-b px-4 py-3"> <div className="flex items-center justify-between"> <div> <p className="text-muted-foreground text-[10px] tracking-wider uppercase"> {rangeStart === todayKey ? 'Today' : 'Selected'} </p> <p className="mt-0.5 text-sm font-semibold">{fmtDayLong(rangeStart)}</p> </div> <div className="text-right"> <p className="text-muted-foreground text-[10px] tracking-wider uppercase">Events</p> <p className="text-sm font-semibold tabular-nums">{selectedDayEvents.length}</p> </div> </div> </div> <div className="max-h-[420px] overflow-y-auto p-3"> {loading ? ( Array.from({ length: 3 }).map((_, i) => ( <div key={i} className="mb-2 space-y-2 rounded-lg border p-3"> <Skeleton className="h-3 w-32" /> <Skeleton className="h-2 w-20" /> </div> )) ) : selectedDayEvents.length === 0 ? ( emptyDay ?? ( <div className="flex flex-col items-center justify-center gap-2 py-8 text-center"> <div className="bg-muted flex size-10 items-center justify-center rounded-full"> <CalendarDays className="text-muted-foreground size-5" /> </div> <p className="text-sm font-medium">Nothing scheduled</p> <p className="text-muted-foreground text-xs">Click a date or add a new event.</p> <Button size="sm" variant="outline" className="mt-1 h-7 gap-1.5 text-xs" onClick={() => onEventCreate?.(rangeStart)} > <Plus className="size-3" /> New event </Button> </div> ) ) : ( selectedDayEvents.map((e) => ( <ContextMenu key={e.id}> <ContextMenuTrigger asChild> <div className="group hover:bg-accent/50 relative flex cursor-pointer gap-3 rounded-lg p-2.5 transition-colors" onClick={() => openEvent(e)} > <div className={['w-0.5 shrink-0 rounded-full', metaFor(e.type).bar].join(' ')} /> <div className="min-w-0 flex-1 space-y-1"> <div className="flex items-start justify-between gap-2"> <p className="text-sm leading-tight font-medium">{e.title}</p> <span className={[ 'shrink-0 rounded px-1.5 py-0.5 text-[9px] tracking-wider uppercase ring-1 ring-inset', metaFor(e.type).chip, ].join(' ')} > {metaFor(e.type).label} </span> </div> <div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]"> <span className="inline-flex items-center gap-1"> <Clock className="size-3" /> {e.start}-{e.end} </span> {e.location && ( <span className="inline-flex items-center gap-1"> <MapPin className="size-3" /> {e.location} </span> )} </div> {e.attendees?.length ? ( <div className="flex items-center -space-x-1.5 pt-0.5"> {e.attendees.slice(0, 4).map((a, i) => ( <Avatar key={i} className="border-background size-5 border-2"> <AvatarFallback className="bg-primary/10 text-primary text-[8px]"> {initials(a)} </AvatarFallback> </Avatar> ))} {e.attendees.length > 4 && ( <span className="text-muted-foreground pl-2 text-[10px]"> +{e.attendees.length - 4} </span> )} </div> ) : null} </div> </div> </ContextMenuTrigger> <ContextMenuContent className="w-48"> <ContextMenuLabel className="text-muted-foreground truncate text-[11px]"> {e.title} </ContextMenuLabel> <ContextMenuSeparator /> <ContextMenuItem className="gap-2" onSelect={() => openEvent(e)}> <Eye className="size-3.5" /> View details </ContextMenuItem> <ContextMenuItem className="gap-2" onSelect={() => onEventEdit?.(e)}> <Pencil className="size-3.5" /> Edit </ContextMenuItem> <ContextMenuItem className="gap-2" onSelect={() => onEventDuplicate?.(e)}> <CopyPlus className="size-3.5" /> Duplicate </ContextMenuItem> <ContextMenuSeparator /> <ContextMenuItem className="text-destructive focus:text-destructive gap-2" onSelect={() => onEventDelete?.(e)} > <Trash2 className="size-3.5" /> Cancel event </ContextMenuItem> </ContextMenuContent> </ContextMenu> )) )} </div> </div> ) : ( <div className="bg-card/40 overflow-hidden rounded-xl border"> <div className="from-primary/10 border-b bg-gradient-to-r to-transparent px-4 py-3"> <div className="flex items-center justify-between gap-3"> <div className="min-w-0"> <p className="text-muted-foreground text-[10px] tracking-wider uppercase"> Range, {rangeDayCount} days </p> <p className="mt-0.5 truncate text-sm font-semibold"> {fmtDayShort(rangeBounds.lo)} -> {fmtDayShort(rangeBounds.hi)} </p> </div> <Button variant="ghost" size="icon" className="size-7" onClick={clearRange}> <X className="size-3.5" /> </Button> </div> <div className={`mt-3 grid gap-2 grid-cols-${Math.min(typeKeys.length, 4)}`}> {typeKeys.map((key) => ( <div key={key} className="bg-background/40 rounded-md border px-2 py-1.5"> <div className="text-muted-foreground flex items-center gap-1 text-[9px] tracking-wider uppercase"> <span className={['size-1.5 rounded-full', eventTypes[key]!.dot].join(' ')} /> {eventTypes[key]!.label} </div> <p className="mt-0.5 text-sm font-semibold tabular-nums">{rangeTypeCounts[key] ?? 0}</p> </div> ))} </div> </div> <div className="max-h-[420px] overflow-y-auto"> {rangeEvents.length === 0 ? ( <p className="text-muted-foreground px-4 py-8 text-center text-xs">No events in range.</p> ) : ( rangeEvents.map((e) => ( <div key={e.id} className="hover:bg-accent/40 flex cursor-pointer items-start gap-3 border-b px-3 py-2.5 transition-colors last:border-b-0" onClick={() => openEvent(e)} > <div className="bg-background/60 flex w-10 shrink-0 flex-col items-center rounded-md border px-1 py-1 text-center"> <span className="text-muted-foreground text-[8px] uppercase"> {new Date(e.date).toLocaleDateString('en-US', { month: 'short' })} </span> <span className="text-sm leading-none font-semibold tabular-nums"> {new Date(e.date).getDate()} </span> </div> <div className={['w-0.5 shrink-0 self-stretch rounded-full', metaFor(e.type).bar].join(' ')} /> <div className="min-w-0 flex-1"> <p className="truncate text-xs font-medium">{e.title}</p> <p className="text-muted-foreground mt-0.5 text-[11px] tabular-nums"> {e.start}-{e.end} {e.location && <span> {e.location}</span>} </p> </div> <span className={[ 'shrink-0 rounded px-1.5 py-0.5 text-[9px] tracking-wider uppercase ring-1 ring-inset', metaFor(e.type).chip, ].join(' ')} > {metaFor(e.type).label} </span> </div> )) )} </div> </div> )} {showUpcoming && ( <div className="bg-card/40 overflow-hidden rounded-xl border"> <div className="bg-muted/30 border-b px-4 py-2.5"> <p className="text-sm font-semibold">Up next</p> <p className="text-muted-foreground text-[11px]">After today</p> </div> <div className="divide-y"> {loading ? ( Array.from({ length: 3 }).map((_, i) => ( <div key={i} className="flex items-center gap-3 p-3"> <Skeleton className="size-9 rounded-md" /> <div className="flex-1 space-y-1"> <Skeleton className="h-3 w-32" /> <Skeleton className="h-2 w-20" /> </div> </div> )) ) : upcoming.length === 0 ? ( <p className="text-muted-foreground px-4 py-6 text-center text-xs">Nothing on the horizon.</p> ) : ( upcoming.map((e) => ( <button key={e.id} type="button" className="group hover:bg-accent/40 flex w-full items-center gap-3 p-3 text-left transition-colors" onClick={() => openEvent(e)} > <div className="bg-background flex size-10 shrink-0 flex-col items-center justify-center rounded-md border text-center"> <span className="text-muted-foreground text-[9px] uppercase"> {new Date(e.date).toLocaleDateString('en-US', { month: 'short' })} </span> <span className="text-sm leading-none font-semibold tabular-nums"> {new Date(e.date).getDate()} </span> </div> <div className="min-w-0 flex-1"> <div className="flex items-center gap-1.5"> <span className={['size-1.5 rounded-full', metaFor(e.type).dot].join(' ')} /> <p className="truncate text-xs font-medium">{e.title}</p> </div> <p className="text-muted-foreground mt-0.5 text-[11px] tabular-nums"> {e.start}-{e.end} {e.location && <span> {e.location}</span>} </p> </div> </button> )) )} </div> </div> )} </aside> )} </div> {/* Event Detail Dialog */} <Dialog open={eventOpen} onOpenChange={setEventOpen}> {selectedEvent && ( <DialogContent className="sm:max-w-md"> {eventDialog ? ( eventDialog(selectedEvent) ) : ( <> <DialogHeader> <div className="flex items-center gap-2"> <div className={[ 'flex size-8 items-center justify-center rounded-md ring-1 ring-inset', metaFor(selectedEvent.type).chip, ].join(' ')} > {React.createElement(metaFor(selectedEvent.type).icon, { className: 'size-4' })} </div> <div> <DialogTitle className="text-base">{selectedEvent.title}</DialogTitle> <DialogDescription className="text-xs"> {metaFor(selectedEvent.type).label} {selectedEvent.status && <span> {selectedEvent.status}</span>} </DialogDescription> </div> </div> </DialogHeader> <div className="space-y-3 py-2"> {selectedEvent.description && ( <p className="text-muted-foreground text-sm">{selectedEvent.description}</p> )} <Separator /> <div className="grid gap-2 text-sm"> <div className="flex items-center gap-2"> <Clock className="text-muted-foreground size-4" /> <span> {selectedEvent.date} {selectedEvent.start} - {selectedEvent.end} </span> </div> {selectedEvent.location && ( <div className="flex items-center gap-2"> <MapPin className="text-muted-foreground size-4" /> <span>{selectedEvent.location}</span> </div> )} {selectedEvent.attendees?.length ? ( <div className="flex items-start gap-2"> <Users className="text-muted-foreground mt-0.5 size-4" /> <div className="flex flex-wrap items-center gap-1.5"> {selectedEvent.attendees.map((a) => ( <div key={a} className="bg-muted flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs" > <Avatar className="size-4"> <AvatarFallback className="bg-primary/10 text-primary text-[8px]"> {initials(a)} </AvatarFallback> </Avatar> <span>{a}</span> </div> ))} </div> </div> ) : null} </div> </div> <DialogFooter> <Button variant="outline" size="sm" onClick={() => { onEventEdit?.(selectedEvent) setEventOpen(false) }} > Edit </Button> <Button size="sm" variant="destructive" onClick={() => { onEventDelete?.(selectedEvent) setEventOpen(false) }} > Cancel event </Button> </DialogFooter> </> )} </DialogContent> )} </Dialog> </div> ) }
Raw manifest: https://react.uipkge.dev/r/react/event-calendar.json