@object-ui/plugin-calendar 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,504 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ "use client"
10
+
11
+ import * as React from "react"
12
+ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
13
+ import { cn, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@object-ui/components"
14
+
15
+ const DEFAULT_EVENT_COLOR = "bg-blue-500 text-white"
16
+
17
+ export interface CalendarEvent {
18
+ id: string | number
19
+ title: string
20
+ start: Date
21
+ end?: Date
22
+ allDay?: boolean
23
+ color?: string
24
+ data?: any
25
+ }
26
+
27
+ export interface CalendarViewProps {
28
+ events?: CalendarEvent[]
29
+ view?: "month" | "week" | "day"
30
+ currentDate?: Date
31
+ onEventClick?: (event: CalendarEvent) => void
32
+ onDateClick?: (date: Date) => void
33
+ onViewChange?: (view: "month" | "week" | "day") => void
34
+ onNavigate?: (date: Date) => void
35
+ className?: string
36
+ }
37
+
38
+ function CalendarView({
39
+ events = [],
40
+ view = "month",
41
+ currentDate = new Date(),
42
+ onEventClick,
43
+ onDateClick,
44
+ onViewChange,
45
+ onNavigate,
46
+ className,
47
+ }: CalendarViewProps) {
48
+ const [selectedView, setSelectedView] = React.useState(view)
49
+ const [selectedDate, setSelectedDate] = React.useState(currentDate)
50
+
51
+ const handlePrevious = () => {
52
+ const newDate = new Date(selectedDate)
53
+ if (selectedView === "month") {
54
+ newDate.setMonth(newDate.getMonth() - 1)
55
+ } else if (selectedView === "week") {
56
+ newDate.setDate(newDate.getDate() - 7)
57
+ } else {
58
+ newDate.setDate(newDate.getDate() - 1)
59
+ }
60
+ setSelectedDate(newDate)
61
+ onNavigate?.(newDate)
62
+ }
63
+
64
+ const handleNext = () => {
65
+ const newDate = new Date(selectedDate)
66
+ if (selectedView === "month") {
67
+ newDate.setMonth(newDate.getMonth() + 1)
68
+ } else if (selectedView === "week") {
69
+ newDate.setDate(newDate.getDate() + 7)
70
+ } else {
71
+ newDate.setDate(newDate.getDate() + 1)
72
+ }
73
+ setSelectedDate(newDate)
74
+ onNavigate?.(newDate)
75
+ }
76
+
77
+ const handleToday = () => {
78
+ const today = new Date()
79
+ setSelectedDate(today)
80
+ onNavigate?.(today)
81
+ }
82
+
83
+ const handleViewChange = (newView: "month" | "week" | "day") => {
84
+ setSelectedView(newView)
85
+ onViewChange?.(newView)
86
+ }
87
+
88
+ const getDateLabel = () => {
89
+ if (selectedView === "month") {
90
+ return selectedDate.toLocaleDateString("default", {
91
+ month: "long",
92
+ year: "numeric",
93
+ })
94
+ } else if (selectedView === "week") {
95
+ const weekStart = getWeekStart(selectedDate)
96
+ const weekEnd = new Date(weekStart)
97
+ weekEnd.setDate(weekEnd.getDate() + 6)
98
+ return `${weekStart.toLocaleDateString("default", {
99
+ month: "short",
100
+ day: "numeric",
101
+ })} - ${weekEnd.toLocaleDateString("default", {
102
+ month: "short",
103
+ day: "numeric",
104
+ year: "numeric",
105
+ })}`
106
+ } else {
107
+ return selectedDate.toLocaleDateString("default", {
108
+ weekday: "long",
109
+ month: "long",
110
+ day: "numeric",
111
+ year: "numeric",
112
+ })
113
+ }
114
+ }
115
+
116
+ return (
117
+ <div className={cn("flex flex-col h-full bg-background", className)}>
118
+ {/* Header */}
119
+ <div className="flex items-center justify-between p-4 border-b">
120
+ <div className="flex items-center gap-2">
121
+ <Button variant="outline" size="sm" onClick={handleToday}>
122
+ Today
123
+ </Button>
124
+ <div className="flex items-center">
125
+ <Button
126
+ variant="ghost"
127
+ size="icon"
128
+ onClick={handlePrevious}
129
+ className="h-8 w-8"
130
+ >
131
+ <ChevronLeftIcon className="h-4 w-4" />
132
+ </Button>
133
+ <Button
134
+ variant="ghost"
135
+ size="icon"
136
+ onClick={handleNext}
137
+ className="h-8 w-8"
138
+ >
139
+ <ChevronRightIcon className="h-4 w-4" />
140
+ </Button>
141
+ </div>
142
+ <h2 className="text-lg font-semibold ml-2">{getDateLabel()}</h2>
143
+ </div>
144
+ <div className="flex items-center gap-2">
145
+ <Select value={selectedView} onValueChange={handleViewChange}>
146
+ <SelectTrigger className="w-32">
147
+ <SelectValue />
148
+ </SelectTrigger>
149
+ <SelectContent>
150
+ <SelectItem value="day">Day</SelectItem>
151
+ <SelectItem value="week">Week</SelectItem>
152
+ <SelectItem value="month">Month</SelectItem>
153
+ </SelectContent>
154
+ </Select>
155
+ </div>
156
+ </div>
157
+
158
+ {/* Calendar Grid */}
159
+ <div className="flex-1 overflow-auto">
160
+ {selectedView === "month" && (
161
+ <MonthView
162
+ date={selectedDate}
163
+ events={events}
164
+ onEventClick={onEventClick}
165
+ onDateClick={onDateClick}
166
+ />
167
+ )}
168
+ {selectedView === "week" && (
169
+ <WeekView
170
+ date={selectedDate}
171
+ events={events}
172
+ onEventClick={onEventClick}
173
+ onDateClick={onDateClick}
174
+ />
175
+ )}
176
+ {selectedView === "day" && (
177
+ <DayView
178
+ date={selectedDate}
179
+ events={events}
180
+ onEventClick={onEventClick}
181
+ />
182
+ )}
183
+ </div>
184
+ </div>
185
+ )
186
+ }
187
+
188
+ function getWeekStart(date: Date): Date {
189
+ const d = new Date(date)
190
+ const day = d.getDay()
191
+ const diff = d.getDate() - day
192
+ d.setDate(diff)
193
+ return d
194
+ }
195
+
196
+ function getMonthDays(date: Date): Date[] {
197
+ const year = date.getFullYear()
198
+ const month = date.getMonth()
199
+ const firstDay = new Date(year, month, 1)
200
+ const lastDay = new Date(year, month + 1, 0)
201
+ const startDay = firstDay.getDay()
202
+ const days: Date[] = []
203
+
204
+ // Add previous month days
205
+ for (let i = startDay - 1; i >= 0; i--) {
206
+ const prevDate = new Date(firstDay.getTime())
207
+ prevDate.setDate(prevDate.getDate() - (i + 1))
208
+ days.push(prevDate)
209
+ }
210
+
211
+ // Add current month days
212
+ for (let i = 1; i <= lastDay.getDate(); i++) {
213
+ days.push(new Date(year, month, i))
214
+ }
215
+
216
+ // Add next month days
217
+ const remainingDays = 42 - days.length
218
+ for (let i = 1; i <= remainingDays; i++) {
219
+ const nextDate = new Date(lastDay.getTime())
220
+ nextDate.setDate(nextDate.getDate() + i)
221
+ days.push(nextDate)
222
+ }
223
+
224
+ return days
225
+ }
226
+
227
+ function isSameDay(date1: Date, date2: Date): boolean {
228
+ return (
229
+ date1.getFullYear() === date2.getFullYear() &&
230
+ date1.getMonth() === date2.getMonth() &&
231
+ date1.getDate() === date2.getDate()
232
+ )
233
+ }
234
+
235
+ function getEventsForDate(date: Date, events: CalendarEvent[]): CalendarEvent[] {
236
+ return events.filter((event) => {
237
+ const eventStart = new Date(event.start)
238
+ const eventEnd = event.end ? new Date(event.end) : new Date(eventStart)
239
+
240
+ // Create new date objects for comparison to avoid mutation
241
+ const dateStart = new Date(date)
242
+ dateStart.setHours(0, 0, 0, 0)
243
+ const dateEnd = new Date(date)
244
+ dateEnd.setHours(23, 59, 59, 999)
245
+
246
+ const eventStartTime = new Date(eventStart)
247
+ eventStartTime.setHours(0, 0, 0, 0)
248
+ const eventEndTime = new Date(eventEnd)
249
+ eventEndTime.setHours(23, 59, 59, 999)
250
+
251
+ return dateStart <= eventEndTime && dateEnd >= eventStartTime
252
+ })
253
+ }
254
+
255
+ interface MonthViewProps {
256
+ date: Date
257
+ events: CalendarEvent[]
258
+ onEventClick?: (event: CalendarEvent) => void
259
+ onDateClick?: (date: Date) => void
260
+ }
261
+
262
+ function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps) {
263
+ const days = getMonthDays(date)
264
+ const today = new Date()
265
+ const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
266
+
267
+ return (
268
+ <div className="flex flex-col h-full">
269
+ {/* Week day headers */}
270
+ <div className="grid grid-cols-7 border-b">
271
+ {weekDays.map((day) => (
272
+ <div
273
+ key={day}
274
+ className="p-2 text-center text-sm font-medium text-muted-foreground border-r last:border-r-0"
275
+ >
276
+ {day}
277
+ </div>
278
+ ))}
279
+ </div>
280
+
281
+ {/* Calendar days */}
282
+ <div className="grid grid-cols-7 flex-1 auto-rows-fr">
283
+ {days.map((day, index) => {
284
+ const dayEvents = getEventsForDate(day, events)
285
+ const isCurrentMonth = day.getMonth() === date.getMonth()
286
+ const isToday = isSameDay(day, today)
287
+
288
+ return (
289
+ <div
290
+ key={index}
291
+ className={cn(
292
+ "border-b border-r last:border-r-0 p-2 min-h-[100px] cursor-pointer hover:bg-accent/50",
293
+ !isCurrentMonth && "bg-muted/30 text-muted-foreground"
294
+ )}
295
+ onClick={() => onDateClick?.(day)}
296
+ >
297
+ <div
298
+ className={cn(
299
+ "text-sm font-medium mb-1",
300
+ isToday &&
301
+ "inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground h-6 w-6"
302
+ )}
303
+ >
304
+ {day.getDate()}
305
+ </div>
306
+ <div className="space-y-1">
307
+ {dayEvents.slice(0, 3).map((event) => (
308
+ <div
309
+ key={event.id}
310
+ className={cn(
311
+ "text-xs px-2 py-1 rounded truncate cursor-pointer hover:opacity-80",
312
+ event.color || DEFAULT_EVENT_COLOR
313
+ )}
314
+ style={
315
+ event.color && event.color.startsWith("#")
316
+ ? { backgroundColor: event.color }
317
+ : undefined
318
+ }
319
+ onClick={(e) => {
320
+ e.stopPropagation()
321
+ onEventClick?.(event)
322
+ }}
323
+ >
324
+ {event.title}
325
+ </div>
326
+ ))}
327
+ {dayEvents.length > 3 && (
328
+ <div className="text-xs text-muted-foreground px-2">
329
+ +{dayEvents.length - 3} more
330
+ </div>
331
+ )}
332
+ </div>
333
+ </div>
334
+ )
335
+ })}
336
+ </div>
337
+ </div>
338
+ )
339
+ }
340
+
341
+ interface WeekViewProps {
342
+ date: Date
343
+ events: CalendarEvent[]
344
+ onEventClick?: (event: CalendarEvent) => void
345
+ onDateClick?: (date: Date) => void
346
+ }
347
+
348
+ function WeekView({ date, events, onEventClick, onDateClick }: WeekViewProps) {
349
+ const weekStart = getWeekStart(date)
350
+ const weekDays = Array.from({ length: 7 }, (_, i) => {
351
+ const day = new Date(weekStart)
352
+ day.setDate(day.getDate() + i)
353
+ return day
354
+ })
355
+ const today = new Date()
356
+
357
+ return (
358
+ <div className="flex flex-col h-full">
359
+ {/* Week day headers */}
360
+ <div className="grid grid-cols-7 border-b">
361
+ {weekDays.map((day) => {
362
+ const isToday = isSameDay(day, today)
363
+ return (
364
+ <div
365
+ key={day.toISOString()}
366
+ className="p-3 text-center border-r last:border-r-0"
367
+ >
368
+ <div className="text-sm font-medium text-muted-foreground">
369
+ {day.toLocaleDateString("default", { weekday: "short" })}
370
+ </div>
371
+ <div
372
+ className={cn(
373
+ "text-lg font-semibold mt-1",
374
+ isToday &&
375
+ "inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground h-8 w-8"
376
+ )}
377
+ >
378
+ {day.getDate()}
379
+ </div>
380
+ </div>
381
+ )
382
+ })}
383
+ </div>
384
+
385
+ {/* Week events */}
386
+ <div className="grid grid-cols-7 flex-1">
387
+ {weekDays.map((day) => {
388
+ const dayEvents = getEventsForDate(day, events)
389
+ return (
390
+ <div
391
+ key={day.toISOString()}
392
+ className="border-r last:border-r-0 p-2 min-h-[400px] cursor-pointer hover:bg-accent/50"
393
+ onClick={() => onDateClick?.(day)}
394
+ >
395
+ <div className="space-y-2">
396
+ {dayEvents.map((event) => (
397
+ <div
398
+ key={event.id}
399
+ className={cn(
400
+ "text-sm px-3 py-2 rounded cursor-pointer hover:opacity-80",
401
+ event.color || DEFAULT_EVENT_COLOR
402
+ )}
403
+ style={
404
+ event.color && event.color.startsWith("#")
405
+ ? { backgroundColor: event.color }
406
+ : undefined
407
+ }
408
+ onClick={(e) => {
409
+ e.stopPropagation()
410
+ onEventClick?.(event)
411
+ }}
412
+ >
413
+ <div className="font-medium">{event.title}</div>
414
+ {!event.allDay && (
415
+ <div className="text-xs opacity-90 mt-1">
416
+ {event.start.toLocaleTimeString("default", {
417
+ hour: "numeric",
418
+ minute: "2-digit",
419
+ })}
420
+ </div>
421
+ )}
422
+ </div>
423
+ ))}
424
+ </div>
425
+ </div>
426
+ )
427
+ })}
428
+ </div>
429
+ </div>
430
+ )
431
+ }
432
+
433
+ interface DayViewProps {
434
+ date: Date
435
+ events: CalendarEvent[]
436
+ onEventClick?: (event: CalendarEvent) => void
437
+ }
438
+
439
+ function DayView({ date, events, onEventClick }: DayViewProps) {
440
+ const dayEvents = getEventsForDate(date, events)
441
+ const hours = Array.from({ length: 24 }, (_, i) => i)
442
+
443
+ return (
444
+ <div className="flex flex-col h-full">
445
+ <div className="flex-1 overflow-auto">
446
+ {hours.map((hour) => {
447
+ const hourEvents = dayEvents.filter((event) => {
448
+ if (event.allDay) return hour === 0
449
+ const eventHour = event.start.getHours()
450
+ return eventHour === hour
451
+ })
452
+
453
+ return (
454
+ <div key={hour} className="flex border-b min-h-[60px]">
455
+ <div className="w-20 p-2 text-sm text-muted-foreground border-r">
456
+ {hour === 0
457
+ ? "12 AM"
458
+ : hour < 12
459
+ ? `${hour} AM`
460
+ : hour === 12
461
+ ? "12 PM"
462
+ : `${hour - 12} PM`}
463
+ </div>
464
+ <div className="flex-1 p-2 space-y-2">
465
+ {hourEvents.map((event) => (
466
+ <div
467
+ key={event.id}
468
+ className={cn(
469
+ "px-3 py-2 rounded cursor-pointer hover:opacity-80",
470
+ event.color || DEFAULT_EVENT_COLOR
471
+ )}
472
+ style={
473
+ event.color && event.color.startsWith("#")
474
+ ? { backgroundColor: event.color }
475
+ : undefined
476
+ }
477
+ onClick={() => onEventClick?.(event)}
478
+ >
479
+ <div className="font-medium">{event.title}</div>
480
+ {!event.allDay && (
481
+ <div className="text-xs opacity-90 mt-1">
482
+ {event.start.toLocaleTimeString("default", {
483
+ hour: "numeric",
484
+ minute: "2-digit",
485
+ })}
486
+ {event.end &&
487
+ ` - ${event.end.toLocaleTimeString("default", {
488
+ hour: "numeric",
489
+ minute: "2-digit",
490
+ })}`}
491
+ </div>
492
+ )}
493
+ </div>
494
+ ))}
495
+ </div>
496
+ </div>
497
+ )
498
+ })}
499
+ </div>
500
+ </div>
501
+ )
502
+ }
503
+
504
+ export { CalendarView }