UNPKG

@atiqisrak/shadcn-weekly-calendar

Version:

A beautiful weekly calendar component for shadcn/ui with zoom functionality and event management

22 lines 21.4 kB
{ "name": "weekly-calendar", "type": "registry:ui", "registryDependencies": [ "avatar", "button", "hover-card", "scroll-area" ], "dependencies": [ "lucide-react" ], "devDependencies": [], "files": [ { "name": "weekly-calendar.tsx", "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\"\nimport { Button } from \"@/components/ui/button\"\nimport { HoverCard, HoverCardContent, HoverCardTrigger } from \"@/components/ui/hover-card\"\nimport { cn } from \"@/lib/utils\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\nimport { ChevronLeft, ChevronRight, Clock, Users, Tag, ZoomIn, ZoomOut } from \"lucide-react\"\n\ntype Participant = {\n id: string\n name: string\n image?: string\n}\n\nexport type WeeklyEvent = {\n id: string\n title: string\n description?: string\n start: string\n end: string\n participants?: Participant[]\n status?: string\n tags?: string[]\n}\n\ntype WeeklyCalendarProps = {\n events: WeeklyEvent[]\n height: string\n}\n\nconst HOURS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]\nconst HOUR_HEIGHT = 60\nconst DAYS = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\"]\nconst TIME_COLUMN_WIDTH = 80 // Increased width for AM/PM format\n\nfunction formatTime(hour: number): string {\n if (hour === 24) return \"12:00 AM\"\n if (hour === 0) return \"12:00 AM\"\n if (hour < 12) return `${hour}:00 AM`\n if (hour === 12) return \"12:00 PM\"\n return `${hour - 12}:00 PM`\n}\n\nfunction getMonday(date: Date) {\n const day = date.getDay()\n const diff = (day === 0 ? -6 : 1) - day\n const monday = new Date(date)\n monday.setDate(date.getDate() + diff)\n monday.setHours(0, 0, 0, 0)\n return monday\n}\n\nfunction addDays(date: Date, days: number) {\n const d = new Date(date)\n d.setDate(d.getDate() + days)\n return d\n}\n\nfunction dayIndexFromMonday(date: Date) {\n const idx = date.getDay() === 0 ? 6 : date.getDay() - 1\n return Math.min(idx, 5) // cap to Sat for this UI\n}\n\nfunction hourFloat(date: Date) {\n return date.getHours() + date.getMinutes() / 60\n}\n\nexport function WeeklyCalendar({ events, height }: WeeklyCalendarProps) {\n const [currentDate, setCurrentDate] = React.useState(() => {\n if (!events?.length) return new Date()\n return new Date(events[0].start)\n })\n \n const [zoomedDate, setZoomedDate] = React.useState<Date | null>(null)\n const scrollAreaRef = React.useRef<HTMLDivElement>(null)\n\n const weekStart = React.useMemo(() => getMonday(currentDate), [currentDate])\n const monthLabel = React.useMemo(() => {\n return currentDate.toLocaleString(undefined, { month: \"long\", year: \"numeric\" })\n }, [currentDate])\n\n const prevMonthLabel = React.useMemo(() => {\n const prevMonth = new Date(currentDate)\n prevMonth.setMonth(prevMonth.getMonth() - 1)\n return prevMonth.toLocaleString(undefined, { month: \"short\" })\n }, [currentDate])\n\n const nextMonthLabel = React.useMemo(() => {\n const nextMonth = new Date(currentDate)\n nextMonth.setMonth(nextMonth.getMonth() + 1)\n return nextMonth.toLocaleString(undefined, { month: \"short\" })\n }, [currentDate])\n\n const handlePrevWeek = () => {\n setCurrentDate(prev => {\n const newDate = new Date(prev)\n newDate.setDate(newDate.getDate() - 7)\n return newDate\n })\n }\n\n const handleNextWeek = () => {\n setCurrentDate(prev => {\n const newDate = new Date(prev)\n newDate.setDate(newDate.getDate() + 7)\n return newDate\n })\n }\n\n const handlePrevMonth = () => {\n setCurrentDate(prev => {\n const newDate = new Date(prev)\n newDate.setMonth(newDate.getMonth() - 1)\n return newDate\n })\n }\n\n const handleNextMonth = () => {\n setCurrentDate(prev => {\n const newDate = new Date(prev)\n newDate.setMonth(newDate.getMonth() + 1)\n return newDate\n })\n }\n\n // Determine the date range based on zoom level\n const { displayStart, displayEnd, displayDays } = React.useMemo(() => {\n if (zoomedDate) {\n // Single day view\n const start = new Date(zoomedDate)\n start.setHours(0, 0, 0, 0)\n const end = new Date(zoomedDate)\n end.setHours(23, 59, 59, 999)\n return {\n displayStart: start,\n displayEnd: end,\n displayDays: [zoomedDate.toLocaleDateString(undefined, { weekday: 'short' })]\n }\n } else {\n // Week view\n const end = new Date(weekStart)\n end.setDate(end.getDate() + 6)\n end.setHours(23, 59, 59, 999)\n return {\n displayStart: weekStart,\n displayEnd: end,\n displayDays: DAYS\n }\n }\n }, [weekStart, zoomedDate])\n\n const displayEvents = React.useMemo(() => {\n return events.filter(event => {\n const eventStart = new Date(event.start)\n const eventEnd = new Date(event.end)\n \n return (\n (eventStart >= displayStart && eventStart <= displayEnd) ||\n (eventEnd >= displayStart && eventEnd <= displayEnd) ||\n (eventStart <= displayStart && eventEnd >= displayEnd)\n )\n })\n }, [events, displayStart, displayEnd])\n\n const handleZoomIn = (dayIndex: number) => {\n if (!zoomedDate) {\n const targetDate = addDays(weekStart, dayIndex)\n setZoomedDate(targetDate)\n // Scroll to 8 AM after zoom\n setTimeout(() => {\n if (scrollAreaRef.current) {\n const viewport = scrollAreaRef.current.querySelector('[data-slot=\"scroll-area-viewport\"]') as HTMLElement\n if (viewport) {\n const scrollTarget = (8 - 1) * HOUR_HEIGHT // 8 AM position\n viewport.scrollTo({ top: scrollTarget, behavior: 'smooth' })\n }\n }\n }, 100)\n }\n }\n\n const handleZoomOut = () => {\n setZoomedDate(null)\n }\n\n const handleDayNavigation = (direction: 'prev' | 'next') => {\n if (zoomedDate) {\n const newDate = new Date(zoomedDate)\n newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1))\n setZoomedDate(newDate)\n }\n }\n\n React.useEffect(() => {\n // Auto scroll to 8 AM when component mounts or zoom changes\n if (scrollAreaRef.current && !zoomedDate) {\n const viewport = scrollAreaRef.current.querySelector('[data-slot=\"scroll-area-viewport\"]') as HTMLElement\n if (viewport) {\n const scrollTarget = (8 - 1) * HOUR_HEIGHT\n viewport.scrollTo({ top: scrollTarget, behavior: 'smooth' })\n }\n }\n }, [zoomedDate])\n\n const containerHeight = HOURS.length * HOUR_HEIGHT\n const isZoomed = !!zoomedDate\n const gridColumns = isZoomed ? 1 : 5\n\n return (\n <div className=\"w-full rounded-3xl bg-rose-50/40 dark:bg-background border border-border p-4 md:p-6\">\n {/* Header with navigation */}\n <div className=\"relative pb-4 md:pb-6\">\n {/* Month navigation */}\n <div className=\"flex items-center justify-between mb-3\">\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={isZoomed ? () => handleDayNavigation('prev') : handlePrevMonth}\n className=\"text-sm md:text-base rounded-full bg-white dark:bg-card border px-3 py-1 shadow-sm hover:shadow-md transition-shadow\"\n >\n <ChevronLeft className=\"h-3 w-3 mr-1\" />\n {isZoomed ? \"Prev Day\" : prevMonthLabel}\n </Button>\n <div className=\"flex items-center gap-3\">\n <h2 className=\"text-xl md:text-2xl font-semibold tracking-tight\">\n {isZoomed \n ? zoomedDate?.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })\n : monthLabel\n }\n </h2>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={isZoomed ? handleZoomOut : undefined}\n className=\"h-8 w-8 p-0 rounded-full hover:bg-white/50 dark:hover:bg-card/50\"\n title={isZoomed ? \"Zoom out to week view\" : \"Week view\"}\n >\n {isZoomed ? <ZoomOut className=\"h-4 w-4\" /> : <ZoomIn className=\"h-4 w-4 opacity-50\" />}\n </Button>\n </div>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={isZoomed ? () => handleDayNavigation('next') : handleNextMonth}\n className=\"text-sm md:text-base rounded-full bg-white dark:bg-card border px-3 py-1 shadow-sm hover:shadow-md transition-shadow\"\n >\n {isZoomed ? \"Next Day\" : nextMonthLabel}\n <ChevronRight className=\"h-3 w-3 ml-1\" />\n </Button>\n </div>\n \n {/* Week navigation - only show in week view */}\n {!isZoomed && (\n <div className=\"flex items-center justify-center gap-4\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={handlePrevWeek}\n className=\"h-8 w-8 p-0 rounded-full hover:bg-white/50 dark:hover:bg-card/50\"\n >\n <ChevronLeft className=\"h-4 w-4\" />\n </Button>\n <span className=\"text-sm text-muted-foreground\">\n Week of {weekStart.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}\n </span>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={handleNextWeek}\n className=\"h-8 w-8 p-0 rounded-full hover:bg-white/50 dark:hover:bg-card/50\"\n >\n <ChevronRight className=\"h-4 w-4\" />\n </Button>\n </div>\n )}\n </div>\n\n <div className=\"w-full overflow-x-auto scrollbar-hide\">\n <div className={cn(\"min-w-[900px]\", isZoomed && \"min-w-[400px]\")}>\n {/* Days header */}\n <div \n className={cn(\"grid gap-0\", `grid-cols-${gridColumns}`)} \n style={{ marginLeft: TIME_COLUMN_WIDTH }}\n >\n {displayDays.map((d, i) => (\n <div \n key={d} \n className={cn(\n \"flex flex-col items-center py-2 cursor-pointer hover:bg-muted/50 rounded-lg transition-colors\",\n !isZoomed && \"group\"\n )}\n onClick={() => !isZoomed && handleZoomIn(i)}\n title={!isZoomed ? \"Click to zoom into this day\" : undefined}\n >\n <div className=\"text-sm text-muted-foreground flex items-center gap-1\">\n {d}\n {!isZoomed && <ZoomIn className=\"h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity\" />}\n </div>\n <div className=\"text-xs text-muted-foreground\">\n {isZoomed \n ? zoomedDate?.getDate()\n : addDays(weekStart, i).getDate()\n }\n </div>\n </div>\n ))}\n </div>\n <ScrollArea className={`h-[${height}] overflow-y-auto`} ref={scrollAreaRef}>\n <div className=\"relative\">\n {/* Time labels - Sticky */}\n <div \n className=\"sticky left-0 top-0 z-10 bg-transparent backdrop-blur-sm border-r border-border\" \n style={{ width: TIME_COLUMN_WIDTH, height: containerHeight }}\n >\n {HOURS.map((h, i) => (\n <div\n key={h}\n className=\"pr-3 text-sm text-muted-foreground flex items-start justify-end border-b border-border/30\"\n style={{ height: HOUR_HEIGHT }}\n >\n <span className=\"mt-1\">{formatTime(h)}</span>\n </div>\n ))}\n </div>\n\n {/* Grid */}\n <div\n className={cn(\"grid relative\", `grid-cols-${gridColumns}`)}\n style={{ height: containerHeight, marginLeft: TIME_COLUMN_WIDTH }}\n >\n {Array.from({ length: gridColumns }).map((_, col) => (\n <div key={col} className=\"relative border-l border-dashed border-border/70 last:border-r\">\n {HOURS.map((h) => (\n <div key={h} className=\"border-t border-dashed border-border/60\" style={{ height: HOUR_HEIGHT }} />\n ))}\n </div>\n ))}\n\n {/* Events layer */}\n <div className=\"absolute inset-0 pointer-events-none\" aria-hidden>\n {/* spacer to align with grid */}\n </div>\n\n {displayEvents.map((event) => {\n const start = new Date(event.start)\n const end = new Date(event.end)\n \n let startIdx = 0\n let span = 1\n \n if (!isZoomed) {\n // Week view: calculate day position\n startIdx = dayIndexFromMonday(start)\n const endIdx = Math.max(startIdx, dayIndexFromMonday(end))\n span = Math.min(5, endIdx - startIdx + 1)\n } else {\n // Day view: all events in single column\n startIdx = 0\n span = 1\n }\n \n const startHour = hourFloat(start)\n const top = (startHour - 1) * HOUR_HEIGHT // Adjusted for 1-24 hour range\n\n const isPrimary = event.title.toLowerCase().includes(\"weekly team\")\n\n return (\n <HoverCard key={event.id}>\n <HoverCardTrigger asChild>\n <div\n className=\"absolute hover:shadow-md transition-all duration-300 cursor-pointer transform hover:scale-105\"\n style={{\n top,\n left: `calc(${startIdx} * (100% / ${gridColumns}))`,\n width: `calc(${span} * (100% / ${gridColumns}) - 0.5rem)`,\n }}\n >\n <div\n className={cn(\n \"pointer-events-auto rounded-2xl shadow-sm border flex items-start gap-3 px-4 py-3 md:px-6 md:py-4\",\n isPrimary\n ? \"bg-slate-900 text-white border-transparent\"\n : \"bg-white text-foreground\"\n )}\n >\n <div className=\"flex-1 min-w-0\">\n <div className=\"font-semibold text-sm md:text-base leading-tight line-clamp-1\">{event.title}</div>\n {/* {event.description ? (\n <div className={cn(\"text-xs md:text-sm mt-1 line-clamp-1\", isPrimary ? \"text-white/80\" : \"text-muted-foreground\")}>{event.description}</div>\n ) : null} */}\n </div>\n {event.participants?.length ? (\n <div className=\"flex items-center gap-1 md:gap-2 shrink-0\">\n {event.participants.slice(0, 3).map((p, idx) => (\n <Avatar key={p.id} className={cn(\"h-6 w-6 border\", idx > 0 ? \"-ml-2\" : \"\")}>\n {p.image && <AvatarImage src={p.image} alt={p.name} />}\n <AvatarFallback className={cn(\n \"text-[10px] font-semibold\",\n isPrimary\n ? [\"bg-yellow-400\",\"bg-blue-400\",\"bg-rose-400\"][idx] ?? \"bg-gray-300\"\n : \"bg-gray-100\"\n )}>{p.name?.[0] ?? \"?\"}</AvatarFallback>\n </Avatar>\n ))}\n </div>\n ) : null}\n </div>\n </div>\n </HoverCardTrigger>\n <HoverCardContent className=\"w-80 p-4\" side=\"top\" align=\"start\">\n <div className=\"space-y-3\">\n {/* Event Title and Status */}\n <div className=\"flex items-start justify-between gap-2\">\n <h3 className=\"font-semibold text-lg leading-tight\">{event.title}</h3>\n <div className={cn(\n \"px-2 py-1 rounded-full text-xs font-medium\",\n event.status === \"confirmed\" \n ? \"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300\"\n : \"bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300\"\n )}>\n {event.status}\n </div>\n </div>\n\n {/* Description */}\n {event.description && (\n <p className=\"text-sm text-muted-foreground leading-relaxed\">\n {event.description}\n </p>\n )}\n\n {/* Time Information */}\n <div className=\"flex items-center gap-2 text-sm\">\n <Clock className=\"h-4 w-4 text-muted-foreground\" />\n <span>\n {new Date(event.start).toLocaleString(undefined, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n })} - {new Date(event.end).toLocaleTimeString(undefined, {\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n })}\n </span>\n </div>\n\n {/* Participants */}\n {event.participants?.length ? (\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2 text-sm\">\n <Users className=\"h-4 w-4 text-muted-foreground\" />\n <span className=\"font-medium\">Participants ({event.participants.length})</span>\n </div>\n <div className=\"flex flex-wrap gap-2\">\n {event.participants.map((participant) => (\n <div key={participant.id} className=\"flex items-center gap-2 bg-muted rounded-lg px-2 py-1\">\n <Avatar className=\"h-5 w-5\">\n {participant.image && <AvatarImage src={participant.image} alt={participant.name} />}\n <AvatarFallback className=\"text-[10px]\">{participant.name?.[0] ?? \"?\"}</AvatarFallback>\n </Avatar>\n <span className=\"text-xs font-medium\">{participant.name}</span>\n </div>\n ))}\n </div>\n </div>\n ) : null}\n\n {/* Tags */}\n {event.tags?.length ? (\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2 text-sm\">\n <Tag className=\"h-4 w-4 text-muted-foreground\" />\n <span className=\"font-medium\">Tags</span>\n </div>\n <div className=\"flex flex-wrap gap-1\">\n {event.tags.map((tag) => (\n <span\n key={tag}\n className=\"px-2 py-1 bg-primary/10 text-primary rounded-md text-xs font-medium\"\n >\n {tag}\n </span>\n ))}\n </div>\n </div>\n ) : null}\n </div>\n </HoverCardContent>\n </HoverCard>\n )\n })}\n </div>\n </div>\n </ScrollArea>\n </div>\n </div>\n </div>\n )\n}\n\nexport default WeeklyCalendar\n\n", "type": "registry:ui", "target": "components/ui/weekly-calendar.tsx" } ] }