{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "layouts-calendar", "type": "registry:block", "title": "Calendar", "description": "Full-featured week/day view with event drag + resize, multi-day events, mini-calendar sidebar, command menu, and rich keyboard shortcuts.", "dependencies": [ "date-fns", "lucide-react", "next-themes", "react-day-picker", "cmdk", "motion" ], "files": [ { "path": "components/layouts/calendar/calendar-event-item.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\nimport { format, isPast } from \"date-fns\";\nimport {\n Popover,\n PopoverAnchor,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { EventDetailPopover } from \"./event-detail-popover\";\nimport { useCalendarPopoverBoundary } from \"./calendar-popover-context\";\nimport type {\n CalendarEvent,\n CalendarEventItemProps,\n EventColor,\n} from \"./week-view-types\";\nimport { EventContextMenu } from \"./event-context-menu\";\n\nexport const eventColorStyles: Record<\n EventColor,\n {\n bg: string;\n bgHover: string;\n border: string;\n borderLine: string;\n text: string;\n }\n> = {\n red: {\n bg: \"bg-event-red-bg\",\n bgHover: \"hover:bg-event-red-bg/70\",\n border: \"bg-event-red-border\",\n borderLine: \"border-event-red-border\",\n text: \"text-event-red\",\n },\n orange: {\n bg: \"bg-event-orange-bg\",\n bgHover: \"hover:bg-event-orange-bg/70\",\n border: \"bg-event-orange-border\",\n borderLine: \"border-event-orange-border\",\n text: \"text-event-orange\",\n },\n yellow: {\n bg: \"bg-event-yellow-bg\",\n bgHover: \"hover:bg-event-yellow-bg/70\",\n border: \"bg-event-yellow-border\",\n borderLine: \"border-event-yellow-border\",\n text: \"text-event-yellow\",\n },\n green: {\n bg: \"bg-event-green-bg\",\n bgHover: \"hover:bg-event-green-bg/70\",\n border: \"bg-event-green-border\",\n borderLine: \"border-event-green-border\",\n text: \"text-event-green\",\n },\n blue: {\n bg: \"bg-event-blue-bg\",\n bgHover: \"hover:bg-event-blue-bg/70\",\n border: \"bg-event-blue-border\",\n borderLine: \"border-event-blue-border\",\n text: \"text-event-blue\",\n },\n purple: {\n bg: \"bg-event-purple-bg\",\n bgHover: \"hover:bg-event-purple-bg/70\",\n border: \"bg-event-purple-border\",\n borderLine: \"border-event-purple-border\",\n text: \"text-event-purple\",\n },\n gray: {\n bg: \"bg-event-gray-bg\",\n bgHover: \"hover:bg-event-gray-bg/70\",\n border: \"bg-event-gray-border\",\n borderLine: \"border-event-gray-border\",\n text: \"text-event-gray\",\n },\n};\n\n/**\n * Formats time showing only minutes if not on the hour\n * e.g., \"10\" for 10:00, \"2:45\" for 2:45\n */\nfunction formatTimeShort(date: Date): string {\n const minutes = date.getMinutes();\n if (minutes === 0) {\n return format(date, \"h\");\n }\n return format(date, \"h:mm\");\n}\n\n/**\n * Formats event time as a compact range like \"10–11 AM\" or \"11 AM–2 PM\"\n */\nfunction formatEventTimeRange(event: CalendarEvent): string {\n const startTime = formatTimeShort(event.start);\n const endTime = formatTimeShort(event.end);\n const endPeriod = format(event.end, \"a\");\n const startPeriod = format(event.start, \"a\");\n\n // If same period (both AM or both PM), only show period at the end\n if (startPeriod === endPeriod) {\n return `${startTime}\\u2013${endTime} ${endPeriod}`;\n }\n\n // Different periods, show both\n return `${startTime} ${startPeriod}\\u2013${endTime} ${endPeriod}`;\n}\n\nfunction computeOverrideStyle(\n positionedEvent: CalendarEventItemProps[\"positionedEvent\"],\n hourHeight: number,\n overrideStart: Date,\n overrideEnd: Date,\n) {\n const startMinutes =\n overrideStart.getHours() * 60 + overrideStart.getMinutes();\n let endMinutes = overrideEnd.getHours() * 60 + overrideEnd.getMinutes();\n // If end is midnight and on a different day than start, treat as 1440 (end of day)\n if (endMinutes === 0 && overrideEnd.getDate() !== overrideStart.getDate()) {\n endMinutes = 1440;\n }\n const topPx = (startMinutes / 60) * hourHeight;\n const heightPx = ((endMinutes - startMinutes) / 60) * hourHeight;\n\n return {\n top: `${topPx}px`,\n height: `${heightPx}px`,\n left: `${positionedEvent.left}%`,\n width: `${positionedEvent.width}%`,\n minHeight: \"20px\",\n };\n}\n\nconst RESIZE_HOTZONE_PX = 8;\n\nexport function CalendarEventItem({\n positionedEvent,\n hourHeight,\n isPast: isPastProp,\n isSelected,\n onClick,\n dragVariant = \"default\",\n overrideStart,\n overrideEnd,\n onDragMouseDown,\n onResizeMouseDown,\n onEventChange,\n cursorY,\n cursorX,\n fixedWidth,\n fixedHeight,\n onContextMenuOpenChange,\n isSidebarOpen,\n onDockToSidebar,\n onClosePopover,\n onPrevWeek,\n onNextWeek,\n className,\n}: CalendarEventItemProps) {\n const { event, segmentPosition = \"full\" } = positionedEvent;\n const color = event.color ?? \"blue\";\n const styles = eventColorStyles[color];\n const eventIsPast = isPastProp ?? isPast(event.end);\n const { view, boundaryRight, headerBottom } = useCalendarPopoverBoundary();\n const isDayView = view === \"day\";\n\n /** Ref to the event button element, used to measure its viewport rect. */\n const eventRef = React.useRef(null);\n\n /**\n * Viewport-relative top & height of the event element.\n * Used to vertically align the day-view PopoverAnchor with the event\n * so the popover appears beside the event rather than at a fixed position.\n */\n const [anchorRect, setAnchorRect] = React.useState<{\n top: number;\n height: number;\n } | null>(null);\n\n const showPopover = isSelected && isSidebarOpen === false;\n\n // Measure the event element's viewport position when the popover opens in\n // day view. useLayoutEffect ensures the measurement happens before paint so\n // the PopoverAnchor is positioned correctly on first frame.\n React.useLayoutEffect(() => {\n if (!showPopover || !isDayView || !eventRef.current) {\n setAnchorRect(null);\n return;\n }\n const rect = eventRef.current.getBoundingClientRect();\n setAnchorRect({ top: rect.top, height: rect.height });\n }, [showPopover, isDayView]);\n\n const hasTopRounding =\n segmentPosition === \"start\" || segmentPosition === \"full\";\n const hasBottomRounding =\n segmentPosition === \"end\" || segmentPosition === \"full\";\n const showTopResize =\n segmentPosition === \"start\" || segmentPosition === \"full\";\n const showBottomResize =\n segmentPosition === \"end\" || segmentPosition === \"full\";\n\n const [contextMenu, setContextMenu] = React.useState<{\n x: number;\n y: number;\n } | null>(null);\n\n const closeContextMenu = React.useCallback(() => {\n setContextMenu(null);\n onContextMenuOpenChange?.(false);\n }, [onContextMenuOpenChange]);\n\n const displayStart = overrideStart ?? event.start;\n const displayEnd = overrideEnd ?? event.end;\n\n const displayEvent: CalendarEvent =\n overrideStart && overrideEnd\n ? { ...event, start: displayStart, end: displayEnd }\n : event;\n\n const defaultStyle = {\n top: `${positionedEvent.top}%`,\n height: `${positionedEvent.height}%`,\n left: `${positionedEvent.left}%`,\n width: `${positionedEvent.width}%`,\n minHeight: \"20px\",\n zIndex: isSelected ? 20 : positionedEvent.column,\n };\n\n const posStyle =\n overrideStart && overrideEnd\n ? computeOverrideStyle(\n positionedEvent,\n hourHeight,\n overrideStart,\n overrideEnd,\n )\n : defaultStyle;\n\n const heightInPixels =\n overrideStart && overrideEnd\n ? Number.parseFloat(String(posStyle.height))\n : (positionedEvent.height / 100) * 24 * hourHeight;\n const isCompact = heightInPixels < 40;\n\n if (dragVariant === \"ghost\") {\n return (\n \n
\n
\n \n \n \n {event.title}\n \n {!isCompact && (\n \n {formatEventTimeRange(event)}\n \n )}\n
\n
\n );\n }\n\n if (dragVariant === \"placeholder\") {\n return (\n \n );\n }\n\n const isDraggingCopy = dragVariant === \"dragging\";\n\n if (isDraggingCopy) {\n const durationMinutes =\n (displayEnd.getTime() - displayStart.getTime()) / 60000;\n const heightPx = fixedHeight ?? (durationMinutes / 60) * hourHeight;\n\n const useFixed = cursorX != null && cursorY != null;\n\n const draggingStyle: React.CSSProperties = useFixed\n ? {\n position: \"fixed\",\n top: `${cursorY}px`,\n left: `${cursorX}px`,\n height: `${heightPx}px`,\n width: fixedWidth != null ? `${fixedWidth}px` : \"200px\",\n minHeight: \"20px\",\n zIndex: 30,\n }\n : {\n top: posStyle.top,\n height: `${heightPx}px`,\n left: `${positionedEvent.left}%`,\n width: `${positionedEvent.width}%`,\n minHeight: \"20px\",\n zIndex: 30,\n };\n\n return (\n \n
\n
\n \n \n \n {event.title}\n \n {heightPx >= 40 && (\n \n {formatEventTimeRange(displayEvent)}\n \n )}\n
\n
\n );\n }\n\n function handleMouseMove(e: React.MouseEvent) {\n const target = e.currentTarget as HTMLElement;\n const rect = target.getBoundingClientRect();\n const offsetY = e.clientY - rect.top;\n const height = rect.height;\n\n if (showTopResize && showBottomResize && height < RESIZE_HOTZONE_PX * 2) {\n target.style.cursor = \"row-resize\";\n return;\n }\n\n if (showTopResize && offsetY <= RESIZE_HOTZONE_PX) {\n target.style.cursor = \"row-resize\";\n return;\n }\n\n if (showBottomResize && offsetY >= height - RESIZE_HOTZONE_PX) {\n target.style.cursor = \"row-resize\";\n return;\n }\n\n target.style.cursor = \"default\";\n }\n\n function handleMouseDown(e: React.MouseEvent) {\n e.stopPropagation();\n\n const target = e.currentTarget as HTMLElement;\n const rect = target.getBoundingClientRect();\n const offsetY = e.clientY - rect.top;\n const height = rect.height;\n\n if (showTopResize && showBottomResize && height < RESIZE_HOTZONE_PX * 2) {\n const edge = offsetY < height / 2 ? \"top\" : \"bottom\";\n onResizeMouseDown?.(e, event, edge);\n return;\n }\n\n if (showTopResize && offsetY <= RESIZE_HOTZONE_PX) {\n onResizeMouseDown?.(e, event, \"top\");\n return;\n }\n\n if (showBottomResize && offsetY >= height - RESIZE_HOTZONE_PX) {\n onResizeMouseDown?.(e, event, \"bottom\");\n return;\n }\n\n onDragMouseDown?.(e, event);\n }\n\n function handleClick(e: React.MouseEvent) {\n e.stopPropagation();\n if (!onClick) return;\n onClick(event);\n }\n\n function handleKeyDown(e: React.KeyboardEvent) {\n if (e.key !== \"Enter\" && e.key !== \" \") return;\n e.preventDefault();\n onClick?.(event);\n }\n\n function handleContextMenu(e: React.MouseEvent) {\n e.preventDefault();\n e.stopPropagation();\n setContextMenu({ x: e.clientX, y: e.clientY });\n onContextMenuOpenChange?.(true);\n }\n\n const eventElement = (\n \n {/* Solid background layer to prevent transparency bleed-through */}\n \n\n {/* Colored background layer - uses border color when selected */}\n \n\n {/* Left border - hidden when selected (merges with bg) */}\n {!isSelected && (\n \n )}\n \n \n {event.title}\n \n {!isCompact && (\n \n {formatEventTimeRange(displayEvent)}\n \n )}\n \n \n );\n\n if (showPopover) {\n return (\n <>\n {\n if (!open) onClosePopover?.();\n }}\n >\n {eventElement}\n {/*\n * In day view the event spans the full grid width, so Radix can't\n * fit the popover beside the trigger. Place a zero-width anchor at\n * the RIGHT edge of the calendar boundary and use side=\"left\" so\n * the popover extends leftward \\u2014 matching Notion Calendar.\n *\n * The anchor is portaled to document.body to escape scroll\n * containers that apply CSS transforms (which break position:fixed\n * by creating a new containing block).\n */}\n {isDayView &&\n createPortal(\n ,\n document.body,\n )}\n onClosePopover?.()}\n onDockToSidebar={() => onDockToSidebar?.()}\n onPrevWeek={onPrevWeek}\n onNextWeek={onNextWeek}\n side={isDayView ? \"left\" : \"right\"}\n collisionPaddingTop={isDayView ? headerBottom : undefined}\n />\n \n {contextMenu && (\n \n )}\n \n );\n }\n\n return (\n <>\n {eventElement}\n {contextMenu && (\n \n )}\n \n );\n}\n\n/** Drag visual variant for all-day events */\nexport type AllDayDragVariant = \"ghost\" | \"placeholder\" | \"dragging\";\n\nexport interface AllDayEventItemProps {\n event: CalendarEvent;\n isPast?: boolean;\n isSelected?: boolean;\n onClick?: (event: CalendarEvent) => void;\n className?: string;\n /** For multi-day events: position info */\n spanStart?: boolean;\n spanEnd?: boolean;\n /** Mousedown handler to initiate horizontal resize or drag */\n onResizeMouseDown?: (\n e: React.MouseEvent,\n event: CalendarEvent,\n edge: \"left\" | \"right\" | \"move\",\n ) => void;\n /** Callback when an event is changed (e.g. color change from context menu) */\n onEventChange?: (event: CalendarEvent) => void;\n /** Callback when context menu open state changes */\n onContextMenuOpenChange?: (open: boolean) => void;\n /** Whether the right sidebar is open (controls popover visibility) */\n isSidebarOpen?: boolean;\n /** Callback to dock popover to sidebar */\n onDockToSidebar?: () => void;\n /** Callback to close popover (deselect event) */\n onClosePopover?: () => void;\n /** Navigate to previous week */\n onPrevWeek?: () => void;\n /** Navigate to next week */\n onNextWeek?: () => void;\n /**\n * Percentage of the event's width that is hidden off-screen to the left.\n * Used in day view to offset the title into the visible area so multi-day\n * events always show their title \\u2014 \\u201csticky title\\u201d effect.\n */\n titleOffsetPercent?: number;\n /** Visual variant during drag operations */\n dragVariant?: AllDayDragVariant;\n}\n\n/**\n * Formats start time for all-day events like \"8:45 AM\" or \"4 PM\"\n */\nfunction formatAllDayStartTime(date: Date): string {\n const minutes = date.getMinutes();\n if (minutes === 0) {\n return format(date, \"h a\");\n }\n return format(date, \"h:mm a\");\n}\n\nconst ALL_DAY_RESIZE_HOTZONE_PX = 6;\n\nexport function AllDayEventItem({\n event,\n isPast: isPastProp,\n isSelected,\n onClick,\n className,\n spanStart = true,\n spanEnd = true,\n onResizeMouseDown,\n onEventChange,\n onContextMenuOpenChange,\n isSidebarOpen,\n onDockToSidebar,\n onClosePopover,\n onPrevWeek,\n onNextWeek,\n titleOffsetPercent = 0,\n dragVariant,\n}: AllDayEventItemProps) {\n const color = event.color ?? \"blue\";\n const styles = eventColorStyles[color];\n const { view, boundaryRight, headerBottom } = useCalendarPopoverBoundary();\n const isDayView = view === \"day\";\n const eventIsPast = isPastProp ?? isPast(event.end);\n\n const [contextMenu, setContextMenu] = React.useState<{\n x: number;\n y: number;\n } | null>(null);\n\n const closeContextMenu = React.useCallback(() => {\n setContextMenu(null);\n onContextMenuOpenChange?.(false);\n }, [onContextMenuOpenChange]);\n\n // Ghost: faded version at original position during move\n if (dragVariant === \"ghost\") {\n return (\n \n \n \n {spanStart && (\n \n )}\n \n {event.title}\n \n \n );\n }\n\n // Placeholder: border-only outline at target position\n if (dragVariant === \"placeholder\") {\n return (\n \n );\n }\n\n // Dragging copy: floating replica following cursor\n if (dragVariant === \"dragging\") {\n return (\n \n
\n
\n \n \n {event.title}\n \n
\n );\n }\n\n // Check if event has a specific start time (not midnight)\n const hasStartTime =\n event.start.getHours() !== 0 || event.start.getMinutes() !== 0;\n\n function handleClick(e: React.MouseEvent) {\n e.stopPropagation();\n if (!onClick) {\n return;\n }\n onClick(event);\n }\n\n function handleKeyDown(e: React.KeyboardEvent) {\n if (e.key !== \"Enter\" && e.key !== \" \") {\n return;\n }\n e.preventDefault();\n onClick?.(event);\n }\n\n function handleAllDayMouseMove(e: React.MouseEvent) {\n const target = e.currentTarget as HTMLElement;\n const rect = target.getBoundingClientRect();\n const offsetX = e.clientX - rect.left;\n const width = rect.width;\n\n if (spanStart && offsetX <= ALL_DAY_RESIZE_HOTZONE_PX) {\n target.style.cursor = \"col-resize\";\n return;\n }\n\n if (spanEnd && offsetX >= width - ALL_DAY_RESIZE_HOTZONE_PX) {\n target.style.cursor = \"col-resize\";\n return;\n }\n\n target.style.cursor = \"default\";\n }\n\n function handleAllDayMouseDown(e: React.MouseEvent) {\n if (!onResizeMouseDown) return;\n\n const target = e.currentTarget as HTMLElement;\n const rect = target.getBoundingClientRect();\n const offsetX = e.clientX - rect.left;\n const width = rect.width;\n\n if (spanStart && offsetX <= ALL_DAY_RESIZE_HOTZONE_PX) {\n e.stopPropagation();\n onResizeMouseDown(e, event, \"left\");\n return;\n }\n\n if (spanEnd && offsetX >= width - ALL_DAY_RESIZE_HOTZONE_PX) {\n e.stopPropagation();\n onResizeMouseDown(e, event, \"right\");\n return;\n }\n\n // Middle area: initiate drag (move)\n e.stopPropagation();\n onResizeMouseDown(e, event, \"move\");\n }\n\n function handleContextMenu(e: React.MouseEvent) {\n e.preventDefault();\n e.stopPropagation();\n setContextMenu({ x: e.clientX, y: e.clientY });\n onContextMenuOpenChange?.(true);\n }\n\n const showPopover = isSelected && isSidebarOpen === false;\n\n const eventElement = (\n 0\n ? { paddingLeft: `${titleOffsetPercent}%` }\n : undefined\n }\n >\n {/* Solid background layer to prevent transparency bleed-through */}\n \n\n {/* Colored background layer - uses border color when selected */}\n \n\n {/* Left border - hidden when selected (merges with bg) */}\n {spanStart && !isSelected && (\n \n )}\n \n {event.title}\n \n {hasStartTime && (\n \n {formatAllDayStartTime(event.start)}\n \n )}\n
\n );\n\n if (showPopover) {\n return (\n <>\n {\n if (!open) onClosePopover?.();\n }}\n >\n {eventElement}\n {/*\n * In day view, all-day events span the full width. Portal the\n * anchor to document.body (escaping transformed scroll containers)\n * and position it at the calendar boundary's right edge so the\n * popover always appears at the visible right edge \\u2014 even when the\n * event wrapper extends into off-screen buffer days.\n */}\n {isDayView &&\n createPortal(\n ,\n document.body,\n )}\n onClosePopover?.()}\n onDockToSidebar={() => onDockToSidebar?.()}\n onPrevWeek={onPrevWeek}\n onNextWeek={onNextWeek}\n side={isDayView ? \"left\" : \"right\"}\n align=\"start\"\n collisionPaddingTop={isDayView ? headerBottom : undefined}\n />\n \n {contextMenu && (\n \n )}\n \n );\n }\n\n return (\n <>\n {eventElement}\n {contextMenu && (\n \n )}\n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/calendar-event-item.tsx" }, { "path": "components/layouts/calendar/calendar-popover-context.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { ViewType } from \"./week-view-types\";\n\n/**\n * Context to provide the collision boundary element, header inset,\n * and current view type for event detail popovers.\n * - boundary: the outer calendar container (so popovers can position freely)\n * - headerHeight: the height of the weekday header + all-day row,\n * used as top collision padding so popovers never overlap the header.\n * - view: current calendar view (\"day\" | \"week\") — in day view, the collision\n * boundary is skipped so Radix uses the viewport for positioning.\n * - boundaryRight: the right-edge x-coordinate (in viewport px) of the\n * calendar boundary. Used in day view to place a fixed-position popover\n * anchor at the calendar's right edge so popovers match Notion Calendar.\n */\n\ninterface CalendarPopoverBoundaryValue {\n boundary: HTMLElement | null;\n headerHeight: number;\n view: ViewType;\n /** Right edge of the calendar boundary in viewport pixels. */\n boundaryRight: number;\n /**\n * Bottom edge of the header (weekday + all-day row) in viewport pixels.\n * Used as collision padding top in day view so the popover never overlaps\n * the header area when the collision boundary is the viewport.\n */\n headerBottom: number;\n}\n\nconst CalendarPopoverBoundaryContext =\n React.createContext({\n boundary: null,\n headerHeight: 0,\n view: \"week\",\n boundaryRight: 0,\n headerBottom: 0,\n });\n\nexport function CalendarPopoverBoundaryProvider({\n boundaryRef,\n headerRef,\n view = \"week\",\n children,\n}: {\n boundaryRef: React.RefObject;\n headerRef: React.RefObject;\n view?: ViewType;\n children: React.ReactNode;\n}) {\n const [boundary, setBoundary] = React.useState(null);\n const [headerHeight, setHeaderHeight] = React.useState(0);\n const [boundaryRight, setBoundaryRight] = React.useState(0);\n const [headerBottom, setHeaderBottom] = React.useState(0);\n\n React.useEffect(() => {\n setBoundary(boundaryRef.current);\n }, [boundaryRef]);\n\n // Observe header height changes (all-day row can expand/collapse).\n // Also track the viewport-relative bottom edge of the header for day-view\n // collision padding — the popover must not overlap the header area.\n React.useEffect(() => {\n const el = headerRef.current;\n if (!el) return;\n\n const update = () => {\n setHeaderHeight(el.offsetHeight);\n setHeaderBottom(el.getBoundingClientRect().bottom);\n };\n update();\n\n const ro = new ResizeObserver(update);\n ro.observe(el);\n return () => ro.disconnect();\n }, [headerRef]);\n\n // Track the right edge of the calendar boundary for day-view anchoring.\n // A ResizeObserver catches layout changes; we don't need scroll since the\n // boundary element itself doesn't scroll within the viewport.\n React.useEffect(() => {\n const el = boundaryRef.current;\n if (!el) return;\n\n const update = () => {\n setBoundaryRight(el.getBoundingClientRect().right);\n };\n update();\n\n const ro = new ResizeObserver(update);\n ro.observe(el);\n return () => ro.disconnect();\n }, [boundaryRef]);\n\n const value = React.useMemo(\n () => ({ boundary, headerHeight, view, boundaryRight, headerBottom }),\n [boundary, headerHeight, view, boundaryRight, headerBottom],\n );\n\n return (\n \n {children}\n \n );\n}\n\nexport function useCalendarPopoverBoundary() {\n return React.useContext(CalendarPopoverBoundaryContext);\n}\n", "type": "registry:component", "target": "components/layouts/calendar/calendar-popover-context.tsx" }, { "path": "components/layouts/calendar/calendars.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronRight, Eye, EyeOff, Rss } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport type {\n CalendarAccount,\n CalendarColor,\n} from \"@/components/layouts/calendar/sidebar-right\";\nimport {\n Collapsible,\n CollapsibleContent,\n CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport {\n SidebarGroup,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarSeparator,\n} from \"@/components/ui/sidebar\";\n\nconst colorStyles: Record = {\n red: \"bg-event-red\",\n orange: \"bg-event-orange\",\n yellow: \"bg-event-yellow\",\n green: \"bg-event-green\",\n blue: \"bg-event-blue\",\n purple: \"bg-event-purple\",\n gray: \"bg-event-gray\",\n};\n\ninterface CalendarsProps {\n accounts: CalendarAccount[];\n}\n\nexport function Calendars({ accounts }: CalendarsProps) {\n const [visibleCalendars, setVisibleCalendars] = React.useState>(\n () => {\n const visible = new Set();\n for (const account of accounts) {\n for (const calendar of account.calendars) {\n if (calendar.visible) {\n visible.add(`${account.email}-${calendar.name}`);\n }\n }\n }\n return visible;\n },\n );\n\n const toggleVisibility = (accountEmail: string, calendarName: string) => {\n const key = `${accountEmail}-${calendarName}`;\n setVisibleCalendars((prev) => {\n const next = new Set(prev);\n if (next.has(key)) {\n next.delete(key);\n } else {\n next.add(key);\n }\n return next;\n });\n };\n\n return (\n <>\n {accounts.map((account, index) => (\n \n \n \n \n \n {account.email}\n \n \n \n \n \n \n {account.calendars.map((calendar) => {\n const isVisible = visibleCalendars.has(\n `${account.email}-${calendar.name}`,\n );\n return (\n \n \n \n {calendar.isSubscribed && (\n \n )}\n \n \n {calendar.name}\n \n {\n e.stopPropagation();\n toggleVisibility(account.email, calendar.name);\n }}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n e.stopPropagation();\n toggleVisibility(\n account.email,\n calendar.name,\n );\n }\n }}\n >\n {isVisible ? (\n \n ) : (\n \n )}\n \n \n \n );\n })}\n \n \n \n \n \n {index < accounts.length - 1 && }\n \n ))}\n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/calendars.tsx" }, { "path": "components/layouts/calendar/command-menu.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport {\n CommandDialog,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport { Kbd, KbdGroup } from \"@/components/ui/kbd\";\n\nimport type { ViewType } from \"@/components/layouts/calendar/week-view-types\";\n\n/** Reusable \"Soon\" badge for unimplemented command items */\nconst SOON_BADGE = (\n \n Soon\n \n);\n\ninterface CommandMenuProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n onGoToToday: () => void;\n onGoToPrev: () => void;\n onGoToNext: () => void;\n onSwitchView: (view: ViewType) => void;\n onToggleLeftSidebar: () => void;\n onToggleRightSidebar: () => void;\n onCycleTheme: () => void;\n}\n\nexport function CommandMenu({\n open,\n onOpenChange,\n onGoToToday,\n onGoToPrev,\n onGoToNext,\n onSwitchView,\n onToggleLeftSidebar,\n onToggleRightSidebar,\n onCycleTheme,\n}: CommandMenuProps) {\n const runCommand = React.useCallback(\n (command: () => void) => {\n onOpenChange(false);\n command();\n },\n [onOpenChange],\n );\n\n return (\n \n \n \n No results found.\n\n {/* ── Calendar ── */}\n \n \n Create event...\n {SOON_BADGE}\n \n \n Meet with...\n {SOON_BADGE}\n \n \n Show teammate calendar...\n {SOON_BADGE}\n \n \n Create recurring scheduling link...\n {SOON_BADGE}\n \n \n Create one-off scheduling link...\n {SOON_BADGE}\n \n \n\n {/* ── Navigation ── */}\n \n \n Go to date...\n {SOON_BADGE}\n \n runCommand(onGoToToday)}>\n Go to today\n T\n \n runCommand(onGoToNext)}>\n Go to next week\n J\n \n runCommand(onGoToPrev)}>\n Go to previous week\n K\n \n \n Search events\n {SOON_BADGE}\n \n \n\n {/* ── Time zones ── */}\n \n \n Travel to time zone...\n {SOON_BADGE}\n \n \n Show additional time zones...\n {SOON_BADGE}\n \n \n\n {/* ── App ── */}\n \n runCommand(onToggleLeftSidebar)}>\n Toggle sidebar\n \n \n /\n \n \n runCommand(onCycleTheme)}>\n Set theme...\n \n \n \n L\n \n \n \n\n {/* ── View ── */}\n \n \n Start week on...\n {SOON_BADGE}\n \n runCommand(() => onSwitchView(\"day\"))}>\n Display day view\n \n D\n \n \n runCommand(() => onSwitchView(\"week\"))}>\n Display week view\n \n W\n \n \n runCommand(() => onSwitchView(\"month\"))}>\n Display month view\n \n M\n \n \n \n Set number of displayed days...\n {SOON_BADGE}\n \n \n Select all visible\n {SOON_BADGE}\n \n \n Default hour size\n {SOON_BADGE}\n \n \n Zoom hours in\n {SOON_BADGE}\n \n \n Zoom hours out\n {SOON_BADGE}\n \n \n Hide weekends\n {SOON_BADGE}\n \n \n Hide declined events\n {SOON_BADGE}\n \n \n Hide week numbers\n {SOON_BADGE}\n \n \n\n {/* ── Settings & help ── */}\n \n \n Get CalendarCN mobile app\n {SOON_BADGE}\n \n \n Show keyboard shortcuts\n {SOON_BADGE}\n \n \n Go to settings\n {SOON_BADGE}\n \n \n Support & feedback\n {SOON_BADGE}\n \n \n\n {/* ── Accounts ── */}\n \n \n Add Google Calendar account\n {SOON_BADGE}\n \n \n Manage calendar accounts\n {SOON_BADGE}\n \n \n Log out\n {SOON_BADGE}\n \n \n\n {/* ── CalendarCN ── */}\n \n \n Check for update\n {SOON_BADGE}\n \n \n About CalendarCN\n {SOON_BADGE}\n \n \n\n {/* ── Panels ── */}\n \n runCommand(onToggleRightSidebar)}>\n Toggle context panel\n /\n \n \n \n\n
\n \n ↑↓ Navigate\n \n \n Select\n \n \n Close\n \n
\n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/command-menu.tsx" }, { "path": "components/layouts/calendar/date-picker.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { isSameDay } from \"date-fns\";\n\nimport { Calendar } from \"@/components/ui/calendar\";\nimport { SidebarGroup, SidebarGroupContent } from \"@/components/ui/sidebar\";\n\ninterface DatePickerProps {\n onDateSelect?: (date: Date) => void;\n currentDate?: Date;\n visibleDays?: Date[];\n}\n\nexport function DatePicker({\n onDateSelect,\n currentDate,\n visibleDays,\n}: DatePickerProps) {\n const [today] = React.useState(() => new Date());\n const [displayedMonth, setDisplayedMonth] = React.useState(\n currentDate ?? today,\n );\n\n // Track previous first visible day to determine scroll direction\n const prevFirstDayRef = React.useRef(null);\n\n // Auto-navigate datepicker month so highlighted days stay visible\n // Scrolling forward → keep last visible day's month shown\n // Scrolling backward → keep first visible day's month shown\n React.useEffect(() => {\n if (!visibleDays || visibleDays.length === 0) return;\n\n const firstDay = visibleDays[0];\n const lastDay = visibleDays[visibleDays.length - 1];\n const prevFirstDay = prevFirstDayRef.current;\n prevFirstDayRef.current = firstDay;\n\n const setMonthIfChanged = (anchor: Date) => {\n setDisplayedMonth((prev) => {\n if (\n prev.getMonth() === anchor.getMonth() &&\n prev.getFullYear() === anchor.getFullYear()\n ) {\n return prev;\n }\n return anchor;\n });\n };\n\n // Scrolling forward → ensure last visible day's month is displayed\n if (prevFirstDay && firstDay.getTime() > prevFirstDay.getTime()) {\n setMonthIfChanged(lastDay);\n return;\n }\n\n // Scrolling backward or initial render → ensure first visible day's month is displayed\n setMonthIfChanged(firstDay);\n }, [visibleDays]);\n\n const isSameMonth =\n displayedMonth.getMonth() === today.getMonth() &&\n displayedMonth.getFullYear() === today.getFullYear();\n\n const monthYearLabel = displayedMonth.toLocaleDateString(\"default\", {\n month: \"long\",\n year: \"numeric\",\n });\n\n const goBackToToday = () => {\n setDisplayedMonth(today);\n onDateSelect?.(today);\n };\n\n // Build modifiers for visible days highlighting\n const modifiers = React.useMemo(() => {\n if (!visibleDays || visibleDays.length === 0) return undefined;\n\n return {\n inView: (date: Date) => visibleDays.some((d) => isSameDay(d, date)),\n };\n }, [visibleDays]);\n\n const modifiersClassNames = React.useMemo(() => {\n if (!modifiers) return undefined;\n return {\n inView: \"in-view-day\",\n };\n }, [modifiers]);\n\n return (\n // Pin the mini calendar to the top of the sidebar scroll container so\n // it stays visible while the sections below (Scheduling, accounts,\n // teams) scroll underneath.\n \n \n {\n if (date) {\n onDateSelect?.(date);\n }\n }}\n fixedWeeks\n modifiers={modifiers}\n modifiersClassNames={modifiersClassNames}\n // Dropping showWeekNumber: react-day-picker v8 adds the week column\n // only to tbody rows, not to the header row, so headers and data\n // were off-by-one — Saturday was shoved off the right edge. A\n // clean 7-column grid (7 × 32px + padding ≈ 240px) fits the\n // ~256px sidebar without clipping.\n className=\"bg-transparent [&_[role=gridcell].bg-accent]:bg-sidebar-primary [&_[role=gridcell].bg-accent]:text-sidebar-primary-foreground\"\n />\n \n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/date-picker.tsx" }, { "path": "components/layouts/calendar/event-context-menu.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n Check,\n Copy,\n Monitor,\n SquareDashed,\n TabletSmartphone,\n Trash2,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { CalendarEvent, EventColor } from \"./week-view-types\";\n\nconst EVENT_COLORS: EventColor[] = [\n \"red\",\n \"orange\",\n \"yellow\",\n \"green\",\n \"blue\",\n \"purple\",\n \"gray\",\n];\n\nconst colorSwatchClass: Record = {\n red: \"bg-event-red-border\",\n orange: \"bg-event-orange-border\",\n yellow: \"bg-event-yellow-border\",\n green: \"bg-event-green-border\",\n blue: \"bg-event-blue-border\",\n purple: \"bg-event-purple-border\",\n gray: \"bg-event-gray-border\",\n};\n\ninterface CalendarAccountData {\n email: string;\n calendars: { name: string; color: EventColor }[];\n}\n\nconst CALENDAR_ACCOUNTS: CalendarAccountData[] = [\n {\n email: \"you@example.com\",\n calendars: [\n { name: \"you@example.com\", color: \"red\" },\n { name: \"Personal\", color: \"purple\" },\n { name: \"Work\", color: \"blue\" },\n { name: \"Family\", color: \"orange\" },\n { name: \"Side Projects\", color: \"yellow\" },\n { name: \"Fitness\", color: \"green\" },\n { name: \"Holidays in Brazil\", color: \"green\" },\n ],\n },\n];\n\ninterface EventContextMenuProps {\n event: CalendarEvent;\n position: { x: number; y: number };\n onClose: () => void;\n onEventChange?: (event: CalendarEvent) => void;\n}\n\nfunction MenuItem({\n className,\n children,\n onSelect,\n}: {\n className?: string;\n children: React.ReactNode;\n onSelect?: () => void;\n}) {\n return (\n \n {children}\n \n );\n}\n\nfunction Shortcut({ children }: { children: React.ReactNode }) {\n return {children};\n}\n\nfunction Separator() {\n return
;\n}\n\nfunction SubMenu({\n trigger,\n children,\n}: {\n trigger: React.ReactNode;\n children: React.ReactNode;\n}) {\n const [open, setOpen] = React.useState(false);\n const timeoutRef = React.useRef | null>(null);\n\n function handleMouseEnter() {\n if (timeoutRef.current) clearTimeout(timeoutRef.current);\n setOpen(true);\n }\n\n function handleMouseLeave() {\n timeoutRef.current = setTimeout(() => setOpen(false), 150);\n }\n\n return (\n \n \n {trigger}\n \n \n \n \n {open && (\n
\n {children}\n
\n )}\n
\n );\n}\n\nexport function EventContextMenu({\n event,\n position,\n onClose,\n onEventChange,\n}: EventContextMenuProps) {\n const menuRef = React.useRef(null);\n const [adjustedPos, setAdjustedPos] = React.useState(position);\n const [ready, setReady] = React.useState(false);\n const currentColor = event.color ?? \"blue\";\n\n React.useLayoutEffect(() => {\n const menu = menuRef.current;\n if (!menu) return;\n\n const menuHeight = menu.offsetHeight;\n const menuWidth = menu.offsetWidth;\n let y = position.y;\n let x = position.x;\n\n if (position.y + menuHeight > window.innerHeight) {\n y = position.y - menuHeight;\n }\n if (position.x + menuWidth > window.innerWidth) {\n x = position.x - menuWidth;\n }\n\n setAdjustedPos({ x, y });\n setReady(true);\n }, [position]);\n\n React.useEffect(() => {\n function handleClickOutside(e: MouseEvent) {\n if (!menuRef.current) return;\n if (menuRef.current.contains(e.target as Node)) return;\n onClose();\n }\n\n function handleEscape(e: KeyboardEvent) {\n if (e.key !== \"Escape\") return;\n onClose();\n }\n\n // Use capture to close before other handlers fire\n document.addEventListener(\"mousedown\", handleClickOutside, true);\n document.addEventListener(\"keydown\", handleEscape);\n return () => {\n document.removeEventListener(\"mousedown\", handleClickOutside, true);\n document.removeEventListener(\"keydown\", handleEscape);\n };\n }, [onClose]);\n\n function handleColorSelect(color: EventColor) {\n onEventChange?.({ ...event, color });\n onClose();\n }\n\n function handleCalendarSelect(calendarName: string) {\n onEventChange?.({ ...event, calendarId: calendarName });\n onClose();\n }\n\n return createPortal(\n \n {/* Color selector row */}\n
\n {EVENT_COLORS.map((color) => (\n handleColorSelect(color)}\n >\n {color === currentColor && }\n \n ))}\n
\n\n \n\n {/* Block on calendar */}\n \n \n Block on calendar\n \n }\n >\n {CALENDAR_ACCOUNTS.map((account) => (\n \n
\n {account.email}\n
\n {account.calendars.map((cal) => (\n handleCalendarSelect(cal.name)}\n >\n \n {cal.name}\n \n ))}\n
\n ))}\n \n\n \n\n {/* Cut / Copy / Duplicate */}\n \n \n Cut\n ⌘X\n \n \n \n Copy\n ⌘C\n \n \n \n Duplicate\n ⌘D\n \n\n \n\n {/* Delete */}\n svg]:!text-white [&:focus>svg]:!text-white [&:hover>.ml-auto]:!text-white [&:focus>.ml-auto]:!text-white\">\n \n Delete\n delete\n \n ,\n document.body,\n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/event-context-menu.tsx" }, { "path": "components/layouts/calendar/event-detail-panel.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as ReactDOM from \"react-dom\";\nimport {\n addDays,\n differenceInCalendarDays,\n differenceInMinutes,\n format,\n parse,\n} from \"date-fns\";\nimport {\n Bell,\n Check,\n ChevronDown,\n CircleHelp,\n ChevronLeft,\n ChevronRight,\n Clock,\n Copy,\n Globe,\n MapPin,\n MoreHorizontal,\n NotepadText,\n RefreshCcw,\n SquareDashed,\n TabletSmartphone,\n Trash2,\n User,\n Video,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuShortcut,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Switch } from \"@/components/ui/switch\";\nimport type { CalendarEvent, EventColor } from \"./week-view-types\";\n\ninterface EventDetailPanelProps {\n event: CalendarEvent;\n onEventChange?: (event: CalendarEvent) => void;\n onPrevWeek?: () => void;\n onNextWeek?: () => void;\n /** Extra action buttons rendered in the header row (after the \"...\" menu). */\n headerActions?: React.ReactNode;\n}\n\nconst colorDotClass: Record = {\n red: \"bg-event-red-border\",\n orange: \"bg-event-orange-border\",\n yellow: \"bg-event-yellow-border\",\n green: \"bg-event-green-border\",\n blue: \"bg-event-blue-border\",\n purple: \"bg-event-purple-border\",\n gray: \"bg-event-gray-border\",\n};\n\nfunction formatDuration(start: Date, end: Date): string {\n const totalMinutes = differenceInMinutes(end, start);\n\n if (totalMinutes < 60) {\n return `${totalMinutes}min`;\n }\n\n const hours = Math.floor(totalMinutes / 60);\n const minutes = totalMinutes % 60;\n\n if (minutes === 0) {\n return `${hours}h`;\n }\n\n return `${hours}h ${minutes}min`;\n}\n\nfunction formatTimeDisplay(date: Date): string {\n const minutes = date.getMinutes();\n if (minutes === 0) {\n return format(date, \"h a\");\n }\n return format(date, \"h:mm a\");\n}\n\ninterface ParsedTime {\n hours: number;\n minutes: number;\n}\n\n/**\n * Parses a user-typed time string into hours and minutes.\n * Accepts formats: \"3 PM\", \"3:30 PM\", \"15:00\", \"3pm\", \"330pm\", \"3:30pm\".\n * Returns null if the input cannot be parsed.\n */\nfunction parseTimeInput(input: string): ParsedTime | null {\n const trimmed = input.trim().toLowerCase();\n if (trimmed.length === 0) {\n return null;\n }\n\n const isPM = /pm$/.test(trimmed);\n const isAM = /am$/.test(trimmed);\n const stripped = trimmed.replace(/\\s*(am|pm)\\s*$/, \"\").trim();\n\n if (stripped.length === 0) {\n return null;\n }\n\n let hours: number;\n let minutes: number;\n\n if (stripped.includes(\":\")) {\n const parts = stripped.split(\":\");\n if (parts.length !== 2) {\n return null;\n }\n hours = Number.parseInt(parts[0], 10);\n minutes = Number.parseInt(parts[1], 10);\n } else {\n const num = Number.parseInt(stripped, 10);\n if (Number.isNaN(num)) {\n return null;\n }\n if (stripped.length > 2 && num > 99) {\n // e.g., \"330\" → 3:30, \"1230\" → 12:30\n minutes = num % 100;\n hours = Math.floor(num / 100);\n } else {\n hours = num;\n minutes = 0;\n }\n }\n\n if (Number.isNaN(hours) || Number.isNaN(minutes)) {\n return null;\n }\n\n // Apply AM/PM conversion\n if (isPM && hours < 12) {\n hours += 12;\n }\n if (isAM && hours === 12) {\n hours = 0;\n }\n\n if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {\n return null;\n }\n\n return { hours, minutes };\n}\n\n/**\n * Returns a new Date with the same year/month/day as `base`\n * but with hours and minutes replaced.\n */\nfunction applyTimeToDate(base: Date, hours: number, minutes: number): Date {\n const result = new Date(base);\n result.setHours(hours, minutes, 0, 0);\n return result;\n}\n\nfunction formatDateDisplay(date: Date): string {\n return format(date, \"EEE MMM d\");\n}\n\n/**\n * Parses a user-typed date string into a Date.\n * Strips any leading weekday name and parses \"MMM d\" (e.g., \"Mar 11\").\n * Uses the reference date's year. Returns null if unparseable.\n */\nfunction parseDateInput(input: string, referenceDate: Date): Date | null {\n const trimmed = input.trim();\n if (trimmed.length === 0) {\n return null;\n }\n\n // Strip optional leading weekday (e.g., \"Tue \", \"Wed \")\n const withoutWeekday = trimmed.replace(/^[a-z]{3}\\s+/i, \"\");\n if (withoutWeekday.length === 0) {\n return null;\n }\n\n const parsed = parse(withoutWeekday, \"MMM d\", referenceDate);\n if (Number.isNaN(parsed.getTime())) {\n return null;\n }\n\n return parsed;\n}\n\nfunction formatVisibility(\n visibility?: \"default\" | \"public\" | \"private\",\n): string {\n if (visibility === \"public\") {\n return \"Public\";\n }\n if (visibility === \"private\") {\n return \"Private\";\n }\n return \"Default visibility\";\n}\n\nfunction FieldRow({\n icon: Icon,\n label,\n value,\n}: {\n icon: React.ComponentType<{ className?: string }>;\n label: string;\n value?: string;\n}) {\n return (\n
\n \n \n {label}\n \n {value && {value}}\n
\n );\n}\n\nfunction TimezoneDisplay({ timezone }: { timezone: string }) {\n const spaceIdx = timezone.indexOf(\" \");\n if (spaceIdx === -1) {\n return (\n \n {timezone}\n \n );\n }\n\n const code = timezone.substring(0, spaceIdx);\n const city = timezone.substring(spaceIdx + 1);\n\n return (\n \n {code}\n {city}\n \n );\n}\n\nfunction RecurrenceDisplay({ recurrence }: { recurrence: string }) {\n const onIdx = recurrence.indexOf(\" on \");\n if (onIdx === -1) {\n return {recurrence};\n }\n\n const main = recurrence.substring(0, onIdx);\n const suffix = recurrence.substring(onIdx);\n\n return (\n \n {main}\n {suffix}\n \n );\n}\n\nconst EVENT_TYPES = [\n \"Event\",\n \"Focus time\",\n \"Out of office\",\n \"Birthday\",\n] as const;\ntype EventType = (typeof EVENT_TYPES)[number];\n\nconst EVENT_TYPE_TOOLTIPS: Partial> = {\n \"Focus time\":\n \"Create a focus time event with the option to automatically decline meetings during this time. Available for work and school accounts.\",\n \"Out of office\":\n \"Create an out of office (OOO) event with the option to automatically decline meetings during this time. Available for work and school accounts.\",\n Birthday:\n \"Create a birthday event to keep track of a person's upcoming birthdays. Birthdays from your Google Contacts may appear on a separate Birthday calendar.\",\n};\n\nfunction EventTypeHelpIcon({ tooltip }: { tooltip?: string }) {\n const [tooltipPos, setTooltipPos] = React.useState<{\n top: number;\n left: number;\n } | null>(null);\n\n if (!tooltip) {\n return null;\n }\n\n function handleMouseEnter(e: React.MouseEvent) {\n const rect = e.currentTarget.getBoundingClientRect();\n setTooltipPos({ top: rect.top + rect.height / 2, left: rect.left });\n }\n\n function handleMouseLeave() {\n setTooltipPos(null);\n }\n\n return (\n e.stopPropagation()}\n onPointerDown={(e) => e.stopPropagation()}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n >\n \n {tooltipPos &&\n ReactDOM.createPortal(\n \n {tooltip}\n ,\n document.body,\n )}\n \n );\n}\n\nexport function EventDetailPanel({\n event,\n onEventChange,\n onPrevWeek,\n onNextWeek,\n headerActions,\n}: EventDetailPanelProps) {\n const color = event.color ?? \"blue\";\n const [eventType, setEventType] = React.useState(\"Event\");\n const [eventDropdownOpen, setEventDropdownOpen] = React.useState(false);\n const [hoveredOther, setHoveredOther] = React.useState(false);\n /** Whether the compact \"All-day / Time zone / Repeat\" row is expanded into individual rows. */\n const [optionsExpanded, setOptionsExpanded] = React.useState(false);\n const [titleValue, setTitleValue] = React.useState(event.title);\n const titleRef = React.useRef(null);\n const escapePressedRef = React.useRef(false);\n /** Stores the title when the input gains focus, used to restore on Escape. */\n const titleOnFocusRef = React.useRef(event.title);\n\n React.useEffect(() => {\n setTitleValue(event.title);\n }, [event.title]);\n\n // Reset expanded options when switching to a different event\n React.useEffect(() => {\n setOptionsExpanded(false);\n }, [event.id]);\n\n const handleTitleChange = React.useCallback(\n (e: React.ChangeEvent) => {\n const next = e.target.value;\n setTitleValue(next);\n onEventChange?.({ ...event, title: next });\n },\n [event, onEventChange],\n );\n\n const handleTitleFocus = React.useCallback(() => {\n titleOnFocusRef.current = event.title;\n }, [event.title]);\n\n const commitTitle = React.useCallback(() => {\n if (escapePressedRef.current) {\n escapePressedRef.current = false;\n return;\n }\n const trimmed = titleValue.trim();\n if (trimmed === titleValue) {\n return;\n }\n onEventChange?.({ ...event, title: trimmed });\n }, [titleValue, event, onEventChange]);\n\n const handleTitleKeyDown = React.useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n titleRef.current?.blur();\n return;\n }\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n escapePressedRef.current = true;\n const original = titleOnFocusRef.current;\n setTitleValue(original);\n onEventChange?.({ ...event, title: original });\n titleRef.current?.blur();\n }\n },\n [event, onEventChange],\n );\n\n // --- Start time input state & handlers ---\n const [startTimeValue, setStartTimeValue] = React.useState(() =>\n formatTimeDisplay(event.start),\n );\n const startTimeRef = React.useRef(null);\n const startTimeEscapePressedRef = React.useRef(false);\n const startTimeOnFocusRef = React.useRef(formatTimeDisplay(event.start));\n\n React.useEffect(() => {\n setStartTimeValue(formatTimeDisplay(event.start));\n }, [event.start]);\n\n const handleStartTimeChange = React.useCallback(\n (e: React.ChangeEvent) => {\n setStartTimeValue(e.target.value);\n },\n [],\n );\n\n const handleStartTimeFocus = React.useCallback(() => {\n startTimeOnFocusRef.current = formatTimeDisplay(event.start);\n requestAnimationFrame(() => {\n startTimeRef.current?.select();\n });\n }, [event.start]);\n\n const commitStartTime = React.useCallback(() => {\n if (startTimeEscapePressedRef.current) {\n startTimeEscapePressedRef.current = false;\n return;\n }\n const parsed = parseTimeInput(startTimeValue);\n if (!parsed) {\n setStartTimeValue(startTimeOnFocusRef.current);\n return;\n }\n const newStart = applyTimeToDate(event.start, parsed.hours, parsed.minutes);\n if (newStart.getTime() >= event.end.getTime()) {\n setStartTimeValue(startTimeOnFocusRef.current);\n return;\n }\n setStartTimeValue(formatTimeDisplay(newStart));\n onEventChange?.({ ...event, start: newStart });\n }, [startTimeValue, event, onEventChange]);\n\n const handleStartTimeKeyDown = React.useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n startTimeRef.current?.blur();\n return;\n }\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n startTimeEscapePressedRef.current = true;\n setStartTimeValue(startTimeOnFocusRef.current);\n startTimeRef.current?.blur();\n }\n },\n [],\n );\n\n // --- End time input state & handlers ---\n const [endTimeValue, setEndTimeValue] = React.useState(() =>\n formatTimeDisplay(event.end),\n );\n const endTimeRef = React.useRef(null);\n const endTimeEscapePressedRef = React.useRef(false);\n const endTimeOnFocusRef = React.useRef(formatTimeDisplay(event.end));\n\n React.useEffect(() => {\n setEndTimeValue(formatTimeDisplay(event.end));\n }, [event.end]);\n\n const handleEndTimeChange = React.useCallback(\n (e: React.ChangeEvent) => {\n setEndTimeValue(e.target.value);\n },\n [],\n );\n\n const handleEndTimeFocus = React.useCallback(() => {\n endTimeOnFocusRef.current = formatTimeDisplay(event.end);\n requestAnimationFrame(() => {\n endTimeRef.current?.select();\n });\n }, [event.end]);\n\n const commitEndTime = React.useCallback(() => {\n if (endTimeEscapePressedRef.current) {\n endTimeEscapePressedRef.current = false;\n return;\n }\n const parsed = parseTimeInput(endTimeValue);\n if (!parsed) {\n setEndTimeValue(endTimeOnFocusRef.current);\n return;\n }\n const newEnd = applyTimeToDate(event.end, parsed.hours, parsed.minutes);\n if (newEnd.getTime() <= event.start.getTime()) {\n setEndTimeValue(endTimeOnFocusRef.current);\n return;\n }\n setEndTimeValue(formatTimeDisplay(newEnd));\n onEventChange?.({ ...event, end: newEnd });\n }, [endTimeValue, event, onEventChange]);\n\n const handleEndTimeKeyDown = React.useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n endTimeRef.current?.blur();\n return;\n }\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n endTimeEscapePressedRef.current = true;\n setEndTimeValue(endTimeOnFocusRef.current);\n endTimeRef.current?.blur();\n }\n },\n [],\n );\n\n // --- Date input state & handlers ---\n const [dateValue, setDateValue] = React.useState(() =>\n formatDateDisplay(event.start),\n );\n const dateRef = React.useRef(null);\n const dateEscapePressedRef = React.useRef(false);\n const dateOnFocusRef = React.useRef(formatDateDisplay(event.start));\n\n React.useEffect(() => {\n setDateValue(formatDateDisplay(event.start));\n }, [event.start]);\n\n const handleDateChange = React.useCallback(\n (e: React.ChangeEvent) => {\n setDateValue(e.target.value);\n },\n [],\n );\n\n const handleDateFocus = React.useCallback(() => {\n dateOnFocusRef.current = formatDateDisplay(event.start);\n requestAnimationFrame(() => {\n dateRef.current?.select();\n });\n }, [event.start]);\n\n const commitDate = React.useCallback(() => {\n if (dateEscapePressedRef.current) {\n dateEscapePressedRef.current = false;\n return;\n }\n const parsed = parseDateInput(dateValue, event.start);\n if (!parsed) {\n setDateValue(dateOnFocusRef.current);\n return;\n }\n const dayDiff = differenceInCalendarDays(parsed, event.start);\n if (dayDiff === 0) {\n setDateValue(formatDateDisplay(event.start));\n return;\n }\n const newStart = addDays(event.start, dayDiff);\n const newEnd = addDays(event.end, dayDiff);\n setDateValue(formatDateDisplay(newStart));\n onEventChange?.({ ...event, start: newStart, end: newEnd });\n }, [dateValue, event, onEventChange]);\n\n const handleDateKeyDown = React.useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n dateRef.current?.blur();\n return;\n }\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n dateEscapePressedRef.current = true;\n setDateValue(dateOnFocusRef.current);\n dateRef.current?.blur();\n }\n },\n [],\n );\n\n // --- End date input state & handlers (shown only for all-day events) ---\n const [endDateValue, setEndDateValue] = React.useState(() =>\n formatDateDisplay(event.end),\n );\n const endDateRef = React.useRef(null);\n const endDateEscapePressedRef = React.useRef(false);\n const endDateOnFocusRef = React.useRef(formatDateDisplay(event.end));\n\n React.useEffect(() => {\n setEndDateValue(formatDateDisplay(event.end));\n }, [event.end]);\n\n const handleEndDateChange = React.useCallback(\n (e: React.ChangeEvent) => {\n setEndDateValue(e.target.value);\n },\n [],\n );\n\n const handleEndDateFocus = React.useCallback(() => {\n endDateOnFocusRef.current = formatDateDisplay(event.end);\n requestAnimationFrame(() => {\n endDateRef.current?.select();\n });\n }, [event.end]);\n\n const commitEndDate = React.useCallback(() => {\n if (endDateEscapePressedRef.current) {\n endDateEscapePressedRef.current = false;\n return;\n }\n const parsed = parseDateInput(endDateValue, event.end);\n if (!parsed) {\n setEndDateValue(endDateOnFocusRef.current);\n return;\n }\n const dayDiff = differenceInCalendarDays(parsed, event.end);\n if (dayDiff === 0) {\n setEndDateValue(formatDateDisplay(event.end));\n return;\n }\n const newEnd = addDays(event.end, dayDiff);\n if (newEnd.getTime() < event.start.getTime()) {\n setEndDateValue(endDateOnFocusRef.current);\n return;\n }\n setEndDateValue(formatDateDisplay(newEnd));\n onEventChange?.({ ...event, end: newEnd });\n }, [endDateValue, event, onEventChange]);\n\n const handleEndDateKeyDown = React.useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n endDateRef.current?.blur();\n return;\n }\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n endDateEscapePressedRef.current = true;\n setEndDateValue(endDateOnFocusRef.current);\n endDateRef.current?.blur();\n }\n },\n [],\n );\n\n // --- All-day toggle handler ---\n /**\n * Stores the original hours/minutes before toggling to all-day.\n * When toggling off, these are applied to the current (possibly resized) dates.\n */\n const savedTimeOfDayRef = React.useRef<{\n startHours: number;\n startMinutes: number;\n endHours: number;\n endMinutes: number;\n } | null>(null);\n\n const handleAllDayToggle = React.useCallback(\n (checked: boolean) => {\n if (checked) {\n savedTimeOfDayRef.current = {\n startHours: event.start.getHours(),\n startMinutes: event.start.getMinutes(),\n endHours: event.end.getHours(),\n endMinutes: event.end.getMinutes(),\n };\n onEventChange?.({ ...event, isAllDay: true });\n return;\n }\n\n if (savedTimeOfDayRef.current) {\n const { startHours, startMinutes, endHours, endMinutes } =\n savedTimeOfDayRef.current;\n onEventChange?.({\n ...event,\n isAllDay: false,\n start: applyTimeToDate(event.start, startHours, startMinutes),\n end: applyTimeToDate(event.end, endHours, endMinutes),\n });\n savedTimeOfDayRef.current = null;\n return;\n }\n\n /** Default 9 AM – 10 AM when no saved times (e.g., existing all-day event). */\n const DEFAULT_START_HOUR = 9;\n const DEFAULT_END_HOUR = 10;\n onEventChange?.({\n ...event,\n isAllDay: false,\n start: applyTimeToDate(event.start, DEFAULT_START_HOUR, 0),\n end: applyTimeToDate(event.end, DEFAULT_END_HOUR, 0),\n });\n },\n [event, onEventChange],\n );\n\n const otherTypes = EVENT_TYPES.filter((t) => t !== eventType);\n\n return (\n
\n {/* Header */}\n
\n {\n setEventDropdownOpen(open);\n if (open) setHoveredOther(false);\n }}\n >\n \n \n {eventType}\n \n \n \n setHoveredOther(false)}\n >\n setEventType(eventType)}\n onMouseEnter={() => setHoveredOther(false)}\n >\n \n {eventType}\n \n \n \n {otherTypes.map((type) => (\n setEventType(type)}\n onMouseEnter={() => setHoveredOther(true)}\n >\n {type}\n \n \n ))}\n \n \n
\n \n \n \n \n \n \n \n \n \n Cut\n \n ⌘X\n \n \n \n \n Copy\n \n ⌘C\n \n \n \n \n Duplicate\n \n ⌘D\n \n \n \n svg]:!text-white focus:[&>[data-slot=dropdown-menu-shortcut]]:!text-white\">\n \n Delete\n \n delete\n \n \n \n \n {headerActions}\n
\n
\n\n {/* Title */}\n \n\n {/* Divider */}\n
\n\n {/* Time — muted and non-interactive for all-day events */}\n {(event.start.getHours() !== 0 ||\n event.start.getMinutes() !== 0 ||\n event.end.getHours() !== 0 ||\n event.end.getMinutes() !== 0) && (\n
\n {/* Start time group — Clock icon + input in one bordered container */}\n startTimeRef.current?.focus()\n }\n >\n \n \n
\n {/* End time group — arrow + input + duration in one bordered container */}\n endTimeRef.current?.focus()\n }\n >\n \n →\n \n \n \n {formatDuration(event.start, event.end)}\n \n
\n
\n )}\n\n {/* Date — editable inline input(s), indented to align with time text */}\n \n {/* Start date */}\n dateRef.current?.focus()}\n >\n \n \n {/* End date — only visible for all-day events */}\n {event.isAllDay && (\n endDateRef.current?.focus()}\n >\n \n \n )}\n \n\n {optionsExpanded || event.isAllDay ? (\n <>\n {/* All-day toggle row — clicking label or row triggers toggle */}\n handleAllDayToggle(!(event.isAllDay ?? false))}\n >\n e.stopPropagation()}\n className=\"data-[state=unchecked]:!bg-[#C7C5C1] dark:data-[state=unchecked]:!bg-[#595959] data-[state=checked]:!bg-[#3A85D3]\"\n />\n All-day\n \n\n {/* Timezone row — hidden when all-day */}\n {!event.isAllDay && (\n
\n \n \n
\n )}\n\n {/* Recurrence row — active display for recurring, placeholder for non-recurring */}\n {event.recurrence ? (\n
\n \n
\n \n
\n \n \n \n \n \n \n
\n
\n
\n ) : (\n
\n \n \n Repeat\n \n
\n )}\n \n ) : (\n
\n setOptionsExpanded(true)}\n >\n \n All-day\n \n \n Time zone\n \n \n Repeat\n \n
\n \n )}\n\n {/* Divider */}\n
\n\n {/* Field sections */}\n
\n \n \n \n \n
\n\n {/* Divider */}\n
\n\n {/* Description */}\n
\n \n Description\n \n {event.description && (\n {event.description}\n )}\n
\n\n {/* Divider */}\n
\n\n {/* Calendar */}\n
\n
\n \n {event.calendarEmail ?? event.calendarId ?? \"Calendar\"}\n \n
\n\n {/* Status */}\n
\n \n {event.status ?? \"Busy\"}\n \n \n {formatVisibility(event.visibility)}\n \n
\n\n {/* Reminders */}\n
\n
\n \n \n Reminders\n \n
\n {event.reminders &&\n event.reminders.length > 0 &&\n event.reminders.map((reminder) => (\n \n \n {reminder.amount}\n {reminder.unit.replace(/s$/, \"\")}\n {\" \"}\n before\n \n ))}\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/event-detail-panel.tsx" }, { "path": "components/layouts/calendar/event-detail-popover.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { PanelRightIcon, X } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { PopoverContent } from \"@/components/ui/popover\";\nimport { EventDetailPanel } from \"./event-detail-panel\";\nimport { useCalendarPopoverBoundary } from \"./calendar-popover-context\";\nimport type { CalendarEvent } from \"./week-view-types\";\n\ninterface EventDetailPopoverProps {\n event: CalendarEvent;\n onEventChange?: (event: CalendarEvent) => void;\n onClose: () => void;\n onDockToSidebar: () => void;\n onPrevWeek?: () => void;\n onNextWeek?: () => void;\n /** Which side to prefer for the popover. Defaults to \"right\". */\n side?: \"right\" | \"bottom\" | \"left\" | \"top\";\n /** Alignment along the side axis. Defaults to \"center\". */\n align?: \"start\" | \"center\" | \"end\";\n /**\n * Override the top collision padding. When omitted the header height is\n * used so the popover never overlaps the weekday header. All-day events\n * live *inside* the header, so they pass a small value to avoid being\n * pushed off-screen.\n */\n collisionPaddingTop?: number;\n}\n\nexport function EventDetailPopover({\n event,\n onEventChange,\n onClose,\n onDockToSidebar,\n onPrevWeek,\n onNextWeek,\n side = \"right\",\n align = \"center\",\n collisionPaddingTop,\n}: EventDetailPopoverProps) {\n const { boundary, headerHeight, view } = useCalendarPopoverBoundary();\n\n /**\n * In day view the event trigger spans the full grid width, leaving no room\n * for a 320px popover on either side within the calendar container.\n * Skip the collision boundary so Radix uses the viewport instead.\n */\n const isDayView = view === \"day\";\n const effectiveBoundary = isDayView\n ? undefined\n : boundary\n ? [boundary]\n : undefined;\n\n const popoverHeaderActions = (\n <>\n {\n e.stopPropagation();\n onDockToSidebar();\n }}\n title=\"Dock to sidebar\"\n >\n \n \n {\n e.stopPropagation();\n onClose();\n }}\n title=\"Close\"\n >\n \n \n \n );\n\n return (\n e.preventDefault()}\n onCloseAutoFocus={(e) => e.preventDefault()}\n onInteractOutside={(e) => {\n const target = e.target as HTMLElement;\n if (target.closest(\"[data-radix-popper-content-wrapper]\")) {\n e.preventDefault();\n }\n }}\n >\n \n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/event-detail-popover.tsx" }, { "path": "components/layouts/calendar/nav-favorites.tsx", "content": "\"use client\";\n\nimport {\n ArrowUpRight,\n Link,\n MoreHorizontal,\n StarOff,\n Trash2,\n} from \"lucide-react\";\n\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n SidebarGroup,\n SidebarGroupLabel,\n SidebarMenu,\n SidebarMenuAction,\n SidebarMenuButton,\n SidebarMenuItem,\n useSidebar,\n} from \"@/components/ui/sidebar\";\n\nexport function NavFavorites({\n favorites,\n}: {\n favorites: {\n name: string;\n url: string;\n emoji: string;\n }[];\n}) {\n const { isMobile } = useSidebar();\n\n return (\n \n Favorites\n \n {favorites.map((item) => (\n \n \n \n {item.emoji}\n {item.name}\n \n \n \n \n \n \n More\n \n \n \n \n \n Remove from Favorites\n \n \n \n \n Copy Link\n \n \n \n Open in New Tab\n \n \n \n \n Delete\n \n \n \n \n ))}\n \n \n \n More\n \n \n \n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/nav-favorites.tsx" }, { "path": "components/layouts/calendar/nav-main.tsx", "content": "\"use client\";\n\nimport { type LucideIcon } from \"lucide-react\";\n\nimport {\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n} from \"@/components/ui/sidebar\";\n\nexport function NavMain({\n items,\n}: {\n items: {\n title: string;\n url: string;\n icon: LucideIcon;\n isActive?: boolean;\n }[];\n}) {\n return (\n \n {items.map((item) => (\n \n \n \n \n {item.title}\n \n \n \n ))}\n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/nav-main.tsx" }, { "path": "components/layouts/calendar/nav-secondary.tsx", "content": "import React from \"react\";\nimport { type LucideIcon } from \"lucide-react\";\n\nimport {\n SidebarGroup,\n SidebarGroupContent,\n SidebarMenu,\n SidebarMenuBadge,\n SidebarMenuButton,\n SidebarMenuItem,\n} from \"@/components/ui/sidebar\";\n\nexport function NavSecondary({\n items,\n ...props\n}: {\n items: {\n title: string;\n url: string;\n icon: LucideIcon;\n badge?: React.ReactNode;\n }[];\n} & React.ComponentPropsWithoutRef) {\n return (\n \n \n \n {items.map((item) => (\n \n \n \n \n {item.title}\n \n \n {item.badge && {item.badge}}\n \n ))}\n \n \n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/nav-secondary.tsx" }, { "path": "components/layouts/calendar/nav-user.tsx", "content": "\"use client\";\n\nimport {\n BadgeCheck,\n Bell,\n ChevronsUpDown,\n CreditCard,\n LogOut,\n Sparkles,\n} from \"lucide-react\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n useSidebar,\n} from \"@/components/ui/sidebar\";\n\nexport function NavUser({\n user,\n}: {\n user: {\n name: string;\n email: string;\n avatar: string;\n };\n}) {\n const { isMobile } = useSidebar();\n\n return (\n \n \n \n \n \n \n \n VN\n \n
\n {user.name}\n {user.email}\n
\n \n \n
\n \n \n
\n \n \n VN\n \n
\n {user.name}\n {user.email}\n
\n
\n
\n \n \n \n \n Upgrade to Pro\n \n \n \n \n \n \n Account\n \n \n \n Billing\n \n \n \n Notifications\n \n \n \n \n \n Log out\n \n \n
\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/nav-user.tsx" }, { "path": "components/layouts/calendar/nav-workspaces.tsx", "content": "import { ChevronRight, MoreHorizontal, Plus } from \"lucide-react\";\n\nimport {\n Collapsible,\n CollapsibleContent,\n CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport {\n SidebarGroup,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarMenu,\n SidebarMenuAction,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarMenuSub,\n SidebarMenuSubButton,\n SidebarMenuSubItem,\n} from \"@/components/ui/sidebar\";\n\nexport function NavWorkspaces({\n workspaces,\n}: {\n workspaces: {\n name: string;\n emoji: React.ReactNode;\n pages: {\n name: string;\n emoji: React.ReactNode;\n }[];\n }[];\n}) {\n return (\n \n Workspaces\n \n \n {workspaces.map((workspace) => (\n \n \n \n \n {workspace.emoji}\n {workspace.name}\n \n \n \n \n \n \n \n \n \n \n \n \n {workspace.pages.map((page) => (\n \n \n \n {page.emoji}\n {page.name}\n \n \n \n ))}\n \n \n \n \n ))}\n \n \n \n More\n \n \n \n \n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/nav-workspaces.tsx" }, { "path": "components/layouts/calendar/sidebar-left.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowLeft, CalendarSearch, PanelRightIcon, X } from \"lucide-react\";\nimport {\n differenceInMinutes,\n format,\n isBefore,\n isSameYear,\n isToday,\n isTomorrow,\n startOfDay,\n} from \"date-fns\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport {\n Sidebar,\n SidebarContent,\n SidebarGroup,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarHeader,\n useSidebar,\n} from \"@/components/ui/sidebar\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { eventColorStyles } from \"./calendar-event-item\";\nimport { EventDetailPanel } from \"./event-detail-panel\";\nimport type { CalendarEvent } from \"./week-view-types\";\n\ninterface SidebarLeftProps extends React.ComponentProps {\n events?: CalendarEvent[];\n selectedEvent?: CalendarEvent | null;\n onEventChange?: (event: CalendarEvent) => void;\n onPrevWeek?: () => void;\n onNextWeek?: () => void;\n}\n\ninterface DateGroup {\n key: string;\n label: string;\n isToday: boolean;\n events: CalendarEvent[];\n}\n\nfunction formatDuration(start: Date, end: Date): string {\n const totalMinutes = differenceInMinutes(end, start);\n const hours = Math.floor(totalMinutes / 60);\n const minutes = totalMinutes % 60;\n\n if (hours === 0) {\n return `${minutes}min`;\n }\n if (minutes === 0) {\n return `${hours}h`;\n }\n return `${hours}h ${minutes}min`;\n}\n\nfunction formatTimeRange(event: CalendarEvent): string {\n if (event.isAllDay) {\n return \"All day\";\n }\n const startPeriod = format(event.start, \"a\");\n const endPeriod = format(event.end, \"a\");\n const endStr = format(event.end, \"h:mm a\").replace(\":00 \", \" \");\n\n if (startPeriod === endPeriod) {\n const startStr = format(event.start, \"h:mm\").replace(\":00\", \"\");\n return `${startStr}\\u2013${endStr}`;\n }\n\n const startStr = format(event.start, \"h:mm a\").replace(\":00 \", \" \");\n return `${startStr}\\u2013${endStr}`;\n}\n\nfunction formatDateHeader(date: Date): {\n label: string;\n isTodayGroup: boolean;\n} {\n if (isToday(date)) {\n return { label: \"Today\", isTodayGroup: true };\n }\n if (isTomorrow(date)) {\n return { label: \"Tomorrow\", isTodayGroup: false };\n }\n if (isSameYear(date, new Date())) {\n return { label: format(date, \"EEE MMM d\"), isTodayGroup: false };\n }\n return { label: format(date, \"EEE MMM d, yyyy\"), isTodayGroup: false };\n}\n\nfunction groupEventsByDate(events: CalendarEvent[]): DateGroup[] {\n const grouped = new Map();\n\n for (const event of events) {\n const dayKey = format(startOfDay(event.start), \"yyyy-MM-dd\");\n const existing = grouped.get(dayKey);\n if (existing) {\n existing.push(event);\n } else {\n grouped.set(dayKey, [event]);\n }\n }\n\n const groups: DateGroup[] = [];\n for (const [key, groupEvents] of grouped) {\n const date = groupEvents[0].start;\n const { label, isTodayGroup } = formatDateHeader(date);\n const sorted = groupEvents.sort(\n (a, b) => a.start.getTime() - b.start.getTime(),\n );\n groups.push({ key, label, isToday: isTodayGroup, events: sorted });\n }\n\n return groups.sort((a, b) => a.key.localeCompare(b.key));\n}\n\nexport function SidebarLeft({\n events = [],\n selectedEvent,\n onEventChange,\n onPrevWeek,\n onNextWeek,\n ...props\n}: SidebarLeftProps) {\n const { toggleSidebar } = useSidebar();\n const [searchQuery, setSearchQuery] = React.useState(\"\");\n const [debouncedQuery, setDebouncedQuery] = React.useState(\"\");\n const [searchSelectedEvent, setSearchSelectedEvent] =\n React.useState(null);\n const inputRef = React.useRef(null);\n\n const isSearching = searchQuery.trim().length > 0;\n const isLoadingResults = isSearching && searchQuery !== debouncedQuery;\n\n React.useEffect(() => {\n if (!isSearching) {\n setDebouncedQuery(\"\");\n return;\n }\n const timer = setTimeout(() => {\n setDebouncedQuery(searchQuery);\n }, 400);\n return () => clearTimeout(timer);\n }, [searchQuery, isSearching]);\n\n const resolvedSearchEvent = React.useMemo(() => {\n if (!searchSelectedEvent) return null;\n return (\n events.find((e) => e.id === searchSelectedEvent.id) ?? searchSelectedEvent\n );\n }, [events, searchSelectedEvent]);\n\n React.useEffect(() => {\n if (selectedEvent) {\n setSearchSelectedEvent(null);\n }\n }, [selectedEvent]);\n\n const searchResults = React.useMemo(() => {\n if (!debouncedQuery.trim()) {\n return [];\n }\n const query = debouncedQuery.trim().toLowerCase();\n return events.filter((event) => event.title.toLowerCase().includes(query));\n }, [events, debouncedQuery]);\n\n const { pastGroups, todayGroup, futureGroups } = React.useMemo(() => {\n const allGroups = groupEventsByDate(searchResults);\n const now = new Date();\n const todayStart = startOfDay(now);\n\n const past: DateGroup[] = [];\n let today: DateGroup | null = null;\n const future: DateGroup[] = [];\n\n for (const group of allGroups) {\n if (group.isToday) {\n today = group;\n continue;\n }\n const groupDate = new Date(group.key);\n if (isBefore(groupDate, todayStart)) {\n past.push(group);\n continue;\n }\n future.push(group);\n }\n\n return { pastGroups: past, todayGroup: today, futureGroups: future };\n }, [searchResults]);\n\n const hasUpcomingResults = todayGroup !== null || futureGroups.length > 0;\n\n return (\n \n \n
\n {selectedEvent ? (\n
\n ) : resolvedSearchEvent ? (\n <>\n svg]:px-2 text-xs text-[#91908F] justify-start\"\n onClick={() => setSearchSelectedEvent(null)}\n >\n \n Search\n \n
\n \n ) : (\n inputRef.current?.focus()}\n >\n \n setSearchQuery(e.target.value)}\n className=\"text-foreground placeholder:text-muted-foreground h-7 w-full border-none bg-transparent p-0 text-xs outline-none\"\n />\n {isSearching && (\n {\n e.stopPropagation();\n setSearchQuery(\"\");\n inputRef.current?.focus();\n }}\n >\n \n \n )}\n
\n )}\n \n \n \n \n \n \n \n Close context panel /\n \n \n
\n \n \n {selectedEvent ? (\n \n ) : resolvedSearchEvent ? (\n \n ) : isLoadingResults ? (\n
\n

\n Searching\n \n

\n
\n ) : isSearching ? (\n
\n {pastGroups.map((group) => (\n \n ))}\n
\n

\n Today\n

\n {todayGroup && (\n
\n {todayGroup.events.map((event) => (\n \n ))}\n
\n )}\n {!hasUpcomingResults && (\n

\n No upcoming results\n

\n )}\n
\n {futureGroups.map((group) => (\n \n ))}\n
\n ) : (\n \n \n Useful shortcuts\n \n \n
\n \n \n K\n \n \n \n \n K\n \n \n `\n \n \n P\n \n \n .\n \n \n ?\n \n
\n
\n
\n )}\n
\n \n );\n}\n\nfunction DateGroupSection({\n group,\n isPast = false,\n onEventClick,\n}: {\n group: DateGroup;\n isPast?: boolean;\n onEventClick?: (event: CalendarEvent) => void;\n}) {\n return (\n
\n \n {group.label}\n

\n
\n {group.events.map((event) => (\n \n ))}\n
\n
\n );\n}\n\n/** Color tokens for search result items by temporal state */\nconst SEARCH_RESULT_COLORS = {\n future: {\n title: \"text-[#32302C] dark:text-[#D4D4D4]\",\n time: \"text-[#787774] dark:text-[#7F7F7F]\",\n duration: \"text-[#ABABA9] dark:text-[#5A5A5A]\",\n hover: \"hover:bg-[#F5F5F5] dark:hover:bg-[#252525]\",\n },\n past: {\n title: \"text-[#989795] dark:text-[#777]\",\n time: \"text-[#BBBBB9] dark:text-[#4C4C4C]\",\n duration: \"text-[#D5D5D4] dark:text-[#3A3A3A]\",\n hover: \"hover:bg-[#FAFAFA] dark:hover:bg-[#1F1F1F]\",\n },\n} as const;\n\n/**\n * Sequential dot fill animation.\n * Steps: fill dot 0 → fill dot 1 → fill dot 2 → unfill dot 0 → unfill dot 1 → unfill dot 2\n * Each dot is either 10% or 100% opacity based on the current step.\n */\nconst DOT_STEP_INTERVAL_MS = 300;\nconst DOT_STEPS = [\n [false, false, false],\n [true, false, false],\n [true, true, false],\n [true, true, true],\n [false, true, true],\n [false, false, true],\n] as const;\n\nfunction AnimatedDots() {\n const [step, setStep] = React.useState(0);\n\n React.useEffect(() => {\n const interval = setInterval(() => {\n setStep((prev) => (prev + 1) % DOT_STEPS.length);\n }, DOT_STEP_INTERVAL_MS);\n return () => clearInterval(interval);\n }, []);\n\n const filled = DOT_STEPS[step];\n\n return (\n \n {[0, 1, 2].map((i) => (\n \n ))}\n \n );\n}\n\nfunction SearchResultItem({\n event,\n isPast = false,\n onClick,\n}: {\n event: CalendarEvent;\n isPast?: boolean;\n onClick?: (event: CalendarEvent) => void;\n}) {\n const timeRange = formatTimeRange(event);\n const duration = event.isAllDay ? \"\" : formatDuration(event.start, event.end);\n const colors = isPast\n ? SEARCH_RESULT_COLORS.past\n : SEARCH_RESULT_COLORS.future;\n\n return (\n onClick?.(event)}\n >\n \n
\n \n {event.title}\n

\n

\n {timeRange}\n {duration && {duration}}\n

\n
\n
\n );\n}\n\nfunction ShortcutRow({\n label,\n children,\n}: {\n label: string;\n children: React.ReactNode;\n}) {\n return (\n
\n {label}\n
{children}
\n
\n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/sidebar-left.tsx" }, { "path": "components/layouts/calendar/sidebar-right.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Eye, Github, Link2, Plus, UserRound } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Calendars } from \"@/components/layouts/calendar/calendars\";\nimport { DatePicker } from \"@/components/layouts/calendar/date-picker\";\nimport { ModeToggle } from \"@/components/mode-toggle\";\nimport { Button } from \"@/components/ui/button\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport {\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupContent,\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarSeparator,\n} from \"@/components/ui/sidebar\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nconst SIDEBAR_WIDTH = \"15rem\";\n\nexport type CalendarColor =\n | \"red\"\n | \"orange\"\n | \"yellow\"\n | \"green\"\n | \"blue\"\n | \"purple\"\n | \"gray\";\n\nexport interface CalendarItem {\n name: string;\n color: CalendarColor;\n visible: boolean;\n isSubscribed?: boolean;\n}\n\nexport interface CalendarAccount {\n email: string;\n calendars: CalendarItem[];\n}\n\n// Sample data grouped by email accounts\nconst data: { accounts: CalendarAccount[] } = {\n accounts: [\n {\n email: \"you@example.com\",\n calendars: [\n { name: \"you@example.com\", color: \"red\", visible: true },\n { name: \"Personal\", color: \"purple\", visible: true },\n { name: \"Work\", color: \"blue\", visible: true },\n { name: \"Family\", color: \"orange\", visible: true },\n { name: \"Side Projects\", color: \"yellow\", visible: true },\n { name: \"Fitness\", color: \"green\", visible: true },\n {\n name: \"Holidays in Brazil\",\n color: \"green\",\n visible: true,\n isSubscribed: true,\n },\n ],\n },\n ],\n};\n\ninterface SidebarRightProps {\n open?: boolean;\n onDateSelect?: (date: Date) => void;\n currentDate?: Date;\n visibleDays?: Date[];\n}\n\nexport function SidebarRight({\n open = true,\n onDateSelect,\n currentDate,\n visibleDays,\n}: SidebarRightProps) {\n return (\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/sidebar-right.tsx" }, { "path": "components/layouts/calendar/team-switcher.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronDown, Plus } from \"lucide-react\";\n\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuShortcut,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n} from \"@/components/ui/sidebar\";\n\nexport function TeamSwitcher({\n teams,\n}: {\n teams: {\n name: string;\n logo: React.ElementType;\n plan: string;\n }[];\n}) {\n const [activeTeam, setActiveTeam] = React.useState(teams[0]);\n\n if (!activeTeam) {\n return null;\n }\n\n return (\n \n \n \n \n \n
\n \n
\n {activeTeam.name}\n \n
\n
\n \n \n Teams\n \n {teams.map((team, index) => (\n setActiveTeam(team)}\n className=\"gap-2 p-2\"\n >\n
\n \n
\n {team.name}\n ⌘{index + 1}\n \n ))}\n \n \n
\n \n
\n
Add team
\n
\n \n
\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/team-switcher.tsx" }, { "path": "components/layouts/calendar/view-dropdown.tsx", "content": "\"use client\";\n\nimport { CheckIcon, ChevronDownIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Kbd, KbdGroup } from \"@/components/ui/kbd\";\n\nimport type {\n ViewSettings,\n ViewType,\n} from \"@/components/layouts/calendar/week-view-types\";\n\n/** Display labels for each view type */\nconst VIEW_LABELS: Record = {\n day: \"Day\",\n week: \"Week\",\n month: \"Month\",\n};\n\n/** View mode options with their shortcut labels */\nconst VIEW_OPTIONS: Array<{\n value: ViewType;\n label: string;\n shortcuts: Array;\n}> = [\n { value: \"day\", label: \"Day\", shortcuts: [\"1\", \"or\", \"D\"] },\n { value: \"week\", label: \"Week\", shortcuts: [\"0\", \"or\", \"W\"] },\n { value: \"month\", label: \"Month\", shortcuts: [\"M\"] },\n];\n\n/** Number-of-days options for the submenu */\nconst DAYS_OPTIONS = [2, 3, 4, 5, 6, 7, 8, 9] as const;\n\ninterface ViewDropdownProps {\n view: ViewType;\n numberOfDays: number;\n viewSettings: ViewSettings;\n onSwitchView: (view: ViewType) => void;\n onSetNumberOfDays: (count: number) => void;\n onToggleWeekends: () => void;\n onToggleDeclinedEvents: () => void;\n onToggleWeekNumbers: () => void;\n}\n\nexport function ViewDropdown({\n view,\n numberOfDays,\n viewSettings,\n onSwitchView,\n onSetNumberOfDays,\n onToggleWeekends,\n onToggleDeclinedEvents,\n onToggleWeekNumbers,\n}: ViewDropdownProps) {\n return (\n \n \n \n \n \n {VIEW_OPTIONS.map((option) => (\n onSwitchView(option.value)}\n >\n \n {option.label}\n \n {option.shortcuts.map((s) =>\n s === \"or\" ? (\n \n or\n \n ) : (\n \n {s}\n \n ),\n )}\n \n \n ))}\n\n \n\n {/* Number of days submenu */}\n \n \n Number of days\n \n \n {DAYS_OPTIONS.map((n) => (\n onSetNumberOfDays(n)}>\n \n {n} days\n \n {n}\n \n \n ))}\n \n Other...\n \n \n\n \n\n {/* View settings submenu */}\n \n \n View settings\n \n \n onToggleWeekends()}\n >\n Weekends\n \n \n \n E\n \n \n\n onToggleDeclinedEvents()}\n >\n Declined eve...\n \n \n \n D\n \n \n\n onToggleWeekNumbers()}\n >\n Week numbers\n \n\n \n\n \n General settings\n \n \n ,\n \n \n \n \n \n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/view-dropdown.tsx" }, { "path": "components/layouts/calendar/week-view-all-day-row.tsx", "content": "\"use client\";\n\nimport type React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\nimport { isPast, isSameDay } from \"date-fns\";\nimport { useCallback } from \"react\";\nimport { calculateAllDayEventRows } from \"@/lib/layouts/event-utils\";\nimport { AllDayEventItem } from \"./calendar-event-item\";\nimport { useCalendarPopoverBoundary } from \"./calendar-popover-context\";\nimport type { CalendarEvent, WeekViewAllDayRowProps } from \"./week-view-types\";\n\nconst ALL_DAY_EVENT_HEIGHT = 24;\nconst ALL_DAY_ROW_GAP = 2;\n\n/**\n * All-day row for displaying all-day events\n * Shows \"All-day\" label on the left with day columns\n */\nexport function WeekViewAllDayRow({\n days,\n allDayEvents = [],\n onEventClick,\n selectedEventId,\n scrollStyle,\n allDayResizeState,\n onAllDayResizeMouseDown,\n allDayScrollContentRef,\n onEventChange,\n onContextMenuOpenChange,\n isSidebarOpen,\n onDockToSidebar,\n onClosePopover,\n onPrevWeek,\n onNextWeek,\n visibleStartIndex,\n visibleCount,\n dayColumnWidth,\n className,\n}: WeekViewAllDayRowProps) {\n const allEventRows = calculateAllDayEventRows(allDayEvents, days);\n\n // Filter out events entirely within buffer columns so they don't peek\n // through due to sub-pixel rendering at the scroll boundary.\n const visibleEnd =\n visibleStartIndex != null && visibleCount != null\n ? visibleStartIndex + visibleCount - 1\n : days.length - 1;\n const eventRows =\n visibleStartIndex != null\n ? allEventRows.filter(\n ({ startColumn, endColumn }) =>\n endColumn >= visibleStartIndex && startColumn <= visibleEnd,\n )\n : allEventRows;\n const maxRow =\n eventRows.length > 0 ? Math.max(...eventRows.map((r) => r.row)) + 1 : 0;\n const contentHeight =\n maxRow > 0 ? maxRow * (ALL_DAY_EVENT_HEIGHT + ALL_DAY_ROW_GAP) + 8 : 32;\n\n const isMoveDrag =\n allDayResizeState?.isResizing && allDayResizeState.edge === \"move\";\n\n return (\n \n {/* All-day label */}\n
\n All-day\n
\n\n {/* Day columns for all-day events - wrapped for scroll sync */}\n
\n
\n
\n {/* Background grid */}\n \n {days.map((day) => {\n const isWeekend =\n day.date.getDay() === 0 || day.date.getDay() === 6;\n return (\n \n );\n })}\n
\n\n {/* Events */}\n
\n {eventRows.map(({ event, startColumn, endColumn, row }) => {\n const isBeingResized =\n allDayResizeState?.eventId === event.id &&\n allDayResizeState.isResizing;\n const isBeingMoved =\n isBeingResized && allDayResizeState.edge === \"move\";\n\n // For move: ghost stays at original, event renders at target\n // For resize: event renders at target (current columns)\n const displayStartColumn = isBeingResized\n ? isBeingMoved\n ? startColumn\n : allDayResizeState.currentStartColumn\n : startColumn;\n const displayEndColumn = isBeingResized\n ? isBeingMoved\n ? endColumn\n : allDayResizeState.currentEndColumn\n : endColumn;\n\n return (\n \n );\n })}\n\n {/* Placeholder at target position during move */}\n {isMoveDrag &&\n (() => {\n const movedRow = eventRows.find(\n (r) => r.event.id === allDayResizeState.eventId,\n );\n if (!movedRow) return null;\n return (\n \n );\n })()}\n
\n
\n
\n
\n\n {/* Floating drag copy via portal */}\n {isMoveDrag &&\n allDayResizeState.clientX != null &&\n allDayResizeState.clientY != null &&\n (() => {\n const movedRow = eventRows.find(\n (r) => r.event.id === allDayResizeState.eventId,\n );\n if (!movedRow) return null;\n const span = movedRow.endColumn - movedRow.startColumn + 1;\n const colWidthPx = dayColumnWidth ?? 100;\n const floatingWidth = span * colWidthPx;\n const offsetX = allDayResizeState.cursorOffsetX ?? 0;\n\n return createPortal(\n \n \n \n \n ,\n document.body,\n );\n })()}\n \n );\n}\n\ninterface AllDayEventRowProps {\n event: CalendarEvent;\n startColumn: number;\n endColumn: number;\n row: number;\n totalColumns: number;\n days: WeekViewAllDayRowProps[\"days\"];\n onEventClick?: (event: CalendarEvent) => void;\n isSelected?: boolean;\n onAllDayResizeMouseDown?: WeekViewAllDayRowProps[\"onAllDayResizeMouseDown\"];\n originalStartColumn: number;\n originalEndColumn: number;\n isBeingResized?: boolean;\n isBeingMoved?: boolean;\n /** Callback when an event is changed (e.g. color change from context menu) */\n onEventChange?: (event: CalendarEvent) => void;\n /** Callback when context menu open state changes */\n onContextMenuOpenChange?: (open: boolean) => void;\n isSidebarOpen?: boolean;\n onDockToSidebar?: () => void;\n onClosePopover?: () => void;\n onPrevWeek?: () => void;\n onNextWeek?: () => void;\n /** Index of the first visible column (for sticky-title offset) */\n visibleStartIndex?: number;\n}\n\nfunction AllDayEventRow({\n event,\n startColumn,\n endColumn,\n row,\n totalColumns,\n days,\n onEventClick,\n isSelected,\n onAllDayResizeMouseDown,\n originalStartColumn,\n originalEndColumn,\n isBeingResized,\n isBeingMoved,\n onEventChange,\n onContextMenuOpenChange,\n isSidebarOpen,\n onDockToSidebar,\n onClosePopover,\n onPrevWeek,\n onNextWeek,\n visibleStartIndex,\n}: AllDayEventRowProps) {\n const { view } = useCalendarPopoverBoundary();\n const isDayView = view === \"day\";\n\n const left = (startColumn / totalColumns) * 100;\n // Day view uses a smaller right gap than week view so events nearly fill\n // the column but still show a sliver of the grid \\u2014 matching Notion Calendar.\n const columnWidth = 100 / totalColumns;\n const rightGap = isDayView ? columnWidth * 0.02 : columnWidth * 0.08;\n const width = ((endColumn - startColumn + 1) / totalColumns) * 100 - rightGap;\n const top = row * (ALL_DAY_EVENT_HEIGHT + ALL_DAY_ROW_GAP);\n\n // During resize, both edges are always visible so force rounding on both sides\n const spanStart =\n isBeingResized || isSameDay(event.start, days[startColumn].date);\n const spanEnd = isBeingResized || isSameDay(event.end, days[endColumn].date);\n\n /**\n * In day view with buffer days (3 columns: buffer | visible | buffer),\n * the visible column is index 1. Multi-day events that start in the left\n * buffer (column 0) have their title off-screen. Calculate the percentage\n * offset needed to push the title into the visible area.\n *\n * CSS `padding-left` percentages are relative to the **containing block's\n * width** (the wrapper div), not the grid container. We must convert from\n * container-relative coordinates to wrapper-relative coordinates:\n * offset = (visibleStart% - left%) \\u00D7 (100 / width%)\n */\n const visibleColumnIndex = visibleStartIndex ?? 1;\n const visibleStartPercent = (visibleColumnIndex / totalColumns) * 100;\n const hiddenContainerPercent = isDayView\n ? Math.max(0, visibleStartPercent - left)\n : 0;\n /** Small extra nudge (in container %) so the title doesn't sit flush\n * against the visible column edge \\u2014 gives it a bit of breathing room. */\n const TITLE_NUDGE_PERCENT = 0.1;\n const titleOffsetPercent =\n hiddenContainerPercent > 0 && width > 0\n ? ((hiddenContainerPercent + TITLE_NUDGE_PERCENT) / width) * 100\n : 0;\n\n const handleResizeMouseDown = useCallback(\n (\n e: React.MouseEvent,\n ev: CalendarEvent,\n edge: \"left\" | \"right\" | \"move\",\n ) => {\n onAllDayResizeMouseDown?.(\n e,\n ev,\n edge,\n originalStartColumn,\n originalEndColumn,\n );\n },\n [onAllDayResizeMouseDown, originalStartColumn, originalEndColumn],\n );\n\n return (\n \n \n \n );\n}\n\n/** Placeholder border-only outline rendered at the target position during move */\nfunction AllDayPlaceholderRow({\n event,\n startColumn,\n endColumn,\n row,\n totalColumns,\n}: {\n event: CalendarEvent;\n startColumn: number;\n endColumn: number;\n row: number;\n totalColumns: number;\n}) {\n const columnWidth = 100 / totalColumns;\n const left = (startColumn / totalColumns) * 100;\n const rightGap = columnWidth * 0.08;\n const width = ((endColumn - startColumn + 1) / totalColumns) * 100 - rightGap;\n const top = row * (ALL_DAY_EVENT_HEIGHT + ALL_DAY_ROW_GAP);\n\n return (\n \n \n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/week-view-all-day-row.tsx" }, { "path": "components/layouts/calendar/week-view-day-columns.tsx", "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { WeekViewDayColumnsProps } from \"./week-view-types\";\n\n/**\n * Gets the browser's timezone abbreviation\n */\nfunction getTimezoneAbbreviation(): string {\n const date = new Date();\n const timeZoneString = date.toLocaleTimeString(\"en-US\", {\n timeZoneName: \"short\",\n });\n const match = timeZoneString.match(/\\s([A-Z]{2,5})$/);\n\n if (match) {\n return match[1];\n }\n\n // Fallback to offset format\n const offset = -date.getTimezoneOffset();\n const hours = Math.floor(Math.abs(offset) / 60);\n const sign = offset >= 0 ? \"+\" : \"-\";\n return `GMT${sign}${hours}`;\n}\n\n/**\n * Day column headers showing day names and date numbers\n * Includes timezone label on the left (unless standalone mode)\n * Highlights the current day\n */\nexport function WeekViewDayColumns({\n days,\n standalone,\n className,\n}: WeekViewDayColumnsProps) {\n const timezone = getTimezoneAbbreviation();\n\n // Standalone mode: just render the day columns (used inside scroll container)\n if (standalone) {\n return (\n \n {days.map((day) => (\n \n \n {day.dayName}\n \n \n {day.dayNumber}\n \n \n ))}\n \n );\n }\n\n return (\n \n {/* Timezone label */}\n
\n {timezone}\n
\n\n {/* Day columns */}\n \n {days.map((day) => (\n \n \n {day.dayName}\n \n \n {day.dayNumber}\n \n \n ))}\n \n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/week-view-day-columns.tsx" }, { "path": "components/layouts/calendar/week-view-grid.tsx", "content": "\"use client\";\n\nimport React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { isSameDay, startOfDay, addDays } from \"date-fns\";\nimport { cn } from \"@/lib/utils\";\nimport { isPast } from \"date-fns\";\nimport { calculatePositionedEvents } from \"@/lib/layouts/event-utils\";\nimport { CalendarEventItem } from \"./calendar-event-item\";\nimport { useCalendarPopoverBoundary } from \"./calendar-popover-context\";\nimport type {\n CalendarEvent,\n EventDragState,\n EventResizeState,\n PositionedEvent,\n WeekViewGridProps,\n} from \"./week-view-types\";\n\n/**\n * Main grid displaying hour/day intersection cells with events\n * Each cell represents one hour in one day\n */\nexport function WeekViewGrid({\n days,\n hours,\n hourHeight,\n events = [],\n onEventClick,\n selectedEventId,\n dragState,\n onEventDragMouseDown,\n resizeState,\n onEventResizeMouseDown,\n onEventChange,\n onContextMenuOpenChange,\n isSidebarOpen,\n onDockToSidebar,\n onClosePopover,\n onPrevWeek,\n onNextWeek,\n className,\n}: WeekViewGridProps) {\n const { view } = useCalendarPopoverBoundary();\n const isDayView = view === \"day\";\n const gridRef = React.useRef(null);\n const [gridWidth, setGridWidth] = React.useState(0);\n\n React.useEffect(() => {\n const el = gridRef.current;\n if (!el) return;\n\n const observer = new ResizeObserver((entries) => {\n for (const entry of entries) {\n setGridWidth(entry.contentRect.width);\n }\n });\n observer.observe(el);\n return () => observer.disconnect();\n }, []);\n\n return (\n
\n {/* Background grid */}\n \n {hours.map((hourSlot) =>\n days.map((day) => {\n const isWeekend =\n day.date.getDay() === 0 || day.date.getDay() === 6;\n return (\n \n );\n }),\n )}\n
\n\n {/* Events layer */}\n \n {days.map((day) => {\n /**\n * Day view uses a smaller right gap than week view so events\n * nearly fill the column but still show a sliver of the grid\n * line — matching Notion Calendar's day-view styling.\n */\n const rightGap = isDayView ? 2 : 8;\n const positionedEvents = calculatePositionedEvents(\n events,\n day,\n rightGap,\n );\n\n return (\n \n );\n })}\n \n\n {/* Resize placeholder overlay — rendered at grid level for cross-day support */}\n {resizeState?.isResizing &&\n !isSameDay(\n resizeState.currentStartDate,\n resizeState.currentEndDate,\n ) && (\n \n )}\n\n {/* Drag placeholder overlay — rendered at grid level for cross-column support */}\n {dragState?.isDragging && (\n \n )}\n\n {/* Floating dragging copy — rendered at grid level so it can move freely */}\n {dragState?.isDragging && (\n \n )}\n \n );\n}\n\ninterface DragPlaceholderOverlayProps {\n days: WeekViewGridProps[\"days\"];\n hourHeight: number;\n dragState: EventDragState;\n}\n\nfunction DragPlaceholderOverlay({\n days,\n hourHeight,\n dragState,\n}: DragPlaceholderOverlayProps) {\n // Find the target column index using dragState.currentDate\n const targetColumnIndex = days.findIndex((d) =>\n isSameDay(d.date, dragState.currentDate),\n );\n\n if (targetColumnIndex === -1) return null;\n\n // Build a minimal PositionedEvent from the drag state event\n const placeholderPositioned: PositionedEvent = {\n event: dragState.event,\n top: 0,\n height: 0,\n left: 0,\n width: 92,\n column: 0,\n totalColumns: 1,\n };\n\n return (\n \n {days.map((day, i) => {\n if (i !== targetColumnIndex) {\n return
;\n }\n\n return (\n
\n \n
\n );\n })}\n
\n );\n}\n\ninterface ResizePlaceholderOverlayProps {\n days: WeekViewGridProps[\"days\"];\n hourHeight: number;\n resizeState: EventResizeState;\n}\n\nfunction ResizePlaceholderOverlay({\n days,\n hourHeight,\n resizeState,\n}: ResizePlaceholderOverlayProps) {\n if (resizeState.effectiveEdge === \"bottom\") {\n return (\n \n );\n }\n\n return (\n \n );\n}\n\nfunction BottomEdgeOverlay({\n days,\n hourHeight,\n resizeState,\n}: ResizePlaceholderOverlayProps) {\n const endDay = resizeState.currentEndDate;\n\n const startColIndex = days.findIndex((d) =>\n isSameDay(d.date, resizeState.currentStartDate),\n );\n const endColIndex = days.findIndex((d) => isSameDay(d.date, endDay));\n\n if (startColIndex === -1 || endColIndex === -1) return null;\n if (endColIndex <= startColIndex) return null;\n\n return (\n \n {days.map((day, i) => {\n // Skip start column (handled by DayEventsColumn) and columns outside range\n if (i <= startColIndex || i > endColIndex) {\n return
;\n }\n\n const isEndColumn = i === endColIndex;\n const segmentPosition = isEndColumn\n ? (\"end\" as const)\n : (\"middle\" as const);\n\n const midnight = startOfDay(day.date);\n const overrideStart = midnight;\n const overrideEnd = isEndColumn\n ? resizeState.currentEnd\n : addDays(midnight, 1);\n\n const positioned: PositionedEvent = {\n event: resizeState.event,\n top: 0,\n height: 0,\n left: 0,\n width: 92,\n column: 0,\n totalColumns: 1,\n segmentPosition,\n };\n\n return (\n
\n \n
\n );\n })}\n
\n );\n}\n\nfunction TopEdgeOverlay({\n days,\n hourHeight,\n resizeState,\n}: ResizePlaceholderOverlayProps) {\n const startDay = resizeState.currentStartDate;\n\n const startColIndex = days.findIndex((d) => isSameDay(d.date, startDay));\n const endColIndex = days.findIndex((d) =>\n isSameDay(d.date, resizeState.currentEndDate),\n );\n\n if (startColIndex === -1 || endColIndex === -1) return null;\n if (endColIndex <= startColIndex) return null;\n\n return (\n \n {days.map((day, i) => {\n // Skip end column (handled by DayEventsColumn) and columns outside range\n if (i < startColIndex || i >= endColIndex) {\n return
;\n }\n\n const isStartColumn = i === startColIndex;\n const segmentPosition = isStartColumn\n ? (\"start\" as const)\n : (\"middle\" as const);\n\n const midnight = startOfDay(day.date);\n const overrideStart = isStartColumn\n ? resizeState.currentStart\n : midnight;\n const overrideEnd = addDays(midnight, 1);\n\n const positioned: PositionedEvent = {\n event: resizeState.event,\n top: 0,\n height: 0,\n left: 0,\n width: 92,\n column: 0,\n totalColumns: 1,\n segmentPosition,\n };\n\n return (\n
\n \n
\n );\n })}\n
\n );\n}\n\ninterface FloatingDragCopyProps {\n days: WeekViewGridProps[\"days\"];\n hourHeight: number;\n dragState: EventDragState;\n gridWidth: number;\n}\n\nfunction FloatingDragCopy({\n days,\n hourHeight,\n dragState,\n gridWidth,\n}: FloatingDragCopyProps) {\n const floatingPositioned: PositionedEvent = {\n event: dragState.event,\n top: 0,\n height: 0,\n left: 0,\n width: 92,\n column: 0,\n totalColumns: 1,\n };\n\n const durationMinutes =\n (dragState.currentEnd.getTime() - dragState.currentStart.getTime()) / 60000;\n const heightPx = (durationMinutes / 60) * hourHeight;\n const columnWidthPx =\n (days.length > 0 ? gridWidth / days.length : 200) * 0.92;\n\n return createPortal(\n \n \n ,\n document.body,\n );\n}\n\ninterface DayEventsColumnProps {\n columnDate: Date;\n events: ReturnType;\n hourHeight: number;\n onEventClick?: (event: CalendarEvent) => void;\n selectedEventId?: string;\n dragState?: EventDragState;\n onEventDragMouseDown?: (e: React.MouseEvent, event: CalendarEvent) => void;\n resizeState?: EventResizeState;\n onEventResizeMouseDown?: (\n e: React.MouseEvent,\n event: CalendarEvent,\n edge: \"top\" | \"bottom\",\n ) => void;\n onEventChange?: (event: CalendarEvent) => void;\n onContextMenuOpenChange?: (open: boolean) => void;\n isSidebarOpen?: boolean;\n onDockToSidebar?: () => void;\n onClosePopover?: () => void;\n onPrevWeek?: () => void;\n onNextWeek?: () => void;\n}\n\nfunction renderColumnGhost(\n positionedEvent: PositionedEvent,\n hourHeight: number,\n) {\n return (\n \n );\n}\n\nfunction DayEventsColumn({\n columnDate,\n events,\n hourHeight,\n onEventClick,\n selectedEventId,\n dragState,\n onEventDragMouseDown,\n resizeState,\n onEventResizeMouseDown,\n onEventChange,\n onContextMenuOpenChange,\n isSidebarOpen,\n onDockToSidebar,\n onClosePopover,\n onPrevWeek,\n onNextWeek,\n}: DayEventsColumnProps) {\n return (\n
\n {events.map((positionedEvent) => {\n const eventId = positionedEvent.event.id;\n const isBeingDragged =\n dragState?.isDragging && dragState.eventId === eventId;\n\n if (isBeingDragged) {\n return renderColumnGhost(positionedEvent, hourHeight);\n }\n\n const isBeingResized =\n resizeState?.isResizing && resizeState.eventId === eventId;\n\n if (isBeingResized) {\n const { effectiveEdge, currentStartDate, currentEndDate } =\n resizeState;\n const isCrossDay = !isSameDay(currentStartDate, currentEndDate);\n\n // Determine if this column is the anchor column\n const isAnchorColumn =\n (effectiveEdge === \"bottom\" &&\n isSameDay(columnDate, currentStartDate)) ||\n (effectiveEdge === \"top\" && isSameDay(columnDate, currentEndDate));\n\n // Check if this column is within the new range at all\n const colTime = columnDate.getTime();\n const inRange =\n colTime >= currentStartDate.getTime() &&\n colTime <= currentEndDate.getTime();\n\n // Non-anchor columns with original segments: render as ghost\n // Columns outside new range with original segments: render as ghost\n if (!isAnchorColumn || !inRange) {\n return renderColumnGhost(positionedEvent, hourHeight);\n }\n\n // Anchor column rendering\n let displayStart: Date;\n let displayEnd: Date;\n let segmentPosition: \"start\" | \"middle\" | \"end\" | \"full\";\n\n if (!isCrossDay) {\n // Same day: show currentStart to currentEnd\n displayStart = resizeState.currentStart;\n displayEnd = resizeState.currentEnd;\n segmentPosition = \"full\";\n } else if (effectiveEdge === \"bottom\") {\n // Anchor is start column: show currentStart to end-of-day\n displayStart = resizeState.currentStart;\n displayEnd = addDays(startOfDay(columnDate), 1);\n segmentPosition = \"start\";\n } else {\n // Anchor is end column: show start-of-day to currentEnd\n displayStart = startOfDay(columnDate);\n displayEnd = resizeState.currentEnd;\n segmentPosition = \"end\";\n }\n\n const resizePositioned = { ...positionedEvent, segmentPosition };\n\n return (\n \n {renderColumnGhost(positionedEvent, hourHeight)}\n \n \n );\n }\n\n return (\n \n );\n })}\n
\n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/week-view-grid.tsx" }, { "path": "components/layouts/calendar/week-view-time-axis.tsx", "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { WeekViewTimeAxisProps } from \"./week-view-types\";\n\n/**\n * Left sidebar displaying hourly time labels using CSS Grid\n * Uses same grid row structure as main grid for guaranteed alignment\n * Width matches the timezone/all-day label column (4rem)\n */\nexport function WeekViewTimeAxis({\n hours,\n hourHeight,\n className,\n}: WeekViewTimeAxisProps) {\n return (\n \n {hours.map((hourSlot) => (\n \n {/* Show label at top of each cell, skip 12 AM (hour 0) */}\n {hourSlot.hour > 0 && (\n \n {hourSlot.label}\n \n )}\n \n ))}\n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/week-view-time-axis.tsx" }, { "path": "components/layouts/calendar/week-view-time-indicator.tsx", "content": "\"use client\";\n\nimport { format } from \"date-fns\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { WeekViewTimeIndicatorProps } from \"./week-view-types\";\n\n/**\n * Current time indicator showing time badge and horizontal lines\n * - Time badge with current time (e.g., \"5:48PM\") on the left\n * - Thick line on today's column\n * - Thin line on other day columns\n * Updates position every minute\n */\nexport function WeekViewTimeIndicator({\n days,\n hourHeight,\n scrollDays,\n scrollStyle,\n behindSelection,\n className,\n}: WeekViewTimeIndicatorProps) {\n const [currentTime, setCurrentTime] = React.useState(() => new Date());\n\n // Check if today is visible in the current week\n const todayIndex = days.findIndex((day) => day.isToday);\n const isTodayVisible = todayIndex !== -1;\n\n // Update time every minute\n React.useEffect(() => {\n const interval = setInterval(() => {\n setCurrentTime(new Date());\n }, 60000);\n\n return () => clearInterval(interval);\n }, []);\n\n if (!isTodayVisible) {\n return null;\n }\n\n // Calculate position based on current time\n const minutesSinceMidnight =\n currentTime.getHours() * 60 + currentTime.getMinutes();\n const totalMinutesInDay = 24 * 60;\n const totalGridHeight = hourHeight * 24;\n const topPosition =\n (minutesSinceMidnight / totalMinutesInDay) * totalGridHeight;\n\n // Format time as \"H:MMAM/PM\" (e.g., \"5:48PM\")\n const formattedTime = format(currentTime, \"h:mma\").toUpperCase();\n\n const lineDays = scrollDays ?? days;\n const lineTodayIndex = lineDays.findIndex((d) => d.isToday);\n\n const linesContent = (\n
\n {lineDays.map((day, index) => (\n \n {index === lineTodayIndex && (\n
\n
\n
\n
\n
\n
\n )}\n \n \n ))}\n
\n );\n\n return (\n \n
\n {/* Time badge - positioned in the time axis area */}\n
\n \n {formattedTime}\n \n
\n\n {/* Horizontal lines across day columns */}\n {scrollStyle ? (\n
\n
{linesContent}
\n
\n ) : (\n linesContent\n )}\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/week-view-time-indicator.tsx" }, { "path": "components/layouts/calendar/week-view-types.ts", "content": "import type React from \"react\";\n\n/**\n * Calendar view mode \\u2014 \\u201cday\\u201d shows a single column, \\u201cweek\\u201d shows 7 columns\n */\nexport type ViewType = \"day\" | \"week\" | \"month\";\n\n/**\n * View settings for display preferences (toggleable from the view dropdown)\n */\nexport interface ViewSettings {\n showWeekends: boolean;\n showDeclinedEvents: boolean;\n showWeekNumbers: boolean;\n}\n\n/**\n * Represents a single day in the week view\n */\nexport interface WeekDay {\n /** The full Date object for this day */\n date: Date;\n /** Short day name (e.g., \"Sun\", \"Mon\") */\n dayName: string;\n /** Day of month (1-31) */\n dayNumber: number;\n /** Whether this day is today */\n isToday: boolean;\n}\n\n/**\n * Represents a single hour slot in the time axis\n */\nexport interface HourSlot {\n /** Hour in 24-hour format (0-23) */\n hour: number;\n /** Formatted label (e.g., \"12 AM\", \"1 PM\") */\n label: string;\n}\n\n/**\n * Props for the main WeekView component\n */\nexport interface WeekViewProps {\n /** Calendar view mode. Defaults to \"week\" */\n view?: ViewType;\n /** Reference date to show the week for. Defaults to today */\n currentDate?: Date;\n /** Day the week starts on. Defaults to 0 (Sunday) */\n weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n /** Events to display on the calendar */\n events?: CalendarEvent[];\n /** Optional click handler for events */\n onEventClick?: (event: CalendarEvent) => void;\n /** ID of the currently selected event */\n selectedEventId?: string;\n /** Callback when clicking empty calendar space (not on an event) */\n onBackgroundClick?: () => void;\n /** Callback when the displayed date changes (via scroll navigation) */\n onDateChange?: (date: Date) => void;\n /** Callback when the visible days change during scroll (real-time updates) */\n onVisibleDaysChange?: (days: Date[]) => void;\n /** Callback when an event is changed (e.g. dragged to a new time) */\n onEventChange?: (event: CalendarEvent) => void;\n /** Whether the right sidebar is open */\n isSidebarOpen?: boolean;\n /** Callback to dock popover to sidebar (opens sidebar) */\n onDockToSidebar?: () => void;\n /** Callback to close popover (deselect event) */\n onClosePopover?: () => void;\n /** Navigate to previous week */\n onPrevWeek?: () => void;\n /** Navigate to next week */\n onNextWeek?: () => void;\n /** Optional className for the root element */\n className?: string;\n}\n\n/**\n * Props for the WeekViewDayColumns component\n */\nexport interface WeekViewDayColumnsProps {\n /** Array of days to display */\n days: WeekDay[];\n /** When true, renders without the timezone/grid wrapper (used in scroll container) */\n standalone?: boolean;\n /** Optional className */\n className?: string;\n}\n\n/**\n * Props for the WeekViewTimeAxis component\n */\nexport interface WeekViewTimeAxisProps {\n /** Array of hour slots to display */\n hours: HourSlot[];\n /** Height of each hour row in pixels */\n hourHeight: number;\n /** Optional className */\n className?: string;\n}\n\n/**\n * Props for the WeekViewGrid component\n */\nexport interface WeekViewGridProps {\n /** Array of days for columns */\n days: WeekDay[];\n /** Array of hour slots for rows */\n hours: HourSlot[];\n /** Height of each hour row in pixels */\n hourHeight: number;\n /** Events to display on the grid */\n events?: CalendarEvent[];\n /** Optional click handler for events */\n onEventClick?: (event: CalendarEvent) => void;\n /** ID of the currently selected event */\n selectedEventId?: string;\n /** Current drag state if an event is being dragged */\n dragState?: EventDragState;\n /** Mousedown handler to initiate event drag */\n onEventDragMouseDown?: (e: React.MouseEvent, event: CalendarEvent) => void;\n /** Current resize state if an event is being resized */\n resizeState?: EventResizeState;\n /** Mousedown handler to initiate event resize */\n onEventResizeMouseDown?: (\n e: React.MouseEvent,\n event: CalendarEvent,\n edge: \"top\" | \"bottom\",\n ) => void;\n /** Callback when an event is changed (e.g. color change from context menu) */\n onEventChange?: (event: CalendarEvent) => void;\n /** Callback when context menu open state changes */\n onContextMenuOpenChange?: (open: boolean) => void;\n /** Whether the right sidebar is open */\n isSidebarOpen?: boolean;\n /** Callback to dock popover to sidebar */\n onDockToSidebar?: () => void;\n /** Callback to close popover */\n onClosePopover?: () => void;\n /** Navigate to previous week */\n onPrevWeek?: () => void;\n /** Navigate to next week */\n onNextWeek?: () => void;\n /** Optional className */\n className?: string;\n}\n\n/**\n * Props for the WeekViewTimeIndicator component\n */\nexport interface WeekViewTimeIndicatorProps {\n /** Array of days in the current week view */\n days: WeekDay[];\n /** Height of each hour row in pixels */\n hourHeight: number;\n /** Buffered days array for scroll-synchronized line rendering */\n scrollDays?: WeekDay[];\n /** Scroll transform style to apply to the lines */\n scrollStyle?: React.CSSProperties;\n /** Whether to render behind selected events */\n behindSelection?: boolean;\n /** Optional className */\n className?: string;\n}\n\n/**\n * Props for the WeekViewAllDayRow component\n */\nexport interface WeekViewAllDayRowProps {\n /** Array of days to display */\n days: WeekDay[];\n /** All-day events to display */\n allDayEvents?: CalendarEvent[];\n /** Optional click handler for events */\n onEventClick?: (event: CalendarEvent) => void;\n /** ID of the currently selected event */\n selectedEventId?: string;\n /** Optional scroll transform style for horizontal scroll sync */\n scrollStyle?: React.CSSProperties;\n /** Current all-day resize state */\n allDayResizeState?: AllDayResizeState;\n /** Mousedown handler to initiate all-day event resize or drag */\n onAllDayResizeMouseDown?: (\n e: React.MouseEvent,\n event: CalendarEvent,\n edge: \"left\" | \"right\" | \"move\",\n startColumn: number,\n endColumn: number,\n ) => void;\n /** Callback when an event is changed */\n onEventChange?: (event: CalendarEvent) => void;\n /** Callback when context menu open state changes */\n onContextMenuOpenChange?: (open: boolean) => void;\n /** Ref to attach to the scroll content div for column measurements */\n allDayScrollContentRef?: React.RefObject;\n /** Whether the right sidebar is open */\n isSidebarOpen?: boolean;\n /** Callback to dock popover to sidebar */\n onDockToSidebar?: () => void;\n /** Callback to close popover */\n onClosePopover?: () => void;\n /** Navigate to previous week */\n onPrevWeek?: () => void;\n /** Navigate to next week */\n onNextWeek?: () => void;\n /** Index of the first visible column (used to skip buffer-only events in day view) */\n visibleStartIndex?: number;\n /** Number of visible columns (defaults to days.length when omitted) */\n visibleCount?: number;\n /** Width of a single day column in pixels (for floating drag copy sizing) */\n dayColumnWidth?: number;\n /** Optional className */\n className?: string;\n}\n\n/**\n * Represents an event reminder\n */\nexport interface EventReminder {\n amount: number;\n unit: \"minutes\" | \"hours\" | \"days\";\n}\n\n/**\n * Represents a calendar event\n */\nexport interface CalendarEvent {\n /** Unique identifier for the event */\n id: string;\n /** Event title */\n title: string;\n /** Start date and time */\n start: Date;\n /** End date and time */\n end: Date;\n /** Whether this is an all-day event */\n isAllDay?: boolean;\n /** Event color (for styling) */\n color?: EventColor;\n /** Calendar ID this event belongs to */\n calendarId?: string;\n /** Optional description */\n description?: string;\n /** Optional location */\n location?: string;\n /** Timezone string (e.g. \"GMT-3 Sao Paulo\") */\n timezone?: string;\n /** Recurrence rule display string (e.g. \"Every week on Thu\") */\n recurrence?: string;\n /** Reminders list */\n reminders?: EventReminder[];\n /** Busy/Free status */\n status?: \"busy\" | \"free\";\n /** Visibility setting */\n visibility?: \"default\" | \"public\" | \"private\";\n /** Calendar account email for display */\n calendarEmail?: string;\n}\n\n/**\n * Predefined event colors\n */\nexport type EventColor =\n | \"red\"\n | \"orange\"\n | \"yellow\"\n | \"green\"\n | \"blue\"\n | \"purple\"\n | \"gray\";\n\n/**\n * Represents a positioned event for rendering in the grid\n */\nexport interface PositionedEvent {\n /** The original event */\n event: CalendarEvent;\n /** Top position as percentage from the day start */\n top: number;\n /** Height as percentage of the day */\n height: number;\n /** Left position as percentage (for overlap handling) */\n left: number;\n /** Width as percentage (for overlap handling) */\n width: number;\n /** Column index when events overlap */\n column: number;\n /** Total columns when events overlap */\n totalColumns: number;\n /** Segment position for multi-day timed events (controls corner rounding) */\n segmentPosition?: \"start\" | \"middle\" | \"end\" | \"full\";\n}\n\n/**\n * Drag variant for rendering events in different visual states during drag\n */\nexport type EventDragVariant = \"default\" | \"ghost\" | \"dragging\" | \"placeholder\";\n\n/**\n * State of an in-progress event drag operation\n */\nexport interface EventDragState {\n /** ID of the event being dragged */\n eventId: string;\n /** The original event being dragged (preserved across week navigations) */\n event: CalendarEvent;\n /** Original start time before drag */\n originalStart: Date;\n /** Original end time before drag */\n originalEnd: Date;\n /** Current snapped start time during drag */\n currentStart: Date;\n /** Current snapped end time during drag */\n currentEnd: Date;\n /** Target day for the placeholder (decoupled from currentStart for cross-column drag) */\n currentDate: Date;\n /** Whether the drag threshold has been met */\n isDragging: boolean;\n /** Raw cursor Y position in px (unsnapped, for smooth dragging copy) */\n cursorY: number;\n /** Raw cursor X position in px relative to grid container */\n cursorX: number;\n /** Viewport clientX for fixed-position dragging copy */\n clientX: number;\n /** Viewport clientY for fixed-position dragging copy */\n clientY: number;\n}\n\n/**\n * State of an in-progress event resize operation\n */\nexport interface EventResizeState {\n /** ID of the event being resized */\n eventId: string;\n /** The original event being resized */\n event: CalendarEvent;\n /** Original start time before resize */\n originalStart: Date;\n /** Original end time before resize */\n originalEnd: Date;\n /** Current snapped start time during resize */\n currentStart: Date;\n /** Current snapped end time during resize */\n currentEnd: Date;\n /** Which edge was originally grabbed */\n edge: \"top\" | \"bottom\";\n /** Which edge the cursor is effectively on (flips when crossing anchor) */\n effectiveEdge: \"top\" | \"bottom\";\n /** Whether the drag threshold has been met */\n isResizing: boolean;\n /** Target day column for the end during cross-day bottom resize */\n currentEndDate: Date;\n /** Target day column for the start during cross-day top resize */\n currentStartDate: Date;\n}\n\n/**\n * State of an in-progress all-day event resize operation\n */\nexport interface AllDayResizeState {\n /** ID of the event being resized */\n eventId: string;\n /** The original event being resized */\n event: CalendarEvent;\n /** Original start column index in the buffered days array */\n originalStartColumn: number;\n /** Original end column index in the buffered days array */\n originalEndColumn: number;\n /** Current start column index during resize */\n currentStartColumn: number;\n /** Current end column index during resize */\n currentEndColumn: number;\n /** Which edge is being dragged, or \"move\" for drag-and-drop */\n edge: \"left\" | \"right\" | \"move\";\n /** Whether the drag threshold has been met */\n isResizing: boolean;\n /** Viewport-relative cursor X during move (for floating copy) */\n clientX?: number;\n /** Viewport-relative cursor Y during move (for floating copy) */\n clientY?: number;\n /** Offset from cursor to event left edge at mousedown (px) */\n cursorOffsetX?: number;\n /** Offset from cursor to event top edge at mousedown (px) */\n cursorOffsetY?: number;\n}\n\n/**\n * Props for the CalendarEventItem component\n */\nexport interface CalendarEventItemProps {\n /** The positioned event to render */\n positionedEvent: PositionedEvent;\n /** Height of each hour in pixels */\n hourHeight: number;\n /** Whether the event is in the past */\n isPast?: boolean;\n /** Whether the event is currently selected */\n isSelected?: boolean;\n /** Optional click handler */\n onClick?: (event: CalendarEvent) => void;\n /** Drag variant for visual state during drag */\n dragVariant?: EventDragVariant;\n /** Override start time (for dragging/placeholder positioning) */\n overrideStart?: Date;\n /** Override end time (for dragging/placeholder positioning) */\n overrideEnd?: Date;\n /** Mousedown handler to initiate drag */\n onDragMouseDown?: (e: React.MouseEvent, event: CalendarEvent) => void;\n /** Mousedown handler to initiate resize */\n onResizeMouseDown?: (\n e: React.MouseEvent,\n event: CalendarEvent,\n edge: \"top\" | \"bottom\",\n ) => void;\n /** Callback when an event is changed (e.g. color change from context menu) */\n onEventChange?: (event: CalendarEvent) => void;\n /** Raw cursor Y position for smooth dragging copy */\n cursorY?: number;\n /** Raw cursor X position for smooth dragging copy */\n cursorX?: number;\n /** Fixed width in px (for free-floating dragging copy) */\n fixedWidth?: number;\n /** Fixed height in px (for free-floating dragging copy) */\n fixedHeight?: number;\n /** Callback when context menu open state changes */\n onContextMenuOpenChange?: (open: boolean) => void;\n /** Whether the right sidebar is open (controls popover visibility) */\n isSidebarOpen?: boolean;\n /** Callback to dock popover to sidebar */\n onDockToSidebar?: () => void;\n /** Callback to close popover (deselect event) */\n onClosePopover?: () => void;\n /** Navigate to previous week */\n onPrevWeek?: () => void;\n /** Navigate to next week */\n onNextWeek?: () => void;\n /** Optional className */\n className?: string;\n}\n", "type": "registry:component", "target": "components/layouts/calendar/week-view-types.ts" }, { "path": "components/layouts/calendar/week-view.tsx", "content": "\"use client\";\n\nimport {\n addDays,\n differenceInCalendarDays,\n eachDayOfInterval,\n format,\n getWeek,\n isToday,\n} from \"date-fns\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { isMultiDayEvent } from \"@/lib/layouts/event-utils\";\nimport { useHorizontalScroll } from \"@/hooks/layouts/use-horizontal-scroll\";\nimport { useEventDrag } from \"@/hooks/layouts/use-event-drag\";\nimport { useEventResize } from \"@/hooks/layouts/use-event-resize\";\nimport { useAllDayResize } from \"@/hooks/layouts/use-all-day-resize\";\nimport type {\n HourSlot,\n ViewType,\n WeekDay,\n WeekViewProps,\n} from \"./week-view-types\";\nimport { WeekViewAllDayRow } from \"./week-view-all-day-row\";\nimport { WeekViewDayColumns } from \"./week-view-day-columns\";\nimport { WeekViewGrid } from \"./week-view-grid\";\nimport { WeekViewTimeAxis } from \"./week-view-time-axis\";\nimport { WeekViewTimeIndicator } from \"./week-view-time-indicator\";\nimport { CalendarPopoverBoundaryProvider } from \"./calendar-popover-context\";\n\n/** Minimum height of each hour row in pixels */\nconst MIN_HOUR_HEIGHT = 48;\n\n/** Width of the time axis column in pixels (4rem = 64px) */\nexport const TIME_AXIS_WIDTH = 64;\n\n/** Number of visible days per view mode */\nconst VISIBLE_DAYS_BY_VIEW: Record = {\n day: 1,\n week: 7,\n month: 7,\n};\n\n/** Buffer days per view mode (each side, for horizontal scroll) */\nconst BUFFER_DAYS_BY_VIEW: Record = {\n day: 1,\n week: 7,\n month: 7,\n};\n\n/** Buffer extension step size per view mode */\nconst BUFFER_STEP_BY_VIEW: Record = {\n day: 1,\n week: 7,\n month: 7,\n};\n\n/**\n * Generates an array of WeekDay objects starting from the given date.\n * Note: isToday is computed dynamically, not cached, to handle overnight page views\n */\nfunction generateWeekDays(\n startDate: Date,\n count: number,\n): Omit[] {\n const end = addDays(startDate, count - 1);\n\n return eachDayOfInterval({ start: startDate, end }).map((date) => ({\n date,\n dayName: format(date, \"EEE\"),\n dayNumber: date.getDate(),\n }));\n}\n\n/**\n * Generates an extended array of days including buffer days on both sides\n * for smooth horizontal scroll transitions\n */\nfunction generateBufferedDays(\n startDate: Date,\n bufferDays: number,\n visibleDays: number,\n): Omit[] {\n const bufferStart = addDays(startDate, -bufferDays);\n const bufferEnd = addDays(startDate, visibleDays + bufferDays - 1);\n\n return eachDayOfInterval({ start: bufferStart, end: bufferEnd }).map(\n (date) => ({\n date,\n dayName: format(date, \"EEE\"),\n dayNumber: date.getDate(),\n }),\n );\n}\n\n/**\n * Generates an array of HourSlot objects for all 24 hours\n */\nfunction generateHours(): HourSlot[] {\n return Array.from({ length: 24 }, (_, i) => {\n const dateWithHour = new Date();\n dateWithHour.setHours(i, 0, 0, 0);\n return {\n hour: i,\n label: format(dateWithHour, \"h a\"),\n };\n });\n}\n\n/**\n * Returns the month name, year, and week number for the current date\n */\nexport function getCalendarHeaderInfo(\n currentDate: Date,\n weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6,\n) {\n return {\n monthName: format(currentDate, \"MMMM\"),\n year: format(currentDate, \"yyyy\"),\n weekNumber: getWeek(currentDate, { weekStartsOn }),\n };\n}\n\n/**\n * Returns the visible days starting from the given date (used for sidebar highlighting)\n */\nexport function getVisibleDays(\n currentDate: Date,\n view: ViewType = \"week\",\n): Date[] {\n const count = VISIBLE_DAYS_BY_VIEW[view];\n const end = addDays(currentDate, count - 1);\n return eachDayOfInterval({ start: currentDate, end });\n}\n\n/**\n * Main Week View calendar component\n * Displays a week grid with time slots and supports horizontal scroll navigation\n */\nexport function WeekView({\n view = \"week\",\n currentDate = new Date(),\n events = [],\n onEventClick,\n selectedEventId,\n onBackgroundClick,\n onDateChange,\n onVisibleDaysChange,\n onEventChange,\n isSidebarOpen,\n onDockToSidebar,\n onClosePopover,\n onPrevWeek,\n onNextWeek,\n className,\n}: WeekViewProps) {\n const VISIBLE_DAYS = VISIBLE_DAYS_BY_VIEW[view];\n const BUFFER_DAYS = BUFFER_DAYS_BY_VIEW[view];\n const BUFFER_STEP = BUFFER_STEP_BY_VIEW[view];\n const scrollContainerRef = React.useRef(null);\n const dayColumnsScrollRef = React.useRef(null);\n const allDayScrollRef = React.useRef(null);\n const allDayScrollContentRef = React.useRef(null);\n\n // Visible days starting from currentDate\n const baseDays = React.useMemo(\n () => generateWeekDays(currentDate, VISIBLE_DAYS),\n [currentDate, VISIBLE_DAYS],\n );\n\n const days: WeekDay[] = baseDays.map((day) => ({\n ...day,\n isToday: isToday(day.date),\n }));\n\n const hours = React.useMemo(() => generateHours(), []);\n\n const allDayEvents = React.useMemo(\n () => events.filter((e) => e.isAllDay || isMultiDayEvent(e)),\n [events],\n );\n\n const timedEvents = React.useMemo(\n () => events.filter((e) => !e.isAllDay && !isMultiDayEvent(e)),\n [events],\n );\n\n // Compute day column width and dynamic hour height from container\n const [dayColumnWidth, setDayColumnWidth] = React.useState(0);\n const [hourHeight, setHourHeight] = React.useState(MIN_HOUR_HEIGHT);\n const [contextMenuOpen, setContextMenuOpen] = React.useState(false);\n const [isAllDayResizing, setIsAllDayResizing] = React.useState(false);\n\n React.useEffect(() => {\n const updateDimensions = () => {\n const container = scrollContainerRef.current;\n if (!container) return;\n const availableWidth = container.clientWidth - TIME_AXIS_WIDTH;\n setDayColumnWidth(availableWidth / VISIBLE_DAYS);\n setHourHeight(Math.max(MIN_HOUR_HEIGHT, container.clientHeight / 24));\n };\n\n updateDimensions();\n\n const observer = new ResizeObserver(updateDimensions);\n if (scrollContainerRef.current) {\n observer.observe(scrollContainerRef.current);\n }\n return () => observer.disconnect();\n }, [VISIBLE_DAYS]);\n\n // Track whether navigation was initiated by scroll (to avoid double-animation)\n const scrollNavigatedRef = React.useRef(false);\n const prevDateRef = React.useRef(currentDate);\n\n const handleNavigate = React.useCallback(\n (daysDelta: number) => {\n scrollNavigatedRef.current = true;\n onDateChange?.(addDays(currentDate, daysDelta));\n },\n [currentDate, onDateChange],\n );\n\n const handleDragNavigate = React.useCallback(\n (daysDelta: number) => {\n onDateChange?.(addDays(currentDate, daysDelta));\n },\n [currentDate, onDateChange],\n );\n\n const visibleDayDates = React.useMemo(() => days.map((d) => d.date), [days]);\n\n const { resizeState, handleResizeMouseDown } = useEventResize({\n hourHeight,\n scrollContainerRef,\n events: timedEvents,\n days: visibleDayDates,\n dayColumnWidth,\n timeAxisWidth: TIME_AXIS_WIDTH,\n onEventChange,\n onEventClick,\n onResizeNavigate: handleDragNavigate,\n });\n\n const { dragState, handleEventMouseDown } = useEventDrag({\n hourHeight,\n scrollContainerRef,\n events: timedEvents,\n days: visibleDayDates,\n dayColumnWidth,\n timeAxisWidth: TIME_AXIS_WIDTH,\n onEventChange,\n onEventClick,\n onDragNavigate: handleDragNavigate,\n });\n\n const { scrollOffset, slideOffset, isAnimating, triggerSlideAnimation } =\n useHorizontalScroll({\n containerRef: scrollContainerRef,\n dayColumnWidth,\n onNavigate: handleNavigate,\n disabled:\n dragState?.isDragging ||\n resizeState?.isResizing ||\n isAllDayResizing ||\n contextMenuOpen,\n });\n\n // Compute how many days the scroll has shifted from center\n const scrollDaysDelta =\n dayColumnWidth > 0 ? Math.round(-scrollOffset / dayColumnWidth) : 0;\n\n // Report visible days to parent in real-time as scroll crosses day boundaries\n React.useEffect(() => {\n const start = addDays(currentDate, scrollDaysDelta);\n const end = addDays(start, VISIBLE_DAYS - 1);\n onVisibleDaysChange?.(eachDayOfInterval({ start, end }));\n }, [currentDate, scrollDaysDelta, onVisibleDaysChange, VISIBLE_DAYS]);\n\n // Dynamic buffer: extends in BUFFER_STEP chunks based on scroll distance\n const extraScrollDays =\n dayColumnWidth > 0 && BUFFER_STEP > 0\n ? Math.ceil(Math.abs(scrollOffset) / dayColumnWidth / BUFFER_STEP) *\n BUFFER_STEP\n : 0;\n const dynamicBuffer = BUFFER_DAYS + extraScrollDays;\n const totalDays = dynamicBuffer + VISIBLE_DAYS + dynamicBuffer;\n\n // Extended buffered days for scroll (grows dynamically with scroll distance)\n const bufferedBaseDays = React.useMemo(\n () => generateBufferedDays(currentDate, dynamicBuffer, VISIBLE_DAYS),\n [currentDate, dynamicBuffer, VISIBLE_DAYS],\n );\n\n const bufferedDays: WeekDay[] = bufferedBaseDays.map((day) => ({\n ...day,\n isToday: isToday(day.date),\n }));\n\n const bufferedDayDates = React.useMemo(\n () => bufferedBaseDays.map((d) => d.date),\n [bufferedBaseDays],\n );\n\n const { allDayResizeState, handleAllDayResizeMouseDown } = useAllDayResize({\n days: bufferedDayDates,\n dayColumnWidth,\n allDayContainerRef: allDayScrollContentRef,\n events: allDayEvents,\n onEventChange,\n onEventClick,\n });\n\n React.useEffect(() => {\n setIsAllDayResizing(allDayResizeState?.isResizing ?? false);\n }, [allDayResizeState?.isResizing]);\n\n // Trigger slide animation when currentDate changes externally (not from scroll)\n React.useEffect(() => {\n if (scrollNavigatedRef.current) {\n scrollNavigatedRef.current = false;\n prevDateRef.current = currentDate;\n return;\n }\n\n const prevDate = prevDateRef.current;\n const daysDiff = differenceInCalendarDays(currentDate, prevDate);\n prevDateRef.current = currentDate;\n\n if (daysDiff === 0) return;\n\n triggerSlideAnimation(daysDiff);\n }, [currentDate, triggerSlideAnimation]);\n\n // The base translateX centers on the visible days (skip dynamicBuffer columns)\n const baseTranslateX = -(dynamicBuffer * dayColumnWidth);\n const transformX = baseTranslateX + scrollOffset + slideOffset;\n\n const scrollStyle: React.CSSProperties = {\n width: `${(totalDays / VISIBLE_DAYS) * 100}%`,\n transform: `translateX(${transformX}px)`,\n transition: isAnimating ? `transform ${200}ms ease-out` : \"none\",\n };\n\n // Ref for the popover collision boundary (constrains popovers within the calendar area)\n const calendarBoundaryRef = React.useRef(null);\n // Ref for the header (weekday columns + all-day row) to measure its height for popover top inset\n const calendarHeaderRef = React.useRef(null);\n\n return (\n \n {\n const target = e.target as HTMLElement;\n if (target.closest(\"[data-radix-popper-content-wrapper]\")) return;\n onBackgroundClick?.();\n }}\n >\n {/* Header - day columns and all-day row with synchronized scroll */}\n
\n {\n (\n dayColumnsScrollRef as React.MutableRefObject\n ).current = el;\n (\n calendarHeaderRef as React.MutableRefObject\n ).current = el;\n }}\n className=\"overflow-hidden\"\n >\n
\n {/* Timezone label - rendered outside scroll container */}\n
\n {new Date()\n .toLocaleTimeString(\"en-US\", { timeZoneName: \"short\" })\n .match(/\\s([A-Z]{2,5})$/)?.[1] ?? \"\"}\n
\n
\n
\n \n
\n
\n
\n
\n
\n \n
\n
\n\n {/* Scrollable grid area \\u2014 also serves as the collision boundary for popovers */}\n \n \n \n
\n
\n \n
\n
\n \n
\n
\n \n \n );\n}\n", "type": "registry:component", "target": "components/layouts/calendar/week-view.tsx" }, { "path": "components/ui/kbd.tsx", "content": "import { cn } from \"@/lib/utils\";\n\ninterface KbdProps extends React.ComponentProps<\"kbd\"> {\n /** \"default\" renders with muted background, \"ghost\" renders with transparent background */\n variant?: \"default\" | \"ghost\";\n}\n\nfunction Kbd({ className, variant = \"default\", ...props }: KbdProps) {\n return (\n \n );\n}\n\nfunction KbdGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nexport { Kbd, KbdGroup };\n", "type": "registry:ui", "target": "components/ui/kbd.tsx" }, { "path": "components/ui/sidebar.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { PanelLeftIcon } from \"lucide-react\";\n\nimport { useIsMobile } from \"@/hooks/layouts/use-mobile\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n Sheet,\n SheetContent,\n SheetDescription,\n SheetHeader,\n SheetTitle,\n} from \"@/components/ui/sheet\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nconst SIDEBAR_COOKIE_NAME = \"sidebar_state\";\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;\nconst SIDEBAR_WIDTH = \"18rem\";\nconst SIDEBAR_WIDTH_MOBILE = \"18rem\";\nconst SIDEBAR_WIDTH_ICON = \"3rem\";\nconst SIDEBAR_KEYBOARD_SHORTCUT = \"b\";\n\ntype SidebarContextProps = {\n state: \"expanded\" | \"collapsed\";\n open: boolean;\n setOpen: (open: boolean) => void;\n openMobile: boolean;\n setOpenMobile: (open: boolean) => void;\n isMobile: boolean;\n toggleSidebar: () => void;\n};\n\nconst SidebarContext = React.createContext(null);\n\nfunction useSidebar() {\n const context = React.useContext(SidebarContext);\n if (!context) {\n throw new Error(\"useSidebar must be used within a SidebarProvider.\");\n }\n\n return context;\n}\n\nfunction SidebarProvider({\n defaultOpen = true,\n open: openProp,\n onOpenChange: setOpenProp,\n className,\n style,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n defaultOpen?: boolean;\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n}) {\n const isMobile = useIsMobile();\n const [openMobile, setOpenMobile] = React.useState(false);\n\n // This is the internal state of the sidebar.\n // We use openProp and setOpenProp for control from outside the component.\n const [_open, _setOpen] = React.useState(defaultOpen);\n const open = openProp ?? _open;\n const setOpen = React.useCallback(\n (value: boolean | ((value: boolean) => boolean)) => {\n const openState = typeof value === \"function\" ? value(open) : value;\n if (setOpenProp) {\n setOpenProp(openState);\n } else {\n _setOpen(openState);\n }\n\n // This sets the cookie to keep the sidebar state.\n document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;\n },\n [setOpenProp, open],\n );\n\n // Helper to toggle the sidebar.\n const toggleSidebar = React.useCallback(() => {\n return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);\n }, [isMobile, setOpen, setOpenMobile]);\n\n // Adds a keyboard shortcut to toggle the sidebar.\n React.useEffect(() => {\n const handleKeyDown = (event: KeyboardEvent) => {\n if (\n event.key === SIDEBAR_KEYBOARD_SHORTCUT &&\n (event.metaKey || event.ctrlKey)\n ) {\n event.preventDefault();\n toggleSidebar();\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n return () => window.removeEventListener(\"keydown\", handleKeyDown);\n }, [toggleSidebar]);\n\n // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n // This makes it easier to style the sidebar with Tailwind classes.\n const state = open ? \"expanded\" : \"collapsed\";\n\n const contextValue = React.useMemo(\n () => ({\n state,\n open,\n setOpen,\n isMobile,\n openMobile,\n setOpenMobile,\n toggleSidebar,\n }),\n [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],\n );\n\n return (\n \n \n \n {children}\n \n \n \n );\n}\n\nfunction Sidebar({\n side = \"left\",\n variant = \"sidebar\",\n collapsible = \"offcanvas\",\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n side?: \"left\" | \"right\";\n variant?: \"sidebar\" | \"floating\" | \"inset\";\n collapsible?: \"offcanvas\" | \"icon\" | \"none\";\n}) {\n const { isMobile, state, openMobile, setOpenMobile } = useSidebar();\n\n if (collapsible === \"none\") {\n return (\n \n {children}\n \n );\n }\n\n if (isMobile) {\n return (\n \n button]:hidden\"\n style={\n {\n \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE,\n } as React.CSSProperties\n }\n side={side}\n >\n \n Sidebar\n Displays the mobile sidebar.\n \n
{children}
\n \n
\n );\n }\n\n return (\n