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 #223 from EvanTechDev/feature/replace-loader2-with-loadericon

Anchor event preview as a Popover and pass clicked element anchor from views

authored by

Evan Huang and committed by
GitHub
a0ee346e e6cd13e2

+133 -28
+2 -2
components/app/auth-waiting-loading.tsx
··· 25 25 <Image 26 26 src="/icon.svg" 27 27 alt="One Calendar" 28 - width={128} 29 - height={128} 28 + width={96} 29 + height={96} 30 30 priority 31 31 /> 32 32 <p className="text-sm text-slate-700 dark:text-slate-300">
+22 -4
components/app/calendar.tsx
··· 21 21 CloudUpload, 22 22 CheckCircle2, 23 23 AlertCircle, 24 - Loader2, 24 + LoaderIcon, 25 25 CircleHelp, 26 26 ShieldCheck, 27 27 MessageSquare, ··· 152 152 const notificationsInitializedRef = useRef(false); 153 153 const [previewEvent, setPreviewEvent] = useState<CalendarEvent | null>(null); 154 154 const [previewOpen, setPreviewOpen] = useState(false); 155 + const [previewAnchorRect, setPreviewAnchorRect] = useState<DOMRect | null>( 156 + null, 157 + ); 155 158 const [focusUserProfileSection, setFocusUserProfileSection] = 156 159 useState<UserProfileSection | null>(null); 157 160 const [sidebarDate, setSidebarDate] = useState<Date>(new Date()); ··· 248 251 if (!backupEnabled) return null; 249 252 250 253 if (backupSyncStatus === "uploading") { 251 - return <Loader2 className="h-4 w-4 animate-spin" />; 254 + return <LoaderIcon className="h-4 w-4 animate-spin" />; 252 255 } 253 256 if (backupSyncStatus === "failed") { 254 257 return <AlertCircle className="h-4 w-4 text-destructive" />; ··· 432 435 } 433 436 }; 434 437 435 - const handleEventClick = (event: CalendarEvent) => { 438 + const handleEventClick = ( 439 + event: CalendarEvent, 440 + anchorEl?: HTMLElement | null, 441 + ) => { 436 442 setShareOnlyMode(false); 437 443 setPreviewEvent(event); 444 + setPreviewAnchorRect(anchorEl?.getBoundingClientRect() ?? null); 438 445 setPreviewOpen(true); 439 446 }; 440 447 ··· 573 580 setQuickCreateStartTime(null); 574 581 setEventDialogOpen(true); 575 582 setPreviewOpen(false); 583 + setPreviewAnchorRect(null); 576 584 } 577 585 }; 578 586 ··· 583 591 }; 584 592 setEvents((prevEvents) => [...prevEvents, duplicatedEvent]); 585 593 setPreviewOpen(false); 594 + setPreviewAnchorRect(null); 586 595 }; 587 596 588 597 const handleTimeRangeSelect = (startTime: Date, endTime?: Date) => { ··· 623 632 const handleShare = (event: CalendarEvent, shareOnly = false) => { 624 633 setShareOnlyMode(shareOnly); 625 634 setPreviewEvent(event); 635 + setPreviewAnchorRect(null); 626 636 setOpenShareImmediately(true); 627 637 setPreviewOpen(true); 628 638 }; ··· 800 810 onKeyDown={(e) => { 801 811 if (e.key === "Enter" && searchResultEvents.length > 0) { 802 812 setPreviewEvent(searchResultEvents[0]); 813 + setPreviewAnchorRect(null); 803 814 setPreviewOpen(true); 804 815 setSearchTerm(""); 805 816 setIsSearchFocused(false); ··· 820 831 onMouseDown={(e) => { 821 832 e.preventDefault(); 822 833 setPreviewEvent(event); 834 + setPreviewAnchorRect(null); 823 835 setPreviewOpen(true); 824 836 setSearchTerm(""); 825 837 setIsSearchFocused(false); ··· 1054 1066 open={previewOpen} 1055 1067 onOpenChange={(open) => { 1056 1068 setPreviewOpen(open); 1057 - if (!open) setOpenShareImmediately(false); 1069 + if (!open) { 1070 + setOpenShareImmediately(false); 1071 + setPreviewAnchorRect(null); 1072 + } 1058 1073 }} 1059 1074 onEdit={handleEventEdit} 1060 1075 onDelete={() => { 1061 1076 if (previewEvent) { 1062 1077 handleEventDelete(previewEvent.id); 1063 1078 setPreviewOpen(false); 1079 + setPreviewAnchorRect(null); 1064 1080 } 1065 1081 }} 1066 1082 onDuplicate={handleEventDuplicate} ··· 1068 1084 timezone={timezone} 1069 1085 openShareImmediately={openShareImmediately} 1070 1086 shareOnlyMode={shareOnlyMode} 1087 + anchorRect={previewAnchorRect} 1088 + modal={view !== "year"} 1071 1089 /> 1072 1090 1073 1091 <EventDialog
+95 -9
components/app/event/event-preview.tsx
··· 30 30 import { cn } from "@/lib/utils"; 31 31 import { useCalendar } from "@/components/providers/calendar-context"; 32 32 import { 33 + Popover, 34 + PopoverAnchor, 35 + PopoverContent, 36 + } from "@/components/ui/popover"; 37 + import { 33 38 Dialog, 34 39 DialogContent, 35 40 DialogHeader, ··· 55 60 timezone: string; 56 61 openShareImmediately?: boolean; 57 62 shareOnlyMode?: boolean; 63 + anchorRect?: DOMRect | null; 64 + modal?: boolean; 58 65 } 59 66 60 67 export default function EventPreview({ ··· 68 75 timezone, 69 76 openShareImmediately, 70 77 shareOnlyMode = false, 78 + anchorRect = null, 79 + modal = true, 71 80 }: EventPreviewProps) { 72 81 const { calendars } = useCalendar(); 73 82 const isZh = isZhLanguage(language); ··· 88 97 const [passwordEnabled, setPasswordEnabled] = useState(false); 89 98 const [sharePassword, setSharePassword] = useState(""); 90 99 const [burnAfterRead, setBurnAfterRead] = useState(false); 100 + const ignoreOutsideUntilRef = useRef(0); 91 101 const colorMapping: Record<string, string> = { 92 102 "bg-[#E6F6FD]": "#3B82F6", 93 103 "bg-[#E7F8F2]": "#10B981", ··· 111 121 } 112 122 } 113 123 }, [open, openShareImmediately, isSignedIn, atprotoSignedIn, language]); 124 + 125 + useEffect(() => { 126 + if (open && !modal) { 127 + ignoreOutsideUntilRef.current = Date.now() + 150; 128 + } 129 + }, [open, modal]); 114 130 115 131 useEffect(() => { 116 132 fetch("/api/atproto/session") ··· 443 459 onDelete(); 444 460 }; 445 461 462 + const popoverSide: "top" | "right" | "bottom" | "left" = anchorRect 463 + ? (() => { 464 + const viewportWidth = 465 + typeof window === "undefined" ? 0 : window.innerWidth; 466 + const viewportHeight = 467 + typeof window === "undefined" ? 0 : window.innerHeight; 468 + const spaces = { 469 + top: anchorRect.top, 470 + right: viewportWidth - anchorRect.right, 471 + bottom: viewportHeight - anchorRect.bottom, 472 + left: anchorRect.left, 473 + }; 474 + const estimatedWidth = 460; 475 + const estimatedHeight = 520; 476 + if (spaces.right >= estimatedWidth) return "right"; 477 + if (spaces.left >= estimatedWidth) return "left"; 478 + if (spaces.bottom >= estimatedHeight) return "bottom"; 479 + if (spaces.top >= estimatedHeight) return "top"; 480 + const entries = Object.entries(spaces) as Array< 481 + ["top" | "right" | "bottom" | "left", number] 482 + >; 483 + return entries.sort((a, b) => b[1] - a[1])[0][0]; 484 + })() 485 + : "bottom"; 486 + 487 + const anchorStyle: React.CSSProperties = (() => { 488 + if (anchorRect) { 489 + const midX = anchorRect.left + anchorRect.width / 2; 490 + const midY = anchorRect.top + anchorRect.height / 2; 491 + const edgePoint = 492 + popoverSide === "right" 493 + ? { left: anchorRect.right, top: midY } 494 + : popoverSide === "left" 495 + ? { left: anchorRect.left, top: midY } 496 + : popoverSide === "top" 497 + ? { left: midX, top: anchorRect.top } 498 + : { left: midX, top: anchorRect.bottom }; 499 + return { 500 + position: "fixed", 501 + left: edgePoint.left, 502 + top: edgePoint.top, 503 + width: 0, 504 + height: 0, 505 + pointerEvents: "none", 506 + }; 507 + } 508 + 509 + return { 510 + position: "fixed", 511 + left: typeof window === "undefined" ? 0 : Math.round(window.innerWidth / 2), 512 + top: 513 + typeof window === "undefined" ? 0 : Math.round(window.innerHeight / 2), 514 + width: 0, 515 + height: 0, 516 + pointerEvents: "none", 517 + }; 518 + })(); 519 + 446 520 return ( 447 521 <> 448 522 {!shareOnlyMode && ( 449 - <div 450 - className="fixed inset-0 z-50 flex items-center justify-center bg-black/10" 451 - onClick={() => onOpenChange(false)} 452 - > 453 - <div 454 - className="bg-background rounded-xl shadow-lg w-full max-w-md mx-4 overflow-hidden" 455 - onClick={(e) => e.stopPropagation()} 523 + <Popover open={open} onOpenChange={onOpenChange} modal={modal}> 524 + <PopoverAnchor asChild> 525 + <div style={anchorStyle} /> 526 + </PopoverAnchor> 527 + <PopoverContent 528 + side={popoverSide} 529 + align="center" 530 + sideOffset={12} 531 + className="w-[min(96vw,28rem)] rounded-xl p-0 overflow-hidden" 532 + onOpenAutoFocus={(e) => e.preventDefault()} 533 + onInteractOutside={(e) => { 534 + if (!modal) { 535 + if (Date.now() < ignoreOutsideUntilRef.current) { 536 + e.preventDefault(); 537 + return; 538 + } 539 + onOpenChange(false); 540 + } 541 + }} 456 542 > 457 543 <div className="flex justify-between items-center p-5"> 458 544 <div className="w-24" /> ··· 628 714 </div> 629 715 )} 630 716 </div> 631 - </div> 632 - </div> 717 + </PopoverContent> 718 + </Popover> 633 719 )} 634 720 635 721 <Dialog
+1 -1
components/app/sidebar/sidebar.tsx
··· 206 206 style={{ "--sidebar-calendar-width": "17rem" } as CSSProperties} 207 207 className={cn( 208 208 "border-r bg-background overflow-y-auto transition-all duration-300 ease-in-out", 209 - isCollapsed ? "w-0 opacity-0 overflow-hidden" : "w-[248px] opacity-100", 209 + isCollapsed ? "w-0 opacity-0 overflow-hidden" : "w-[247px] opacity-100", 210 210 )} 211 211 onTransitionEnd={(event) => { 212 212 if (
+3 -3
components/app/views/day-view.tsx
··· 30 30 interface DayViewProps { 31 31 date: Date; 32 32 events: CalendarEvent[]; 33 - onEventClick: (event: CalendarEvent) => void; 33 + onEventClick: (event: CalendarEvent, anchorEl?: HTMLElement | null) => void; 34 34 onTimeSlotClick: (startDate: Date, endDate?: Date) => void; 35 35 language: Language; 36 36 timezone: string; ··· 537 537 e.stopPropagation(); 538 538 if (ignoreNextEventClickRef.current) return; 539 539 if (!isDraggingRef.current) { 540 - onEventClick(event); 540 + onEventClick(event, e.currentTarget as HTMLElement); 541 541 } 542 542 }} 543 543 > ··· 777 777 e.stopPropagation(); 778 778 if (ignoreNextEventClickRef.current) return; 779 779 if (!isDraggingRef.current) { 780 - onEventClick(event); 780 + onEventClick(event, e.currentTarget as HTMLElement); 781 781 } 782 782 }} 783 783 >
+4 -2
components/app/views/month-view.tsx
··· 16 16 interface MonthViewProps { 17 17 date: Date; 18 18 events: CalendarEvent[]; 19 - onEventClick: (event: CalendarEvent) => void; 19 + onEventClick: (event: CalendarEvent, anchorEl?: HTMLElement | null) => void; 20 20 language: Language; 21 21 firstDayOfWeek: number; 22 22 timezone: string; ··· 124 124 "relative text-xs truncate rounded-md p-1 cursor-pointer text-white", 125 125 event.color, 126 126 )} 127 - onClick={() => onEventClick(event)} 127 + onClick={(e) => 128 + onEventClick(event, e.currentTarget as HTMLElement) 129 + } 128 130 style={{ 129 131 opacity: 1, 130 132 backgroundColor: isDark
+3 -3
components/app/views/week-view.tsx
··· 32 32 interface WeekViewProps { 33 33 date: Date; 34 34 events: any[]; 35 - onEventClick: (event: any) => void; 35 + onEventClick: (event: CalendarEvent, anchorEl?: HTMLElement | null) => void; 36 36 onTimeSlotClick: (startDate: Date, endDate?: Date) => void; 37 37 language: Language; 38 38 firstDayOfWeek: number; ··· 648 648 e.stopPropagation(); 649 649 if (ignoreNextEventClickRef.current) return; 650 650 if (!isDraggingRef.current) { 651 - onEventClick(event); 651 + onEventClick(event, e.currentTarget as HTMLElement); 652 652 } 653 653 }} 654 654 > ··· 897 897 onClick={(e) => { 898 898 e.stopPropagation(); 899 899 if (!isDraggingRef.current) { 900 - onEventClick(event); 900 + onEventClick(event, e.currentTarget as HTMLElement); 901 901 } 902 902 }} 903 903 >
+3 -4
components/app/views/year-view.tsx
··· 21 21 interface YearViewProps { 22 22 date: Date; 23 23 events: CalendarEvent[]; 24 - onEventClick: (event: CalendarEvent) => void; 24 + onEventClick: (event: CalendarEvent, anchorEl?: HTMLElement | null) => void; 25 25 language: Language; 26 26 firstDayOfWeek: number; 27 27 isSidebarCollapsed?: boolean; ··· 217 217 "relative w-full cursor-pointer truncate rounded-md p-1.5 pl-3 text-left text-xs", 218 218 event.color, 219 219 )} 220 - onClick={() => { 221 - setOpenDayKey(null); 222 - onEventClick(event); 220 + onClick={(e) => { 221 + onEventClick(event, e.currentTarget); 223 222 }} 224 223 style={{ 225 224 backgroundColor: isDark