Menu

Change language

GitHub repo

Event Calendar Dashboard

blockproductivity

Month-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

$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/event-calendar-dashboard.json
Manifest:https://uipkge.dev/r/svelte/event-calendar-dashboard.jsonInstalls to:src/lib/components/blocks/event-calendar-dashboard/

Variants

Loading interactive previews…

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: 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
}

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
  • src/lib/components/blocks/event-calendar-dashboard/types.ts1.5 kB

Raw manifest:https://uipkge.dev/r/svelte/event-calendar-dashboard.json