Event Calendar Dashboard
blockproductivityMonth-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. bind:events holds 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.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/event-calendar-dashboard.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/event-calendar-dashboard.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/event-calendar-dashboard.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/event-calendar-dashboard.jsonManifest:
https://uipkge.dev/r/svelte/event-calendar-dashboard.jsonInstalls to:src/lib/components/blocks/event-calendar-dashboard/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
CalendarEventinterface 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
}EventTypeMetainterface EventTypeMeta {
label: string
/** Lucide icon component for the stat tile + chip + dialog header. */
icon: Component
/** 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 installed (3)
src/lib/components/blocks/event-calendar-dashboard/EventCalendar.svelte39.3 kB
<!-- 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 CalendarEvent[] the source of truth (bindable) - 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 Snippets: - headerActions extra controls to the right of search/view/new - eventDialog full override of the detail dialog body ({ event }) - empty text 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 --> <script lang="ts" module> import type { Snippet } from 'svelte' import type { CalendarEvent, EventTypeMeta } from './types' export interface EventCalendarProps { /** Source of truth. Pair with bind:events. */ events?: CalendarEvent[] /** 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 headerActions?: Snippet eventDialog?: Snippet<[{ event: CalendarEvent }]> empty?: Snippet 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 } </script> <script lang="ts"> import { onDestroy, onMount } from 'svelte' import { ChevronLeft, ChevronRight, Plus, Clock, MapPin, Users, Search, CalendarDays, ListFilter, Copy, X, CalendarPlus, ArrowRight, MousePointer2, Pencil, Trash2, Eye, CopyPlus, } from '@lucide/svelte' import { cn } from '$lib/utils' import { useMonthGrid, dateFromKey } from '$lib/composables/useMonthGrid.svelte' import { Button } from '$lib/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '$lib/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '$lib/components/ui/select' import { Avatar, AvatarFallback } from '$lib/components/ui/avatar' import { Separator } from '$lib/components/ui/separator' import { Skeleton } from '$lib/components/ui/skeleton' import { Input } from '$lib/components/ui/input' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger, ContextMenuLabel, } from '$lib/components/ui/context-menu' import { defaultEventTypes } from './defaults' let { events = $bindable([]), 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, headerActions, eventDialog, empty, onEventClick, onEventCreate, onEventEdit, onEventDuplicate, onEventDelete, onRangeChange, }: EventCalendarProps = $props() let view = $state<'month' | 'week' | 'day'>('month') let eventOpen = $state(false) let selectedEvent = $state<CalendarEvent | null>(null) let search = $state('') // svelte-ignore state_referenced_locally -- intentionally initial-only; the grid owns the cursor after mount. const grid = useMonthGrid({ initialDate, weekStartsOn }) const todayKey = grid.todayKey // Matches Vue `watch` (no immediate): skip the first run so consumers only // hear about actual range moves. let rangeInit = false $effect(() => { const b = grid.rangeBounds if (!rangeInit) { rangeInit = true return } onRangeChange?.(b) }) // 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 = $derived(Object.keys(eventTypes)) const filtered = $derived.by(() => { 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), ) }) const eventsByDate = $derived.by(() => { 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 }) const monthCounts = $derived.by(() => { const y = grid.cursor.getFullYear() const m = grid.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 }) const typeStats = $derived.by(() => { const y = grid.cursor.getFullYear() const m = grid.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] ?? null out[t]!.next = next } return out }) function fmtNextDate(key: string) { if (key === todayKey) return 'Today' return dateFromKey(key).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } const rangeEvents = $derived.by(() => { const { lo, hi } = grid.rangeBounds return filtered .filter((e) => e.date >= lo && e.date <= hi) .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start)) }) const rangeTypeCounts = $derived.by(() => { 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 }) const selectedDayEvents = $derived(eventsByDate.get(grid.rangeStart) ?? []) const upcoming = $derived( filtered .filter((e) => e.date > todayKey) .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start)) .slice(0, 4), ) function openEvent(e: CalendarEvent) { selectedEvent = e eventOpen = 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 } = grid.rangeBounds if (!grid.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 } function selectCategoryFilter(key: string) { const label = eventTypes[key]?.label.toLowerCase() || '' search = label === search.toLowerCase() ? '' : label } function handleEditSelected() { if (selectedEvent) onEventEdit?.(selectedEvent) eventOpen = false } function handleDeleteSelected() { if (selectedEvent) onEventDelete?.(selectedEvent) eventOpen = false } // Window listeners so the drag also ends when the user releases the mouse // outside the grid surface. onMount(() => { if (typeof window !== 'undefined') { window.addEventListener('mouseup', grid.endDrag) window.addEventListener('mouseleave', grid.endDrag) } }) onDestroy(() => { if (typeof window !== 'undefined') { window.removeEventListener('mouseup', grid.endDrag) window.removeEventListener('mouseleave', grid.endDrag) } }) </script> <div data-slot="event-calendar" class="flex flex-col gap-5" onmouseup={grid.endDrag}> <!-- Header --> <header class="flex flex-wrap items-end justify-between gap-4"> <div> <h1 class="text-2xl font-semibold tracking-tight">{title}</h1> {#if description} <p class="text-muted-foreground mt-1 text-sm">{description}</p> {/if} </div> <div class="flex items-center gap-2"> {#if showSearch} <div class="relative"> <Search class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <Input bind:value={search} placeholder="Search events" class="h-8 w-56 pl-8 text-xs" /> </div> {/if} {#if showViewSelect} <Select value={view} onValueChange={(v) => (view = v as typeof view)}> <SelectTrigger class="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> {/if} {#if headerActions} {@render headerActions()} {:else} <Button size="sm" class="gap-1.5" onclick={() => onEventCreate?.(grid.rangeStart)}> <Plus class="size-3.5" /> New event </Button> {/if} </div> </header> <!-- Stats strip --> {#if showStats} <div class={`grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-${Math.min(typeKeys.length, 4)}`}> {#each typeKeys as key (key)} {@const meta = eventTypes[key]!} {@const Icon = meta.icon} <button type="button" class={cn( 'group bg-card hover:bg-muted/40 relative isolate flex flex-col overflow-hidden rounded-xl border p-4 text-left ring-1 ring-transparent transition-[colors,box-shadow,transform] duration-150 ring-inset hover:shadow-xs active:scale-[0.98]', meta.ring, )} onclick={() => selectCategoryFilter(key)} > <Icon class={cn( 'pointer-events-none absolute -top-4 -right-4 size-24 opacity-[0.06] transition-opacity group-hover:opacity-[0.12]', meta.text, )} /> <div class="flex items-center justify-between gap-2"> <div class="flex items-center gap-1.5"> <span class={cn('size-1.5 rounded-full', meta.dot)}></span> <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase"> {meta.label} </p> </div> <div class={cn('flex size-7 items-center justify-center rounded-md ring-1 ring-inset', meta.chip)}> <Icon class="size-3.5" /> </div> </div> <div class="mt-2 flex items-baseline gap-1.5"> <p class="text-3xl leading-none font-semibold tabular-nums">{monthCounts[key] ?? 0}</p> <p class="text-muted-foreground text-xs tracking-wider uppercase">this month</p> </div> <div class="mt-3 flex h-6 items-end gap-1"> {#each typeStats[key]!.weeks.slice(0, 6) as n, i (i)} <span class={cn( 'flex-1 rounded-sm transition-colors', n > 0 ? meta.bar : 'bg-muted/40', n > 0 && 'opacity-70 group-hover:opacity-100', )} style:height={n > 0 ? `${Math.min(100, 30 + n * 35)}%` : '20%'} title={`Week ${i + 1}: ${n} ${meta.label.toLowerCase()}${n === 1 ? '' : 's'}`} ></span> {/each} </div> <div class="border-border/50 mt-3 min-h-[2.25rem] border-t pt-2"> {#if typeStats[key]!.next} <p class="text-muted-foreground text-xs tracking-wider uppercase"> Next <span class={meta.text}>{fmtNextDate(typeStats[key]!.next!.date)}</span> </p> <p class="mt-0.5 truncate text-xs font-medium">{typeStats[key]!.next!.title}</p> {:else} <p class="text-muted-foreground text-xs tracking-wider uppercase">No upcoming</p> <p class="text-muted-foreground/70 mt-0.5 text-xs">All clear</p> {/if} </div> </button> {/each} </div> {/if} <!-- Main: calendar + side rail --> <div class={cn('grid gap-4', showSideRail && 'lg:grid-cols-[minmax(0,1fr)_320px]')}> <!-- Month grid --> <div class="bg-card/40 overflow-hidden rounded-xl border"> <div class="bg-muted/30 flex flex-wrap items-center justify-between gap-3 border-b px-4 py-2.5"> <div class="flex items-center gap-2"> <Button aria-label="Previous month" variant="outline" size="icon" class="size-7" onclick={() => grid.prevMonth()} ><ChevronLeft class="size-4" /></Button > <Button aria-label="Next month" variant="outline" size="icon" class="size-7" onclick={() => grid.nextMonth()} ><ChevronRight class="size-4" /></Button > <Button variant="ghost" size="sm" class="h-7 text-xs" onclick={() => grid.goToToday()}>Today</Button> <h2 class="ml-2 text-sm font-semibold">{grid.monthLabel}</h2> </div> <div class="text-muted-foreground flex items-center gap-3 text-xs"> {#if grid.isRange} <div class="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 class="size-3" /> <span>{grid.rangeDayCount} days, {rangeEvents.length} events</span> <button class="hover:text-foreground ml-0.5" aria-label="Clear date range" onclick={() => grid.clearRange()}> <X class="size-3" /> </button> </div> {/if} <div class="flex items-center gap-1.5"> <CalendarDays class="size-3" /> <span>{monthCounts.total} event{monthCounts.total === 1 ? '' : 's'} this month</span> </div> </div> </div> <div class="bg-muted/10 text-muted-foreground grid grid-cols-7 border-b text-xs tracking-wider uppercase"> {#each grid.weekdays as w (w)} <div class="px-2 py-2 font-medium">{w}</div> {/each} </div> <div class="grid grid-cols-7 select-none"> {#if loading} {#each Array.from({ length: 42 }, (_, i) => i) as i (i)} <div class="h-24 border-r border-b p-1.5 last:border-r-0"> <Skeleton class="h-3 w-6" /> <Skeleton class="mt-2 h-3 w-full" /> </div> {/each} {:else} {#each grid.gridDays as d, i (d.key)} <ContextMenu> <!-- display:contents keeps the button as the effective grid item. --> <ContextMenuTrigger class="contents"> <button type="button" class={cn( '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 && !grid.inRange(d.key) && 'text-muted-foreground/60', )} onmousedown={(e) => grid.onCellMouseDown(d.key, e)} onmouseenter={() => grid.onCellMouseEnter(d.key)} > <div class="flex items-center justify-between"> <span class={cn( 'inline-flex size-5 items-center justify-center rounded-full text-xs 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', )}>{d.date.getDate()}</span > {#if (eventsByDate.get(d.key)?.length ?? 0) > 0} <span class="text-muted-foreground text-xs tabular-nums"> {eventsByDate.get(d.key)!.length} </span> {/if} </div> <div class="flex flex-col gap-0.5"> {#each (eventsByDate.get(d.key) ?? []).slice(0, 2) as e (e.id)} <ContextMenu> <ContextMenuTrigger class="contents"> <span class={cn( 'flex cursor-pointer items-center gap-1 truncate rounded px-1 py-0.5 text-xs ring-1 ring-inset', metaFor(e.type).chip, )} onmousedown={(ev) => ev.stopPropagation()} onclick={(ev) => { ev.stopPropagation() openEvent(e) }} > <!-- No contextmenu stopPropagation here: the inner trigger must receive the bubbled event to open. It calls preventDefault, which tells the outer cell trigger to stand down. --> <span class="tabular-nums opacity-70">{e.start}</span> <span class="truncate">{e.title}</span> </span> </ContextMenuTrigger> <ContextMenuContent class="w-52"> <ContextMenuLabel class="text-muted-foreground flex items-center gap-1.5 text-xs"> <span class={cn('size-1.5 rounded-full', metaFor(e.type).dot)}></span> <span class="truncate">{e.title}</span> </ContextMenuLabel> <ContextMenuSeparator /> <ContextMenuItem class="gap-2" onSelect={() => openEvent(e)}> <Eye class="size-3.5" /> View details <ContextMenuShortcut>Enter</ContextMenuShortcut> </ContextMenuItem> <ContextMenuItem class="gap-2" onSelect={() => onEventEdit?.(e)} ><Pencil class="size-3.5" /> Edit</ContextMenuItem > <ContextMenuItem class="gap-2" onSelect={() => onEventDuplicate?.(e)} ><CopyPlus class="size-3.5" /> Duplicate</ContextMenuItem > <ContextMenuItem class="gap-2" onSelect={() => copyDate(e.date)}> <Copy class="size-3.5" /> Copy date </ContextMenuItem> <ContextMenuSeparator /> <ContextMenuItem class="text-destructive focus:text-destructive gap-2" onSelect={() => onEventDelete?.(e)} > <Trash2 class="size-3.5" /> Cancel event </ContextMenuItem> </ContextMenuContent> </ContextMenu> {/each} {#if (eventsByDate.get(d.key)?.length ?? 0) > 2} <span class="text-muted-foreground px-1 text-xs"> +{eventsByDate.get(d.key)!.length - 2} more </span> {/if} </div> </button> </ContextMenuTrigger> <ContextMenuContent class="w-56"> <ContextMenuLabel class="text-muted-foreground text-xs">{fmtDayLong(d.key)}</ContextMenuLabel> <ContextMenuSeparator /> <ContextMenuItem class="gap-2" onSelect={() => onEventCreate?.(d.key)}> <CalendarPlus class="size-3.5" /> New event <ContextMenuShortcut>N</ContextMenuShortcut> </ContextMenuItem> <ContextMenuItem class="gap-2" onSelect={() => grid.selectWeekOf(d.key)}> <CalendarDays class="size-3.5" /> Select this week </ContextMenuItem> <ContextMenuSeparator /> <ContextMenuItem class="gap-2" onSelect={() => copyDate(d.key)}> <Copy class="size-3.5" /> Copy date <ContextMenuShortcut class="tabular-nums">{d.key}</ContextMenuShortcut> </ContextMenuItem> <ContextMenuItem class="gap-2" onSelect={() => grid.goToToday()}> <ArrowRight class="size-3.5" /> Go to today </ContextMenuItem> {#if grid.isRange} <ContextMenuItem class="text-destructive focus:text-destructive gap-2" onSelect={() => grid.clearRange()} > <X class="size-3.5" /> Clear range </ContextMenuItem> {/if} </ContextMenuContent> </ContextMenu> {/each} {/if} </div> {#if showLegend} <div class="bg-muted/20 text-muted-foreground flex flex-wrap items-center gap-3 border-t px-4 py-2 text-xs" > <ListFilter class="size-3" /> {#each typeKeys as key (key)} <div class="flex items-center gap-1.5"> <span class={cn('size-2 rounded-full', eventTypes[key]!.dot)}></span> {eventTypes[key]!.label} </div> {/each} <span class="ml-auto text-xs">Tip: drag or shift-click to range. Right-click for actions.</span> </div> {/if} </div> <!-- Side rail --> {#if showSideRail} <aside class="flex flex-col gap-4"> {#if !grid.isRange} <div class="bg-card/40 overflow-hidden rounded-xl border"> <div class="bg-muted/30 border-b px-4 py-3"> <div class="flex items-center justify-between"> <div> <p class="text-muted-foreground text-xs tracking-wider uppercase"> {grid.rangeStart === todayKey ? 'Today' : 'Selected'} </p> <p class="mt-0.5 text-sm font-semibold">{fmtDayLong(grid.rangeStart)}</p> </div> <div class="text-right"> <p class="text-muted-foreground text-xs tracking-wider uppercase">Events</p> <p class="text-sm font-semibold tabular-nums">{selectedDayEvents.length}</p> </div> </div> </div> <div class="max-h-[26.25rem] overflow-y-auto p-3"> {#if loading} {#each [0, 1, 2] as i (i)} <div class="mb-2 space-y-2 rounded-lg border p-3"> <Skeleton class="h-3 w-32" /> <Skeleton class="h-2 w-20" /> </div> {/each} {:else if selectedDayEvents.length === 0} {#if empty} {@render empty()} {:else} <div class="flex flex-col items-center justify-center gap-2 py-8 text-center"> <div class="bg-muted flex size-10 items-center justify-center rounded-full"> <CalendarDays class="text-muted-foreground size-5" /> </div> <p class="text-sm font-medium">Nothing scheduled</p> <p class="text-muted-foreground text-xs">Click a date or add a new event.</p> <Button size="sm" variant="outline" class="mt-1 h-7 gap-1.5 text-xs" onclick={() => onEventCreate?.(grid.rangeStart)} > <Plus class="size-3" /> New event </Button> </div> {/if} {:else} {#each selectedDayEvents as e (e.id)} <ContextMenu> <ContextMenuTrigger class="contents"> <div class="group hover:bg-accent/50 relative flex cursor-pointer gap-3 rounded-lg p-2.5 transition-colors" onclick={() => openEvent(e)} > <div class={cn('w-0.5 shrink-0 rounded-full', metaFor(e.type).bar)}></div> <div class="min-w-0 flex-1 space-y-1"> <div class="flex items-start justify-between gap-2"> <p class="text-sm leading-tight font-medium">{e.title}</p> <span class={cn( 'shrink-0 rounded px-1.5 py-0.5 text-xs tracking-wider uppercase ring-1 ring-inset', metaFor(e.type).chip, )} > {metaFor(e.type).label} </span> </div> <div class="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs"> <span class="inline-flex items-center gap-1" ><Clock class="size-3" />{e.start}-{e.end}</span > {#if e.location} <span class="inline-flex items-center gap-1" ><MapPin class="size-3" />{e.location}</span > {/if} </div> {#if e.attendees?.length} <div class="flex items-center -space-x-1.5 pt-0.5"> {#each e.attendees.slice(0, 4) as a, i (i)} <Avatar class="border-background size-5 border-2"> <AvatarFallback class="bg-primary/10 text-primary text-xs" >{initials(a)}</AvatarFallback > </Avatar> {/each} {#if e.attendees.length > 4} <span class="text-muted-foreground pl-2 text-xs">+{e.attendees.length - 4}</span> {/if} </div> {/if} </div> </div> </ContextMenuTrigger> <ContextMenuContent class="w-48"> <ContextMenuLabel class="text-muted-foreground truncate text-xs">{e.title}</ContextMenuLabel> <ContextMenuSeparator /> <ContextMenuItem class="gap-2" onSelect={() => openEvent(e)} ><Eye class="size-3.5" /> View details</ContextMenuItem > <ContextMenuItem class="gap-2" onSelect={() => onEventEdit?.(e)} ><Pencil class="size-3.5" /> Edit</ContextMenuItem > <ContextMenuItem class="gap-2" onSelect={() => onEventDuplicate?.(e)} ><CopyPlus class="size-3.5" /> Duplicate</ContextMenuItem > <ContextMenuSeparator /> <ContextMenuItem class="text-destructive focus:text-destructive gap-2" onSelect={() => onEventDelete?.(e)}><Trash2 class="size-3.5" /> Cancel event</ContextMenuItem > </ContextMenuContent> </ContextMenu> {/each} {/if} </div> </div> {:else} <div class="bg-card/40 overflow-hidden rounded-xl border"> <div class="from-primary/10 border-b bg-gradient-to-r to-transparent px-4 py-3"> <div class="flex items-center justify-between gap-3"> <div class="min-w-0"> <p class="text-muted-foreground text-xs tracking-wider uppercase"> Range, {grid.rangeDayCount} days </p> <p class="mt-0.5 truncate text-sm font-semibold"> {fmtDayShort(grid.rangeBounds.lo)} -> {fmtDayShort(grid.rangeBounds.hi)} </p> </div> <Button variant="ghost" size="icon" class="size-7" aria-label="Clear date range" onclick={() => grid.clearRange()}><X class="size-3.5" /></Button > </div> <div class={`mt-3 grid gap-2 grid-cols-${Math.min(typeKeys.length, 4)}`}> {#each typeKeys as key (key)} <div class="bg-background/40 rounded-md border px-2 py-1.5"> <div class="text-muted-foreground flex items-center gap-1 text-xs tracking-wider uppercase"> <span class={cn('size-1.5 rounded-full', eventTypes[key]!.dot)}></span> {eventTypes[key]!.label} </div> <p class="mt-0.5 text-sm font-semibold tabular-nums">{rangeTypeCounts[key] ?? 0}</p> </div> {/each} </div> </div> <div class="max-h-[26.25rem] overflow-y-auto"> {#if rangeEvents.length === 0} <p class="text-muted-foreground px-4 py-8 text-center text-xs">No events in range.</p> {:else} {#each rangeEvents as e (e.id)} <div class="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 class="bg-background/60 flex w-10 shrink-0 flex-col items-center rounded-md border px-1 py-1 text-center" > <span class="text-muted-foreground text-xs uppercase"> {new Date(e.date).toLocaleDateString('en-US', { month: 'short' })} </span> <span class="text-sm leading-none font-semibold tabular-nums">{new Date(e.date).getDate()}</span> </div> <div class={cn('w-0.5 shrink-0 self-stretch rounded-full', metaFor(e.type).bar)}></div> <div class="min-w-0 flex-1"> <p class="truncate text-xs font-medium">{e.title}</p> <p class="text-muted-foreground mt-0.5 text-xs tabular-nums"> {e.start}-{e.end}{#if e.location} {e.location}{/if} </p> </div> <span class={cn( 'shrink-0 rounded px-1.5 py-0.5 text-xs tracking-wider uppercase ring-1 ring-inset', metaFor(e.type).chip, )} > {metaFor(e.type).label} </span> </div> {/each} {/if} </div> </div> {/if} {#if showUpcoming} <div class="bg-card/40 overflow-hidden rounded-xl border"> <div class="bg-muted/30 border-b px-4 py-2.5"> <p class="text-sm font-semibold">Up next</p> <p class="text-muted-foreground text-xs">After today</p> </div> <div class="divide-y"> {#if loading} {#each [0, 1, 2] as i (i)} <div class="flex items-center gap-3 p-3"> <Skeleton class="size-9 rounded-md" /> <div class="flex-1 space-y-1"> <Skeleton class="h-3 w-32" /> <Skeleton class="h-2 w-20" /> </div> </div> {/each} {:else if upcoming.length === 0} <p class="text-muted-foreground px-4 py-6 text-center text-xs">Nothing on the horizon.</p> {:else} {#each upcoming as e (e.id)} <button type="button" class="group hover:bg-accent/40 flex w-full items-center gap-3 p-3 text-left transition-colors" onclick={() => openEvent(e)} > <div class="bg-background flex size-10 shrink-0 flex-col items-center justify-center rounded-md border text-center" > <span class="text-muted-foreground text-xs uppercase"> {new Date(e.date).toLocaleDateString('en-US', { month: 'short' })} </span> <span class="text-sm leading-none font-semibold tabular-nums">{new Date(e.date).getDate()}</span> </div> <div class="min-w-0 flex-1"> <div class="flex items-center gap-1.5"> <span class={cn('size-1.5 rounded-full', metaFor(e.type).dot)}></span> <p class="truncate text-xs font-medium">{e.title}</p> </div> <p class="text-muted-foreground mt-0.5 text-xs tabular-nums"> {e.start}-{e.end}{#if e.location} {e.location}{/if} </p> </div> </button> {/each} {/if} </div> </div> {/if} </aside> {/if} </div> <!-- Event Detail Dialog --> <Dialog bind:open={eventOpen}> {#if selectedEvent} {@const event = selectedEvent} {@const dialogMeta = metaFor(event.type)} {@const DialogIcon = dialogMeta.icon} <DialogContent class="sm:max-w-md"> {#if eventDialog} {@render eventDialog({ event })} {:else} <DialogHeader> <div class="flex items-center gap-2"> <div class={cn('flex size-8 items-center justify-center rounded-md ring-1 ring-inset', dialogMeta.chip)}> <DialogIcon class="size-4" /> </div> <div> <DialogTitle class="text-base">{event.title}</DialogTitle> <DialogDescription class="text-xs"> {dialogMeta.label}{#if event.status} {event.status}{/if} </DialogDescription> </div> </div> </DialogHeader> <div class="space-y-3 py-2"> {#if event.description} <p class="text-muted-foreground text-sm"> {event.description} </p> {/if} <Separator /> <div class="grid gap-2 text-sm"> <div class="flex items-center gap-2"> <Clock class="text-muted-foreground size-4" /> <span>{event.date} {event.start} - {event.end}</span> </div> {#if event.location} <div class="flex items-center gap-2"> <MapPin class="text-muted-foreground size-4" /> <span>{event.location}</span> </div> {/if} {#if event.attendees?.length} <div class="flex items-start gap-2"> <Users class="text-muted-foreground mt-0.5 size-4" /> <div class="flex flex-wrap items-center gap-1.5"> {#each event.attendees as a (a)} <div class="bg-muted flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs"> <Avatar class="size-4"> <AvatarFallback class="bg-primary/10 text-primary text-xs">{initials(a)}</AvatarFallback> </Avatar> <span>{a}</span> </div> {/each} </div> </div> {/if} </div> </div> <DialogFooter> <Button variant="outline" size="sm" onclick={handleEditSelected}>Edit</Button> <Button size="sm" variant="destructive" onclick={handleDeleteSelected}>Delete</Button> </DialogFooter> {/if} </DialogContent> {/if} </Dialog> </div>src/lib/components/blocks/event-calendar-dashboard/defaults.ts1.5 kB
import { Video, CheckCircle2, AlertCircle, Plane } from '@lucide/svelte' import type { EventTypeMeta } from './types' /** * 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', }, }src/lib/components/blocks/event-calendar-dashboard/types.ts1.5 kB
import type { Component } from 'svelte' /** * 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: Component /** 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 }
Raw manifest:https://uipkge.dev/r/svelte/event-calendar-dashboard.json