[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
0

Configure Feed

Select the types of activity you want to include in your feed.

One-Calendar / components / app / views / week-view.tsx
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}