[READ-ONLY] One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links 馃搮
calendar.xyehr.cn
nextjs
33 kB
1061 lines
1'use client'
2
3import { useEffect, useRef, useState } from 'react'
4import type React from 'react'
5import { Edit3, Share2, Bookmark, Trash2 } from 'lucide-react'
6import {
7 format,
8 startOfWeek,
9 endOfWeek,
10 eachDayOfInterval,
11 isSameDay,
12 isWithinInterval,
13 add,
14 addDays,
15 startOfDay,
16} from 'date-fns'
17import { cn } from '@/lib/utils'
18import { translations, type Language } from '@/lib/i18n'
19import type { CalendarEvent } from '../calendar'
20
21const ContextMenu = ({ children }: { children: React.ReactNode }) => (
22 <>{children}</>
23)
24const ContextMenuTrigger = ({
25 children,
26}: {
27 children: React.ReactNode
28 asChild?: boolean
29}) => <>{children}</>
30const ContextMenuContent = ({
31 children,
32 className,
33}: {
34 children: React.ReactNode
35 className?: string
36}) => <>{children}</>
37const ContextMenuItem = (_props: any) => null
38
39interface WeekViewProps {
40 date: Date
41 events: CalendarEvent[]
42 onEventClick: (event: CalendarEvent, anchorEl?: HTMLElement | null) => void
43 onTimeSlotClick: (startDate: Date, endDate?: Date) => void
44 language: Language
45 firstDayOfWeek: number
46 timezone: string
47 timeFormat: '24h' | '12h'
48 onEditEvent?: (event: CalendarEvent) => void
49 onDeleteEvent?: (event: CalendarEvent) => void
50 onShareEvent?: (event: CalendarEvent) => void
51 onBookmarkEvent?: (event: CalendarEvent) => void
52 onEventDrop?: (
53 event: CalendarEvent,
54 newStartDate: Date,
55 newEndDate: Date,
56 ) => void
57 daysToShow?: number
58 fixedStartDate?: Date
59}
60
61interface CalendarEvent {
62 id: string
63 startDate: string | Date
64 endDate: string | Date
65 title: string
66 color: string
67 isAllDay?: boolean
68}
69
70export default function WeekView({
71 date,
72 events,
73 onEventClick,
74 onTimeSlotClick,
75 language,
76 firstDayOfWeek,
77 timezone,
78 timeFormat,
79 onEditEvent,
80 onDeleteEvent,
81 onShareEvent,
82 onBookmarkEvent,
83 onEventDrop,
84 daysToShow,
85 fixedStartDate,
86}: WeekViewProps) {
87 const weekStart = startOfWeek(date, { weekStartsOn: firstDayOfWeek })
88 const weekEnd = endOfWeek(date, { weekStartsOn: firstDayOfWeek })
89 const weekDays = daysToShow
90 ? Array.from({ length: daysToShow }, (_, index) =>
91 addDays(startOfDay(fixedStartDate ?? date), index),
92 )
93 : eachDayOfInterval({ start: weekStart, end: weekEnd })
94 const hours = Array.from({ length: 24 }, (_, i) => i)
95 const gridTemplateColumns = `100px repeat(${weekDays.length}, minmax(0, 1fr))`
96 const today = new Date()
97 const t = translations[language]
98
99 const [currentTime, setCurrentTime] = useState(new Date())
100 const hasScrolledRef = useRef(false)
101 const scrollContainerRef = useRef<HTMLDivElement>(null)
102
103 const [draggingEvent, setDraggingEvent] = useState<CalendarEvent | null>(null)
104 const [dragStartPosition, setDragStartPosition] = useState<{
105 x: number
106 y: number
107 } | null>(null)
108 const [dragOffset, setDragOffset] = useState<{ x: number; y: number } | null>(
109 null,
110 )
111 const [dragPreview, setDragPreview] = useState<{
112 day: Date
113 hour: number
114 minute: number
115 } | null>(null)
116 const [dragEventDuration, setDragEventDuration] = useState<number>(0)
117 const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null)
118 const ignoreNextEventClickRef = useRef(false)
119 const isDraggingRef = useRef(false)
120
121 const queueIgnoreEventClick = () => {
122 ignoreNextEventClickRef.current = true
123 window.setTimeout(() => {
124 ignoreNextEventClickRef.current = false
125 }, 0)
126 }
127
128 const [createSelection, setCreateSelection] = useState<{
129 dayIndex: number
130 startMinute: number
131 endMinute: number
132 } | null>(null)
133 const createStartRef = useRef<{ dayIndex: number; minute: number } | null>(
134 null,
135 )
136 const isCreatingRef = useRef(false)
137 const isDark =
138 typeof document !== 'undefined' &&
139 document.documentElement.classList.contains('dark')
140
141 const menuLabels = {
142 edit: t.edit,
143 share: t.share,
144 bookmark: t.bookmark,
145 delete: t.delete,
146 }
147
148 function getDarkerColorClass(color: string) {
149 const colorMapping: Record<string, string> = {
150 'bg-[#E6F6FD]': '#3B82F6',
151 'bg-[#E7F8F2]': '#10B981',
152 'bg-[#FEF5E6]': '#F59E0B',
153 'bg-[#FFE4E6]': '#EF4444',
154 'bg-[#F3EEFE]': '#8B5CF6',
155 'bg-[#FCE7F3]': '#EC4899',
156 'bg-[#EEF2FF]': '#6366F1',
157 'bg-[#FFF0E5]': '#FB923C',
158 'bg-[#E6FAF7]': '#14B8A6',
159 }
160
161 return colorMapping[color] || '#3A3A3A'
162 }
163
164 function getEventBackgroundColor(color: string) {
165 if (!isDark) return undefined
166
167 const darkModeColorMapping: Record<string, string> = {
168 'bg-[#E6F6FD]': '#2F4655',
169 'bg-[#E7F8F2]': '#2D4935',
170 'bg-[#FEF5E6]': '#4F3F1B',
171 'bg-[#FFE4E6]': '#6C2920',
172 'bg-[#F3EEFE]': '#483A63',
173 'bg-[#FCE7F3]': '#5A334A',
174 'bg-[#E6FAF7]': '#1F4A47',
175 }
176
177 return darkModeColorMapping[color]
178 }
179
180 useEffect(() => {
181 if (!hasScrolledRef.current && scrollContainerRef.current) {
182 const now = new Date()
183 const currentHour = now.getHours()
184
185 const hourElements =
186 scrollContainerRef.current.querySelectorAll('.h-\\[60px\\]')
187 if (hourElements.length > 0 && currentHour < hourElements.length) {
188 const currentHourElement = hourElements[currentHour + 1]
189
190 if (currentHourElement) {
191 scrollContainerRef.current.scrollTo({
192 top: (currentHourElement as HTMLElement).offsetTop - 100,
193 behavior: 'auto',
194 })
195
196 hasScrolledRef.current = true
197 }
198 }
199 }
200 }, [date, weekDays])
201
202 useEffect(() => {
203 setCurrentTime(new Date())
204
205 const interval = setInterval(() => {
206 setCurrentTime(new Date())
207 }, 60000)
208
209 return () => clearInterval(interval)
210 }, [])
211
212 useEffect(() => {
213 const handleMouseMove = (e: MouseEvent) => {
214 if (
215 draggingEvent &&
216 isDraggingRef.current &&
217 dragStartPosition &&
218 scrollContainerRef.current
219 ) {
220 const containerRect = scrollContainerRef.current.getBoundingClientRect()
221 const gridItems =
222 scrollContainerRef.current.querySelectorAll('.grid-col')
223
224 let closestDayIndex = 0
225 let minDistance = Infinity
226
227 gridItems.forEach((item, index) => {
228 const rect = item.getBoundingClientRect()
229 const centerX = rect.left + rect.width / 2
230 const distance = Math.abs(e.clientX - centerX)
231
232 if (distance < minDistance) {
233 minDistance = distance
234 closestDayIndex = index
235 }
236 })
237
238 const relativeY =
239 e.clientY - containerRect.top + scrollContainerRef.current.scrollTop
240 const hour = Math.floor(relativeY / 60)
241 const minute = Math.floor((relativeY % 60) / 15) * 15
242
243 if (closestDayIndex < weekDays.length) {
244 setDragPreview({
245 day: weekDays[closestDayIndex],
246 hour: hour,
247 minute: minute,
248 })
249 }
250 }
251 }
252
253 const handleMouseUp = () => {
254 if (
255 draggingEvent &&
256 isDraggingRef.current &&
257 dragPreview &&
258 onEventDrop
259 ) {
260 const newStartDate = new Date(dragPreview.day)
261 newStartDate.setHours(dragPreview.hour, dragPreview.minute, 0, 0)
262
263 const newEndDate = add(newStartDate, { minutes: dragEventDuration })
264
265 onEventDrop(draggingEvent, newStartDate, newEndDate)
266 }
267
268 isDraggingRef.current = false
269 setDraggingEvent(null)
270 setDragStartPosition(null)
271 setDragOffset(null)
272 setDragPreview(null)
273 }
274
275 if (draggingEvent) {
276 document.addEventListener('mousemove', handleMouseMove)
277 document.addEventListener('mouseup', handleMouseUp)
278 }
279
280 return () => {
281 document.removeEventListener('mousemove', handleMouseMove)
282 document.removeEventListener('mouseup', handleMouseUp)
283 }
284 }, [
285 draggingEvent,
286 dragStartPosition,
287 dragPreview,
288 onEventDrop,
289 weekDays,
290 dragEventDuration,
291 ])
292
293 useEffect(() => {
294 const handleMouseMove = (event: MouseEvent) => {
295 if (!isCreatingRef.current || !createStartRef.current) return
296 const endMinute = getMinutesFromMousePosition(event.clientY)
297 setCreateSelection({
298 dayIndex: createStartRef.current.dayIndex,
299 startMinute: createStartRef.current.minute,
300 endMinute,
301 })
302 }
303
304 const handleMouseUp = () => {
305 if (!isCreatingRef.current || !createStartRef.current) return
306
307 const { dayIndex, minute } = createStartRef.current
308 const startMinute = Math.min(minute, createSelection?.endMinute ?? minute)
309 const endMinute = Math.max(minute, createSelection?.endMinute ?? minute)
310 const day = weekDays[dayIndex]
311
312 if (day) {
313 const startDate = new Date(day)
314 startDate.setHours(0, startMinute, 0, 0)
315
316 const effectiveEndMinute =
317 endMinute === startMinute ? startMinute + 30 : endMinute
318 const endDate = new Date(day)
319 endDate.setHours(0, Math.min(effectiveEndMinute, 24 * 60), 0, 0)
320
321 onTimeSlotClick(startDate, endDate)
322 }
323
324 isCreatingRef.current = false
325 createStartRef.current = null
326 setCreateSelection(null)
327 }
328
329 document.addEventListener('mousemove', handleMouseMove)
330 document.addEventListener('mouseup', handleMouseUp)
331
332 return () => {
333 document.removeEventListener('mousemove', handleMouseMove)
334 document.removeEventListener('mouseup', handleMouseUp)
335 }
336 }, [createSelection, onTimeSlotClick, weekDays])
337
338 const formatTime = (hour: number) => {
339 if (timeFormat === '12h') {
340 const period = hour >= 12 ? 'PM' : 'AM'
341 const twelveHour = hour % 12 || 12
342 return `${twelveHour} ${period}`
343 }
344 return `${hour.toString().padStart(2, '0')}:00`
345 }
346
347 const formatHourMinute = (hour: number, minute: number) => {
348 if (timeFormat === '12h') {
349 const period = hour >= 12 ? 'PM' : 'AM'
350 const twelveHour = hour % 12 || 12
351 return `${twelveHour}:${minute.toString().padStart(2, '0')} ${period}`
352 }
353 return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
354 }
355
356 const formatDateWithTimezone = (date: Date) => {
357 const options: Intl.DateTimeFormatOptions = {
358 hour: '2-digit',
359 minute: '2-digit',
360 hour12: timeFormat === '12h',
361 timeZone: timezone,
362 }
363 return new Intl.DateTimeFormat(language, options).format(date)
364 }
365
366 const isAllDayEvent = (event: CalendarEvent) => {
367 if (event.isAllDay) return true
368
369 const start = new Date(event.startDate)
370 const end = new Date(event.endDate)
371
372 const isFullDay =
373 start.getHours() === 0 &&
374 start.getMinutes() === 0 &&
375 ((end.getHours() === 23 && end.getMinutes() === 59) ||
376 (end.getHours() === 0 &&
377 end.getMinutes() === 0 &&
378 end.getDate() !== start.getDate()))
379
380 return isFullDay
381 }
382
383 const isMultiDayEvent = (start: Date, end: Date) => {
384 if (!start || !end) return false
385
386 return (
387 start.getDate() !== end.getDate() ||
388 start.getMonth() !== end.getMonth() ||
389 start.getFullYear() !== end.getFullYear()
390 )
391 }
392
393 const shouldShowEventOnDay = (event: CalendarEvent, day: Date) => {
394 const start = new Date(event.startDate)
395 const end = new Date(event.endDate)
396
397 if (isAllDayEvent(event) && isMultiDayEvent(start, end)) {
398 return isSameDay(start, day)
399 }
400
401 if (isSameDay(start, day)) return true
402
403 if (isMultiDayEvent(start, end) && !isAllDayEvent(event)) {
404 return isWithinInterval(day, { start, end })
405 }
406
407 return false
408 }
409
410 const getEventTimesForDay = (event: CalendarEvent, day: Date) => {
411 const start = new Date(event.startDate)
412 const end = new Date(event.endDate)
413
414 if (!start || !end) return null
415
416 const isMultiDay = isMultiDayEvent(start, end)
417
418 let dayStart = start
419 let dayEnd = end
420
421 if (isMultiDay) {
422 if (!isSameDay(start, day)) {
423 dayStart = new Date(day)
424 dayStart.setHours(0, 0, 0, 0)
425 }
426
427 if (!isSameDay(end, day)) {
428 dayEnd = new Date(day)
429 dayEnd.setHours(23, 59, 59, 999)
430 }
431 }
432
433 return {
434 start: dayStart,
435 end: dayEnd,
436 isMultiDay,
437 }
438 }
439
440 const separateEvents = (dayEvents: CalendarEvent[], day: Date) => {
441 const allDayEvents: CalendarEvent[] = []
442 const regularEvents: CalendarEvent[] = []
443
444 dayEvents.forEach((event) => {
445 if (isAllDayEvent(event)) {
446 allDayEvents.push(event)
447 } else {
448 regularEvents.push(event)
449 }
450 })
451
452 return { allDayEvents, regularEvents }
453 }
454
455 const layoutEventsForDay = (dayEvents: CalendarEvent[], day: Date) => {
456 if (!dayEvents || dayEvents.length === 0) return []
457
458 const eventsWithTimes = dayEvents
459 .map((event) => {
460 const times = getEventTimesForDay(event, day)
461 if (!times) return null
462 return { event, ...times }
463 })
464 .filter(Boolean) as Array<{
465 event: CalendarEvent
466 start: Date
467 end: Date
468 isMultiDay: boolean
469 }>
470
471 eventsWithTimes.sort((a, b) => a.start.getTime() - b.start.getTime())
472
473 type TimePoint = { time: number; isStart: boolean; eventIndex: number }
474 const timePoints: TimePoint[] = []
475
476 eventsWithTimes.forEach((eventWithTime, index) => {
477 const startTime = eventWithTime.start.getTime()
478 const endTime = eventWithTime.end.getTime()
479
480 timePoints.push({ time: startTime, isStart: true, eventIndex: index })
481 timePoints.push({ time: endTime, isStart: false, eventIndex: index })
482 })
483
484 timePoints.sort((a, b) => {
485 if (a.time === b.time) {
486 return a.isStart ? 1 : -1
487 }
488 return a.time - b.time
489 })
490
491 const eventLayouts: Array<{
492 event: CalendarEvent
493 start: Date
494 end: Date
495 column: number
496 totalColumns: number
497 isMultiDay: boolean
498 }> = []
499
500 const activeEvents = new Set<number>()
501
502 const eventToColumn = new Map<number, number>()
503
504 for (let i = 0; i < timePoints.length; i++) {
505 const point = timePoints[i]
506
507 if (point.isStart) {
508 activeEvents.add(point.eventIndex)
509
510 let column = 0
511 const usedColumns = new Set<number>()
512
513 activeEvents.forEach((eventIndex) => {
514 if (eventToColumn.has(eventIndex)) {
515 usedColumns.add(eventToColumn.get(eventIndex)!)
516 }
517 })
518
519 while (usedColumns.has(column)) {
520 column++
521 }
522
523 eventToColumn.set(point.eventIndex, column)
524 } else {
525 activeEvents.delete(point.eventIndex)
526 }
527
528 if (
529 i === timePoints.length - 1 ||
530 timePoints[i + 1].time !== point.time
531 ) {
532 const totalColumns =
533 activeEvents.size > 0
534 ? Math.max(
535 ...Array.from(activeEvents).map(
536 (idx) => eventToColumn.get(idx)!,
537 ),
538 ) + 1
539 : 0
540
541 activeEvents.forEach((eventIndex) => {
542 const column = eventToColumn.get(eventIndex)!
543 const { event, start, end, isMultiDay } = eventsWithTimes[eventIndex]
544
545 const existingLayout = eventLayouts.find(
546 (layout) => layout.event.id === event.id,
547 )
548
549 if (!existingLayout) {
550 eventLayouts.push({
551 event,
552 start,
553 end,
554 column,
555 totalColumns: Math.max(totalColumns, 1),
556 isMultiDay,
557 })
558 }
559 })
560 }
561 }
562
563 return eventLayouts
564 }
565
566 const handleEventDragStart = (event: CalendarEvent, e: React.MouseEvent) => {
567 e.preventDefault()
568 e.stopPropagation()
569
570 longPressTimeoutRef.current = setTimeout(() => {
571 const start = new Date(event.startDate)
572 const end = new Date(event.endDate)
573
574 const durationMs = end.getTime() - start.getTime()
575 const durationMinutes = Math.round(durationMs / (1000 * 60))
576
577 setDraggingEvent(event)
578 setDragStartPosition({ x: e.clientX, y: e.clientY })
579 setDragEventDuration(durationMinutes)
580 isDraggingRef.current = true
581 }, 300)
582 }
583
584 const handleEventDragEnd = () => {
585 if (longPressTimeoutRef.current) {
586 clearTimeout(longPressTimeoutRef.current)
587 longPressTimeoutRef.current = null
588 }
589 }
590
591 const snapToQuarterHour = (minutes: number) => {
592 const clamped = Math.min(Math.max(minutes, 0), 24 * 60)
593 return Math.round(clamped / 15) * 15
594 }
595
596 const getMinutesFromMousePosition = (clientY: number) => {
597 if (!scrollContainerRef.current) return 0
598 const containerRect = scrollContainerRef.current.getBoundingClientRect()
599 return snapToQuarterHour(
600 clientY - containerRect.top + scrollContainerRef.current.scrollTop,
601 )
602 }
603
604 const handleGridMouseDown = (
605 dayIndex: number,
606 event: React.MouseEvent<HTMLDivElement>,
607 ) => {
608 if (event.button !== 0 || draggingEvent) return
609
610 const startMinute = getMinutesFromMousePosition(event.clientY)
611 createStartRef.current = { dayIndex, minute: startMinute }
612 isCreatingRef.current = true
613 setCreateSelection({ dayIndex, startMinute, endMinute: startMinute })
614 }
615
616 const renderAllDayEvents = (day: Date, allDayEvents: CalendarEvent[]) => {
617 const eventSpacing = 2
618
619 return allDayEvents.map((event, index) => (
620 <ContextMenu
621 key={`allday-${event.id}-${day.toISOString().split('T')[0]}`}
622 >
623 <ContextMenuTrigger asChild>
624 <div
625 className={cn(
626 'relative rounded-lg p-1 text-xs cursor-pointer overflow-hidden',
627 event.color,
628 )}
629 style={{
630 height: '20px',
631
632 top: index * (20 + eventSpacing) + 'px',
633 position: 'absolute',
634 left: '0',
635 right: '0',
636 opacity: isDark ? 1 : 0.9,
637 backgroundColor: getEventBackgroundColor(event.color),
638 zIndex: 10 + index,
639 }}
640 onMouseDown={(e) => handleEventDragStart(event, e)}
641 onMouseUp={handleEventDragEnd}
642 onMouseLeave={handleEventDragEnd}
643 onContextMenu={(e) => {
644 e.preventDefault()
645 e.stopPropagation()
646 queueIgnoreEventClick()
647 }}
648 onClick={(e) => {
649 e.stopPropagation()
650 if (ignoreNextEventClickRef.current) return
651 if (!isDraggingRef.current) {
652 onEventClick(event, e.currentTarget as HTMLElement)
653 }
654 }}
655 >
656 <div
657 className={cn('absolute left-0 top-0 w-1 h-full rounded-l-md')}
658 style={{ backgroundColor: getDarkerColorClass(event.color) }}
659 />
660 <div
661 className="pl-1.5 truncate"
662 style={{ color: getDarkerColorClass(event.color) }}
663 >
664 {event.title}
665 </div>
666 </div>
667 </ContextMenuTrigger>
668
669 <ContextMenuContent className="w-40">
670 <ContextMenuItem
671 onSelect={(e) => {
672 e.preventDefault()
673 e.stopPropagation()
674 queueIgnoreEventClick()
675 onEditEvent?.(event)
676 }}
677 >
678 <Edit3 className="mr-2 h-4 w-4" />
679 {menuLabels.edit}
680 </ContextMenuItem>
681 <ContextMenuItem
682 onSelect={(e) => {
683 e.preventDefault()
684 e.stopPropagation()
685 queueIgnoreEventClick()
686 onShareEvent?.(event)
687 }}
688 >
689 <Share2 className="mr-2 h-4 w-4" />
690 {menuLabels.share}
691 </ContextMenuItem>
692 <ContextMenuItem
693 onSelect={(e) => {
694 e.preventDefault()
695 e.stopPropagation()
696 queueIgnoreEventClick()
697 onBookmarkEvent?.(event)
698 }}
699 >
700 <Bookmark className="mr-2 h-4 w-4" />
701 {menuLabels.bookmark}
702 </ContextMenuItem>
703 <ContextMenuItem
704 onSelect={(e) => {
705 e.preventDefault()
706 e.stopPropagation()
707 queueIgnoreEventClick()
708 onDeleteEvent?.(event)
709 }}
710 className="text-red-600"
711 >
712 <Trash2 className="mr-2 h-4 w-4" />
713 {menuLabels.delete}
714 </ContextMenuItem>
715 </ContextMenuContent>
716 </ContextMenu>
717 ))
718 }
719
720 const renderDragPreview = () => {
721 if (!dragPreview || !draggingEvent) return null
722
723 const dayIndex = weekDays.findIndex((day) =>
724 isSameDay(day, dragPreview.day),
725 )
726 if (dayIndex === -1) return null
727
728 const startMinutes = dragPreview.hour * 60 + dragPreview.minute
729 const endMinutes = startMinutes + dragEventDuration
730
731 return (
732 <div
733 className={cn(
734 'absolute rounded-lg p-2 text-sm cursor-pointer overflow-hidden',
735 draggingEvent.color,
736 )}
737 style={{
738 top: `${startMinutes}px`,
739 height: `${dragEventDuration}px`,
740 opacity: 0.6,
741 width: `calc(100% - 4px)`,
742 left: '2px',
743 zIndex: 100,
744 border: '2px dashed white',
745 pointerEvents: 'none',
746 }}
747 >
748 <div
749 className={cn('absolute left-0 top-0 w-1 h-full rounded-l-md')}
750 style={{ backgroundColor: getDarkerColorClass(draggingEvent.color) }}
751 />
752 <div className="pl-1">
753 <div
754 className="font-medium truncate"
755 style={{ color: getDarkerColorClass(draggingEvent.color) }}
756 >
757 {draggingEvent.title}
758 </div>
759 {dragEventDuration >= 40 && (
760 <div className="text-xs text-white/90 truncate">
761 {formatHourMinute(dragPreview.hour, dragPreview.minute)} -{' '}
762 {formatHourMinute(Math.floor(endMinutes / 60), endMinutes % 60)}
763 </div>
764 )}
765 </div>
766 </div>
767 )
768 }
769
770 return (
771 <div className="flex flex-col h-full">
772 <div
773 className="grid divide-x relative z-30 bg-background border-b"
774 style={{ gridTemplateColumns }}
775 >
776 <div className="sticky top-0 z-30 bg-background" />
777 {weekDays.map((day) => {
778 const dayEvents = events.filter((event) =>
779 shouldShowEventOnDay(event, day),
780 )
781
782 const { allDayEvents } = separateEvents(dayEvents, day)
783
784 const eventSpacing = 2
785 const allDayEventsHeight =
786 allDayEvents.length > 0
787 ? allDayEvents.length * 20 +
788 (allDayEvents.length - 1) * eventSpacing
789 : 0
790
791 return (
792 <div
793 key={day.toString()}
794 className="sticky top-0 z-30 bg-background"
795 >
796 <div className="p-2 text-center">
797 <div>{t.weekdays[day.getDay()]}</div>
798 {}
799 <div
800 className={cn(
801 isSameDay(day, today)
802 ? 'text-[#0066FF] font-bold green:text-[#24a854] orange:text-[#e26912] azalea:text-[#CD2F7B]'
803 : '',
804 )}
805 >
806 {format(day, 'd')}
807 </div>
808 </div>
809
810 {}
811 <div
812 className="relative"
813 style={{ height: allDayEventsHeight + 'px' }}
814 >
815 {renderAllDayEvents(day, allDayEvents)}
816 </div>
817 </div>
818 )
819 })}
820 </div>
821
822 <div
823 className="flex-1 grid divide-x overflow-auto"
824 style={{ gridTemplateColumns }}
825 ref={scrollContainerRef}
826 >
827 <div className="text-sm text-muted-foreground">
828 {hours.map((hour) => (
829 <div key={hour} className="h-[60px] relative border-gray-200">
830 <span
831 className={cn(
832 'absolute right-4',
833 hour === 0 ? 'top-0' : 'top-0 -translate-y-1/2',
834 )}
835 >
836 {formatTime(hour)}
837 </span>
838 </div>
839 ))}
840 </div>
841
842 {weekDays.map((day, dayIndex) => {
843 const dayEvents = events.filter((event) =>
844 shouldShowEventOnDay(event, day),
845 )
846
847 const { regularEvents } = separateEvents(dayEvents, day)
848
849 const eventLayouts = layoutEventsForDay(regularEvents, day)
850
851 return (
852 <div
853 key={day.toString()}
854 className="relative border-l grid-col"
855 onMouseDown={(event) => handleGridMouseDown(dayIndex, event)}
856 >
857 {hours.map((hour) => (
858 <div key={hour} className="h-[60px] border-t" />
859 ))}
860
861 {eventLayouts.map(
862 ({ event, start, end, column, totalColumns }) => {
863 const startMinutes =
864 start.getHours() * 60 + start.getMinutes()
865 const endMinutes = end.getHours() * 60 + end.getMinutes()
866 const duration = endMinutes - startMinutes
867
868 const minHeight = 20
869 const height = Math.max(duration, minHeight)
870
871 const width = `calc((100% - 4px) / ${totalColumns})`
872 const left = `calc(${column} * ${width})`
873
874 return (
875 <ContextMenu
876 key={`${event.id}-${day.toISOString().split('T')[0]}`}
877 >
878 <ContextMenuTrigger asChild>
879 <div
880 className={cn(
881 'relative absolute rounded-lg p-2 text-sm cursor-pointer overflow-hidden',
882 event.color,
883 )}
884 style={{
885 top: `${startMinutes}px`,
886 height: `${height}px`,
887 opacity: isDark ? 1 : 0.92,
888 backgroundColor: getEventBackgroundColor(
889 event.color,
890 ),
891 width,
892 left,
893 zIndex: column + 1,
894 }}
895 onMouseDown={(e) => handleEventDragStart(event, e)}
896 onMouseUp={handleEventDragEnd}
897 onMouseLeave={handleEventDragEnd}
898 onClick={(e) => {
899 e.stopPropagation()
900 if (!isDraggingRef.current) {
901 onEventClick(
902 event,
903 e.currentTarget as HTMLElement,
904 )
905 }
906 }}
907 >
908 <div
909 className={cn(
910 'absolute left-0 top-0 w-1 h-full rounded-l-md',
911 )}
912 style={{
913 backgroundColor: getDarkerColorClass(event.color),
914 }}
915 />
916 <div className="pl-1">
917 <div
918 className="font-medium leading-tight break-words"
919 style={{
920 color: getDarkerColorClass(event.color),
921 display: '-webkit-box',
922 WebkitBoxOrient: 'vertical',
923 WebkitLineClamp: Math.max(
924 1,
925 Math.floor((height - 8) / 16),
926 ),
927 overflow: 'hidden',
928 textOverflow: 'ellipsis',
929 }}
930 >
931 {event.title}
932 </div>
933 {height >= 40 && (
934 <div
935 className="text-xs truncate"
936 style={{
937 color: getDarkerColorClass(event.color),
938 }}
939 >
940 {formatDateWithTimezone(start)} -{' '}
941 {formatDateWithTimezone(end)}
942 </div>
943 )}
944 </div>
945 </div>
946 </ContextMenuTrigger>
947
948 <ContextMenuContent className="w-40">
949 <ContextMenuItem
950 onSelect={(e) => {
951 e.preventDefault()
952 e.stopPropagation()
953 queueIgnoreEventClick()
954 onEditEvent?.(event)
955 }}
956 >
957 <Edit3 className="mr-2 h-4 w-4" />
958 {menuLabels.edit}
959 </ContextMenuItem>
960 <ContextMenuItem
961 onSelect={(e) => {
962 e.preventDefault()
963 e.stopPropagation()
964 queueIgnoreEventClick()
965 onShareEvent?.(event)
966 }}
967 >
968 <Share2 className="mr-2 h-4 w-4" />
969 {menuLabels.share}
970 </ContextMenuItem>
971 <ContextMenuItem
972 onSelect={(e) => {
973 e.preventDefault()
974 e.stopPropagation()
975 queueIgnoreEventClick()
976 onBookmarkEvent?.(event)
977 }}
978 >
979 <Bookmark className="mr-2 h-4 w-4" />
980 {menuLabels.bookmark}
981 </ContextMenuItem>
982 <ContextMenuItem
983 onSelect={(e) => {
984 e.preventDefault()
985 e.stopPropagation()
986 queueIgnoreEventClick()
987 onDeleteEvent?.(event)
988 }}
989 className="text-red-600"
990 >
991 <Trash2 className="mr-2 h-4 w-4" />
992 {menuLabels.delete}
993 </ContextMenuItem>
994 </ContextMenuContent>
995 </ContextMenu>
996 )
997 },
998 )}
999
1000 {createSelection && createSelection.dayIndex === dayIndex && (
1001 <div
1002 className="absolute left-0 right-0 bg-[#0066FF]/15 border border-[#0066FF]/40 pointer-events-none green:bg-[#24a854]/15 green:border-[#24a854]/40 orange:bg-[#e26912]/15 orange:border-[#e26912]/40 azalea:bg-[#CD2F7B]/15 azalea:border-[#CD2F7B]/40"
1003 style={{
1004 top: `${Math.min(createSelection.startMinute, createSelection.endMinute)}px`,
1005 height: `${Math.max(Math.abs(createSelection.endMinute - createSelection.startMinute), 15)}px`,
1006 zIndex: 5,
1007 }}
1008 />
1009 )}
1010
1011 {}
1012 {dragPreview &&
1013 isSameDay(dragPreview.day, day) &&
1014 renderDragPreview()}
1015
1016 {isSameDay(day, today) &&
1017 (() => {
1018 const currentTimeInTimezone = new Date(
1019 currentTime.toLocaleString('en-US', { timeZone: timezone }),
1020 )
1021 const currentHours = currentTimeInTimezone.getHours()
1022 const currentMinutes = currentTimeInTimezone.getMinutes()
1023
1024 const topPosition = currentHours * 60 + currentMinutes
1025
1026 return (
1027 <div
1028 className="absolute left-0 right-0 border-t-2 border-[#0066FF] z-0 green:border-[#24a854] orange:border-[#e26912] azalea:border-[#CD2F7B]"
1029 style={{
1030 top: `${topPosition}px`,
1031 }}
1032 >
1033 <span className="absolute -left-1.5 -top-[5px] h-2.5 w-2.5 rounded-full bg-[#0066FF] green:bg-[#24a854] orange:bg-[#e26912] azalea:bg-[#CD2F7B]" />
1034 </div>
1035 )
1036 })()}
1037 </div>
1038 )
1039 })}
1040 </div>
1041
1042 {}
1043 {draggingEvent && (
1044 <div
1045 className="fixed px-2 py-1 bg-black text-white rounded-md text-xs z-50 pointer-events-none"
1046 style={{
1047 left: dragOffset
1048 ? dragStartPosition!.x + dragOffset.x + 10
1049 : dragStartPosition!.x + 10,
1050 top: dragOffset
1051 ? dragStartPosition!.y + dragOffset.y + 10
1052 : dragStartPosition!.y + 10,
1053 opacity: 0.8,
1054 }}
1055 >
1056 {t.dragToNewPosition}
1057 </div>
1058 )}
1059 </div>
1060 )
1061}