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
5
fork

Configure Feed

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

Merge pull request #151 from EvanTechDev/feature/add-drag-and-drop-schedule-creation

feat: small change

authored by

Evan Huang and committed by
GitHub
6c5e04c0 45c74195

+206 -52
+12 -10
components/app/calendar.tsx
··· 146 146 const [quickCreateStartTime, setQuickCreateStartTime] = useState<Date | null>( 147 147 null, 148 148 ); 149 + const [quickCreateEndTime, setQuickCreateEndTime] = useState<Date | null>( 150 + null, 151 + ); 149 152 150 153 const [defaultView, setDefaultView] = useLocalStorage<ViewType>( 151 154 "default-view", ··· 440 443 setPreviewOpen(false); 441 444 }; 442 445 443 - const handleTimeSlotClick = (clickTime: Date) => { 444 - setQuickCreateStartTime(clickTime); 446 + const handleTimeRangeSelect = (startTime: Date, endTime?: Date) => { 447 + setQuickCreateStartTime(startTime); 448 + setQuickCreateEndTime(endTime ?? null); 445 449 446 450 setSelectedEvent(null); 447 451 setEventDialogOpen(true); ··· 576 580 577 581 <div className="flex min-h-0 min-w-0 flex-1 flex-col pr-14"> 578 582 {" "} 579 - <header className="flex items-center justify-between px-4 h-16 border-b relative z-40 bg-background"> 583 + <header className="flex items-center px-4 h-16 border-b relative z-40 bg-background"> 580 584 <div className="flex items-center space-x-4"> 581 585 <Button variant="outline" onClick={toggleSidebar} size="sm"> 582 586 <PanelLeft /> ··· 584 588 <Button variant="outline" size="sm" onClick={handleTodayClick}> 585 589 {t.today || "ไปŠๅคฉ"} 586 590 </Button> 587 - </div> 588 - 589 - <div className="flex items-center space-x-2"> 590 591 {view !== "analytics" && view !== "settings" && ( 591 592 <> 592 593 <div className="flex items-center space-x-1"> ··· 606 607 )} 607 608 </div> 608 609 609 - <div className="flex items-center space-x-2"> 610 + <div className="ml-auto flex items-center space-x-2"> 610 611 <div className="relative z-50"> 611 612 <Select 612 613 value={ ··· 728 729 date={date} 729 730 events={filteredEvents} 730 731 onEventClick={handleEventClick} 731 - onTimeSlotClick={handleTimeSlotClick} 732 + onTimeSlotClick={handleTimeRangeSelect} 732 733 language={language} 733 734 timezone={timezone} 734 735 timeFormat={timeFormat} ··· 756 757 date={date} 757 758 events={filteredEvents} 758 759 onEventClick={handleEventClick} 759 - onTimeSlotClick={handleTimeSlotClick} 760 + onTimeSlotClick={handleTimeRangeSelect} 760 761 language={language} 761 762 firstDayOfWeek={firstDayOfWeek} 762 763 timezone={timezone} ··· 785 786 date={date} 786 787 events={filteredEvents} 787 788 onEventClick={handleEventClick} 788 - onTimeSlotClick={handleTimeSlotClick} 789 + onTimeSlotClick={handleTimeRangeSelect} 789 790 language={language} 790 791 firstDayOfWeek={firstDayOfWeek} 791 792 timezone={timezone} ··· 900 901 onEventUpdate={handleEventUpdate} 901 902 onEventDelete={handleEventDelete} 902 903 initialDate={quickCreateStartTime || date} 904 + initialEndDate={quickCreateEndTime} 903 905 event={selectedEvent} 904 906 language={language} 905 907 timezone={timezone}
+17 -11
components/app/event/event-dialog.tsx
··· 100 100 onEventUpdate: (event: CalendarEvent) => void; 101 101 onEventDelete: (eventId: string) => void; 102 102 initialDate: Date; 103 + initialEndDate?: Date | null; 103 104 event: CalendarEvent | null; 104 105 language: Language; 105 106 timezone: string; ··· 119 120 onEventUpdate, 120 121 onEventDelete, 121 122 initialDate, 123 + initialEndDate, 122 124 event, 123 125 language, 124 126 timezone, ··· 309 311 } else { 310 312 resetForm(); 311 313 if (initialDate) { 312 - setStartDate(initialDate); 314 + const dialogStartDate = new Date(initialDate); 315 + const dialogEndDate = 316 + initialEndDate && initialEndDate > initialDate 317 + ? new Date(initialEndDate) 318 + : new Date(initialDate.getTime() + 30 * 60000); 319 + 320 + setStartDate(dialogStartDate); 313 321 if (calendars.length > 0) { 314 322 setColor(getEventColorByCalendarId(calendars[0].id)); 315 323 } 316 - setEndDate(initialDate); 324 + setEndDate(dialogEndDate); 317 325 318 - const initialHour = getHours(initialDate); 319 - const initialMinute = getMinutes(initialDate); 320 - const endTime = new Date(initialDate); 321 - endTime.setMinutes(initialMinute + 30); 326 + const initialHour = getHours(dialogStartDate); 327 + const initialMinute = getMinutes(dialogStartDate); 322 328 323 329 setStartTime({ 324 330 hours: initialHour.toString().padStart(2, "0"), 325 331 minutes: initialMinute.toString().padStart(2, "0"), 326 - rawInput: format(initialDate, "HH:mm"), 332 + rawInput: format(dialogStartDate, "HH:mm"), 327 333 isCustomInput: false, 328 334 }); 329 335 330 336 setEndTime({ 331 - hours: getHours(endTime).toString().padStart(2, "0"), 332 - minutes: getMinutes(endTime).toString().padStart(2, "0"), 333 - rawInput: format(endTime, "HH:mm"), 337 + hours: getHours(dialogEndDate).toString().padStart(2, "0"), 338 + minutes: getMinutes(dialogEndDate).toString().padStart(2, "0"), 339 + rawInput: format(dialogEndDate, "HH:mm"), 334 340 isCustomInput: false, 335 341 }); 336 342 } 337 343 } 338 344 } 339 - }, [event, calendars, initialDate, open]); 345 + }, [event, calendars, initialDate, initialEndDate, open]); 340 346 341 347 const resetForm = () => { 342 348 const now = new Date();
+85 -15
components/app/views/day-view.tsx
··· 25 25 date: Date; 26 26 events: CalendarEvent[]; 27 27 onEventClick: (event: CalendarEvent) => void; 28 - onTimeSlotClick: (date: Date) => void; 28 + onTimeSlotClick: (startDate: Date, endDate?: Date) => void; 29 29 language: Language; 30 30 timezone: string; 31 31 timeFormat: "24h" | "12h"; ··· 77 77 const [dragEventDuration, setDragEventDuration] = useState<number>(0); 78 78 const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null); 79 79 const isDraggingRef = useRef(false); 80 + 81 + const [createSelection, setCreateSelection] = useState<{ 82 + startMinute: number; 83 + endMinute: number; 84 + } | null>(null); 85 + const createStartMinuteRef = useRef<number | null>(null); 86 + const isCreatingRef = useRef(false); 80 87 81 88 const isDark = 82 89 typeof document !== "undefined" && ··· 284 291 dragEventDuration, 285 292 ]); 286 293 294 + useEffect(() => { 295 + const handleMouseMove = (event: MouseEvent) => { 296 + if (!isCreatingRef.current || createStartMinuteRef.current === null) return; 297 + const endMinute = getMinutesFromMousePosition(event.clientY); 298 + setCreateSelection({ 299 + startMinute: createStartMinuteRef.current, 300 + endMinute, 301 + }); 302 + }; 303 + 304 + const handleMouseUp = () => { 305 + if (!isCreatingRef.current || createStartMinuteRef.current === null) return; 306 + 307 + const startMinute = Math.min( 308 + createStartMinuteRef.current, 309 + createSelection?.endMinute ?? createStartMinuteRef.current, 310 + ); 311 + const endMinute = Math.max( 312 + createStartMinuteRef.current, 313 + createSelection?.endMinute ?? createStartMinuteRef.current, 314 + ); 315 + 316 + const startDate = new Date(date); 317 + startDate.setHours(0, startMinute, 0, 0); 318 + 319 + const effectiveEndMinute = endMinute === startMinute ? startMinute + 30 : endMinute; 320 + const endDate = new Date(date); 321 + endDate.setHours(0, Math.min(effectiveEndMinute, 24 * 60), 0, 0); 322 + 323 + onTimeSlotClick(startDate, endDate); 324 + 325 + isCreatingRef.current = false; 326 + createStartMinuteRef.current = null; 327 + setCreateSelection(null); 328 + }; 329 + 330 + document.addEventListener("mousemove", handleMouseMove); 331 + document.addEventListener("mouseup", handleMouseUp); 332 + 333 + return () => { 334 + document.removeEventListener("mousemove", handleMouseMove); 335 + document.removeEventListener("mouseup", handleMouseUp); 336 + }; 337 + }, [createSelection, date, onTimeSlotClick]); 338 + 287 339 const layoutEvents = (events: CalendarEvent[]) => { 288 340 if (!events || events.length === 0) return []; 289 341 ··· 413 465 } 414 466 }; 415 467 416 - const handleTimeSlotClick = ( 417 - hour: number, 418 - event: React.MouseEvent<HTMLDivElement>, 419 - ) => { 420 - const rect = event.currentTarget.getBoundingClientRect(); 421 - const relativeY = event.clientY - rect.top; 422 - const cellHeight = rect.height; 468 + const snapToQuarterHour = (minutes: number) => { 469 + const clamped = Math.min(Math.max(minutes, 0), 24 * 60); 470 + return Math.round(clamped / 15) * 15; 471 + }; 423 472 424 - const minutes = relativeY < cellHeight / 2 ? 0 : 30; 473 + const getMinutesFromMousePosition = (clientY: number) => { 474 + if (!scrollContainerRef.current) return 0; 475 + const containerRect = scrollContainerRef.current.getBoundingClientRect(); 476 + return snapToQuarterHour( 477 + clientY - containerRect.top + scrollContainerRef.current.scrollTop, 478 + ); 479 + }; 425 480 426 - const clickTime = new Date(date); 427 - clickTime.setHours(hour, minutes, 0, 0); 481 + const handleGridMouseDown = (event: React.MouseEvent<HTMLDivElement>) => { 482 + if (event.button !== 0 || draggingEvent) return; 428 483 429 - onTimeSlotClick(clickTime); 484 + const startMinute = getMinutesFromMousePosition(event.clientY); 485 + createStartMinuteRef.current = startMinute; 486 + isCreatingRef.current = true; 487 + setCreateSelection({ startMinute, endMinute: startMinute }); 430 488 }; 431 489 432 490 const renderAllDayEvents = (allDayEvents: CalendarEvent[]) => { ··· 615 673 ))} 616 674 </div> 617 675 618 - <div className="relative border-l"> 676 + <div className="relative border-l" onMouseDown={handleGridMouseDown}> 619 677 {hours.map((hour) => ( 620 678 <div 621 679 key={hour} 622 680 className="h-[60px] border-t" 623 - onClick={(e) => handleTimeSlotClick(hour, e)} 624 681 /> 625 682 ))} 626 683 ··· 734 791 ); 735 792 })} 736 793 794 + {createSelection && ( 795 + <div 796 + 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" 797 + style={{ 798 + top: `${Math.min(createSelection.startMinute, createSelection.endMinute)}px`, 799 + height: `${Math.max(Math.abs(createSelection.endMinute - createSelection.startMinute), 15)}px`, 800 + zIndex: 5, 801 + }} 802 + /> 803 + )} 804 + 737 805 {} 738 806 {dragPreview && renderDragPreview()} 739 807 ··· 757 825 style={{ 758 826 top: `${topPosition}px`, 759 827 }} 760 - /> 828 + > 829 + <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]" /> 830 + </div> 761 831 ); 762 832 })()} 763 833 </div>
+92 -16
components/app/views/week-view.tsx
··· 27 27 date: Date; 28 28 events: any[]; 29 29 onEventClick: (event: any) => void; 30 - onTimeSlotClick: (date: Date) => void; 30 + onTimeSlotClick: (startDate: Date, endDate?: Date) => void; 31 31 language: Language; 32 32 firstDayOfWeek: number; 33 33 timezone: string; ··· 105 105 const [dragEventDuration, setDragEventDuration] = useState<number>(0); 106 106 const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null); 107 107 const isDraggingRef = useRef(false); 108 + 109 + const [createSelection, setCreateSelection] = useState<{ 110 + dayIndex: number; 111 + startMinute: number; 112 + endMinute: number; 113 + } | null>(null); 114 + const createStartRef = useRef<{ dayIndex: number; minute: number } | null>(null); 115 + const isCreatingRef = useRef(false); 108 116 const isDark = 109 117 typeof document !== "undefined" && 110 118 document.documentElement.classList.contains("dark"); ··· 261 269 weekDays, 262 270 dragEventDuration, 263 271 ]); 272 + 273 + useEffect(() => { 274 + const handleMouseMove = (event: MouseEvent) => { 275 + if (!isCreatingRef.current || !createStartRef.current) return; 276 + const endMinute = getMinutesFromMousePosition(event.clientY); 277 + setCreateSelection({ 278 + dayIndex: createStartRef.current.dayIndex, 279 + startMinute: createStartRef.current.minute, 280 + endMinute, 281 + }); 282 + }; 283 + 284 + const handleMouseUp = () => { 285 + if (!isCreatingRef.current || !createStartRef.current) return; 286 + 287 + const { dayIndex, minute } = createStartRef.current; 288 + const startMinute = Math.min(minute, createSelection?.endMinute ?? minute); 289 + const endMinute = Math.max(minute, createSelection?.endMinute ?? minute); 290 + const day = weekDays[dayIndex]; 291 + 292 + if (day) { 293 + const startDate = new Date(day); 294 + startDate.setHours(0, startMinute, 0, 0); 295 + 296 + const effectiveEndMinute = endMinute === startMinute ? startMinute + 30 : endMinute; 297 + const endDate = new Date(day); 298 + endDate.setHours(0, Math.min(effectiveEndMinute, 24 * 60), 0, 0); 299 + 300 + onTimeSlotClick(startDate, endDate); 301 + } 302 + 303 + isCreatingRef.current = false; 304 + createStartRef.current = null; 305 + setCreateSelection(null); 306 + }; 307 + 308 + document.addEventListener("mousemove", handleMouseMove); 309 + document.addEventListener("mouseup", handleMouseUp); 310 + 311 + return () => { 312 + document.removeEventListener("mousemove", handleMouseMove); 313 + document.removeEventListener("mouseup", handleMouseUp); 314 + }; 315 + }, [createSelection, onTimeSlotClick, weekDays]); 264 316 265 317 const formatTime = (hour: number) => { 266 318 if (timeFormat === "12h") { ··· 515 567 } 516 568 }; 517 569 518 - const handleTimeSlotClick = ( 519 - day: Date, 520 - hour: number, 521 - event: React.MouseEvent<HTMLDivElement>, 522 - ) => { 523 - const rect = event.currentTarget.getBoundingClientRect(); 524 - const relativeY = event.clientY - rect.top; 525 - const cellHeight = rect.height; 570 + const snapToQuarterHour = (minutes: number) => { 571 + const clamped = Math.min(Math.max(minutes, 0), 24 * 60); 572 + return Math.round(clamped / 15) * 15; 573 + }; 526 574 527 - const minutes = relativeY < cellHeight / 2 ? 0 : 30; 575 + const getMinutesFromMousePosition = (clientY: number) => { 576 + if (!scrollContainerRef.current) return 0; 577 + const containerRect = scrollContainerRef.current.getBoundingClientRect(); 578 + return snapToQuarterHour( 579 + clientY - containerRect.top + scrollContainerRef.current.scrollTop, 580 + ); 581 + }; 528 582 529 - const clickTime = new Date(day); 530 - clickTime.setHours(hour, minutes, 0, 0); 583 + const handleGridMouseDown = ( 584 + dayIndex: number, 585 + event: React.MouseEvent<HTMLDivElement>, 586 + ) => { 587 + if (event.button !== 0 || draggingEvent) return; 531 588 532 - onTimeSlotClick(clickTime); 589 + const startMinute = getMinutesFromMousePosition(event.clientY); 590 + createStartRef.current = { dayIndex, minute: startMinute }; 591 + isCreatingRef.current = true; 592 + setCreateSelection({ dayIndex, startMinute, endMinute: startMinute }); 533 593 }; 534 594 535 595 const renderAllDayEvents = (day: Date, allDayEvents: CalendarEvent[]) => { ··· 736 796 const eventLayouts = layoutEventsForDay(regularEvents, day); 737 797 738 798 return ( 739 - <div key={day.toString()} className="relative border-l grid-col"> 799 + <div 800 + key={day.toString()} 801 + className="relative border-l grid-col" 802 + onMouseDown={(event) => handleGridMouseDown(dayIndex, event)} 803 + > 740 804 {hours.map((hour) => ( 741 805 <div 742 806 key={hour} 743 807 className="h-[60px] border-t" 744 - onClick={(e) => handleTimeSlotClick(day, hour, e)} 745 808 /> 746 809 ))} 747 810 ··· 857 920 }, 858 921 )} 859 922 923 + {createSelection && createSelection.dayIndex === dayIndex && ( 924 + <div 925 + 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" 926 + style={{ 927 + top: `${Math.min(createSelection.startMinute, createSelection.endMinute)}px`, 928 + height: `${Math.max(Math.abs(createSelection.endMinute - createSelection.startMinute), 15)}px`, 929 + zIndex: 5, 930 + }} 931 + /> 932 + )} 933 + 860 934 {} 861 935 {dragPreview && 862 936 isSameDay(dragPreview.day, day) && ··· 878 952 style={{ 879 953 top: `${topPosition}px`, 880 954 }} 881 - /> 955 + > 956 + <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]" /> 957 + </div> 882 958 ); 883 959 })()} 884 960 </div>