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 #173 from EvanTechDev/dev

small change

authored by

Evan Huang and committed by
GitHub
10b43ea2 605bbd53

+1788 -440
+38 -2
app/(app)/app/page.tsx
··· 18 18 const [hasSessionCookie, setHasSessionCookie] = useState(hasClerkSessionCookie) 19 19 const [minimumWaitDone, setMinimumWaitDone] = useState(false) 20 20 const [atprotoLogoutDone, setAtprotoLogoutDone] = useState(false) 21 + const [dbReady, setDbReady] = useState(false) 21 22 22 23 useEffect(() => { 23 24 const waitTimer = window.setTimeout(() => { ··· 43 44 .finally(() => setAtprotoLogoutDone(true)) 44 45 }, [isLoaded, isSignedIn, atprotoLogoutDone]) 45 46 47 + 48 + 49 + useEffect(() => { 50 + if (!isLoaded) return 51 + 52 + if (!isSignedIn) { 53 + setDbReady(true) 54 + return 55 + } 56 + 57 + let active = true 58 + const checkDbDataReady = async () => { 59 + try { 60 + const response = await fetch("/api/blob", { cache: "no-store" }) 61 + if (!active) return 62 + if (response.status === 200 || response.status === 404) { 63 + setDbReady(true) 64 + return 65 + } 66 + setDbReady(false) 67 + } catch { 68 + if (active) { 69 + setDbReady(false) 70 + } 71 + } 72 + } 73 + 74 + void checkDbDataReady() 75 + return () => { 76 + active = false 77 + } 78 + }, [isLoaded, isSignedIn]) 79 + 46 80 const shouldShowAuthWait = useMemo(() => { 47 81 if (!minimumWaitDone) return true 48 - return hasSessionCookie && !isLoaded 49 - }, [minimumWaitDone, hasSessionCookie, isLoaded]) 82 + if (hasSessionCookie && !isLoaded) return true 83 + if (isSignedIn && !dbReady) return true 84 + return false 85 + }, [minimumWaitDone, hasSessionCookie, isLoaded, isSignedIn, dbReady]) 50 86 51 87 if (shouldShowAuthWait) { 52 88 return <AuthWaitingLoading />
+53 -5
components/app/analytics/import-export.tsx
··· 35 35 import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; 36 36 import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 37 37 import { translations, useLanguage } from "@/lib/i18n"; 38 + import { useCalendar } from "@/components/providers/calendar-context"; 38 39 import { Checkbox } from "@/components/ui/checkbox"; 39 40 import type { CalendarEvent } from "../calendar"; 40 41 import { Button } from "@/components/ui/button"; ··· 69 70 const [debugInfo, setDebugInfo] = useState<string>(""); 70 71 const [language] = useLanguage(); 71 72 const t = translations[language]; 73 + const { calendars } = useCalendar(); 74 + const [importCalendarId, setImportCalendarId] = useState<string>("__uncategorized__"); 72 75 73 76 const [forceUpdate, setForceUpdate] = useState(0); 74 77 ··· 169 172 } finally { 170 173 setIsLoading(false); 171 174 } 175 + }; 176 + 177 + 178 + const mapCalendarColorToEventColor = (calendarColor?: string) => { 179 + const mapping: Record<string, string> = { 180 + "bg-blue-500": "bg-[#E6F6FD]", 181 + "bg-green-500": "bg-[#E7F8F2]", 182 + "bg-yellow-500": "bg-[#FEF5E6]", 183 + "bg-red-500": "bg-[#FFE4E6]", 184 + "bg-purple-500": "bg-[#F3EEFE]", 185 + "bg-pink-500": "bg-[#FCE7F3]", 186 + "bg-teal-500": "bg-[#E6FAF7]", 187 + }; 188 + return mapping[calendarColor || ""] || "bg-[#E6F6FD]"; 189 + }; 190 + 191 + const applyImportCategory = (eventsToImport: CalendarEvent[]) => { 192 + const targetCategory = calendars.find((calendar) => calendar.id === importCalendarId); 193 + const categoryId = importCalendarId === "__uncategorized__" ? "" : importCalendarId; 194 + const color = mapCalendarColorToEventColor(targetCategory?.color); 195 + 196 + return eventsToImport.map((event) => ({ 197 + ...event, 198 + calendarId: categoryId, 199 + color, 200 + })); 172 201 }; 173 202 174 203 const handleImport = async () => { ··· 219 248 return; 220 249 } 221 250 222 - onImportEvents(importedEvents); 251 + const normalizedImportedEvents = applyImportCategory(importedEvents); 252 + onImportEvents(normalizedImportedEvents); 223 253 224 254 toast( 225 255 t.importSuccess.replace("{count}", importedEvents.length.toString()), ··· 408 438 recurrence: "none", 409 439 participants: [], 410 440 notification: 0, 411 - color: "bg-blue-500", 412 - calendarId: "1", 441 + color: "bg-[#E6F6FD]", 442 + calendarId: "", 413 443 }; 414 444 inEvent = true; 415 445 } else if (line.startsWith("END:VEVENT")) { ··· 651 681 color: 652 682 colorIndex >= 0 && colorIndex < values.length 653 683 ? values[colorIndex] 654 - : "bg-blue-500", 655 - calendarId: "1", 684 + : "bg-[#E6F6FD]", 685 + calendarId: "", 656 686 }); 657 687 } 658 688 } ··· 776 806 <p className="text-xs text-muted-foreground"> 777 807 {t.supportedFormats} 778 808 </p> 809 + </div> 810 + 811 + 812 + <div className="space-y-2"> 813 + <Label htmlFor="import-calendar-category">{t.importToCalendarCategory}</Label> 814 + <Select value={importCalendarId} onValueChange={setImportCalendarId}> 815 + <SelectTrigger id="import-calendar-category"> 816 + <SelectValue /> 817 + </SelectTrigger> 818 + <SelectContent> 819 + <SelectItem value="__uncategorized__">{t.uncategorized}</SelectItem> 820 + {calendars.map((calendar) => ( 821 + <SelectItem key={calendar.id} value={calendar.id}> 822 + {calendar.name} 823 + </SelectItem> 824 + ))} 825 + </SelectContent> 826 + </Select> 779 827 </div> 780 828 781 829 <Alert variant="outline">
+73 -30
components/app/calendar.tsx
··· 18 18 ChevronRight, 19 19 Search, 20 20 PanelLeft, 21 - BarChart2, 22 - Settings as SettingsIcon, 21 + CloudUpload, 22 + CheckCircle2, 23 + AlertCircle, 24 + Loader2, 23 25 } from "lucide-react"; 24 26 import dynamic from "next/dynamic"; 25 27 import { ··· 134 136 const [pendingDeleteEvent, setPendingDeleteEvent] = 135 137 useState<CalendarEvent | null>(null); 136 138 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); 139 + const [backupEnabled, setBackupEnabled] = useState(false); 140 + const [backupSyncStatus, setBackupSyncStatus] = useState< 141 + "uploading" | "failed" | "done" | null 142 + >(null); 143 + const [shareOnlyMode, setShareOnlyMode] = useState(false); 137 144 138 145 const updateEvent = (updatedEvent) => { 139 146 setEvents((prevEvents) => ··· 162 169 "time-format", 163 170 "24h", 164 171 ); 172 + const [toastPosition, setToastPosition] = useLocalStorage<"bottom-left" | "bottom-center" | "bottom-right">( 173 + "toast-position", 174 + "bottom-right", 175 + ); 165 176 166 177 useEffect(() => { 167 178 if (view !== defaultView) { ··· 170 181 }, []); 171 182 172 183 useEffect(() => { 184 + const refreshBackupState = () => { 185 + const enabled = localStorage.getItem("auto-backup-enabled") === "true"; 186 + setBackupEnabled(enabled); 187 + if (!enabled) { 188 + setBackupSyncStatus(null); 189 + return; 190 + } 191 + 192 + const status = localStorage.getItem("auto-backup-sync-status"); 193 + if (status === "uploading" || status === "failed" || status === "done") { 194 + setBackupSyncStatus(status); 195 + } else { 196 + setBackupSyncStatus("done"); 197 + } 198 + }; 199 + 200 + refreshBackupState(); 201 + window.addEventListener("backup-status-change", refreshBackupState); 202 + window.addEventListener("storage", refreshBackupState); 203 + return () => { 204 + window.removeEventListener("backup-status-change", refreshBackupState); 205 + window.removeEventListener("storage", refreshBackupState); 206 + }; 207 + }, []); 208 + 209 + const backupStatusIcon = useMemo(() => { 210 + if (!backupEnabled) return null; 211 + 212 + if (backupSyncStatus === "uploading") { 213 + return <Loader2 className="h-4 w-4 animate-spin" />; 214 + } 215 + if (backupSyncStatus === "failed") { 216 + return <AlertCircle className="h-4 w-4 text-destructive" />; 217 + } 218 + return <CheckCircle2 className="h-4 w-4 text-emerald-500" />; 219 + }, [backupEnabled, backupSyncStatus]); 220 + 221 + useEffect(() => { 173 222 const prefetch = () => { 174 223 void loadDayView(); 175 224 void loadWeekView(); ··· 346 395 }; 347 396 348 397 const handleEventClick = (event: CalendarEvent) => { 398 + setShareOnlyMode(false); 349 399 setPreviewEvent(event); 350 400 setPreviewOpen(true); 351 401 }; ··· 391 441 setEvents((prevEvents) => 392 442 prevEvents.filter((event) => event.id !== deletedEvent.id), 393 443 ); 444 + void readEncryptedLocalStorage<any[]>("bookmarked-events", []).then((bookmarks) => 445 + writeEncryptedLocalStorage( 446 + "bookmarked-events", 447 + bookmarks.filter((bookmark) => bookmark.id !== deletedEvent.id), 448 + ), 449 + ); 394 450 setEventDialogOpen(false); 395 451 setSelectedEvent(null); 396 452 setPreviewOpen(false); ··· 479 535 } 480 536 }; 481 537 482 - const handleShare = (event: CalendarEvent) => { 538 + const handleShare = (event: CalendarEvent, shareOnly = false) => { 539 + setShareOnlyMode(shareOnly); 483 540 setPreviewEvent(event); 541 + setOpenShareImmediately(true); 484 542 setPreviewOpen(true); 485 543 }; 486 544 ··· 699 757 </div> 700 758 )} 701 759 </div> 702 - <Button 703 - variant="outline" 704 - size="icon" 705 - className="rounded-full h-8 w-8" 706 - onClick={() => setView("analytics")} 707 - aria-label={t.analytics} 708 - > 709 - <BarChart2 className="h-4 w-4" /> 710 - </Button> 711 - <Button 712 - variant="outline" 713 - size="icon" 714 - className="rounded-full h-8 w-8" 715 - onClick={() => setView("settings")} 716 - aria-label={t.settings} 717 - > 718 - <SettingsIcon className="h-4 w-4" /> 719 - </Button> 760 + {backupEnabled ? ( 761 + <div className="inline-flex h-8 w-8 items-center justify-center rounded-full border" title="Backup status" aria-label="Backup status"> 762 + {backupStatusIcon ?? <CloudUpload className="h-4 w-4" />} 763 + </div> 764 + ) : null} 720 765 <UserProfileButton 721 766 variant="outline" 722 767 className="rounded-full h-8 w-8" 723 768 onNavigateToSettings={handleUserProfileSectionNavigate} 769 + onNavigateToView={setView} 724 770 /> 725 771 </div> 726 772 </header> ··· 737 783 onEditEvent={handleEventEdit} 738 784 onDeleteEvent={(event) => handleEventDelete(event.id)} 739 785 onShareEvent={(event) => { 740 - setPreviewEvent(event); 741 - setPreviewOpen(true); 742 - setOpenShareImmediately(true); 786 + handleShare(event, true); 743 787 }} 744 788 onBookmarkEvent={toggleBookmark} 745 789 onEventDrop={(event, newStartDate, newEndDate) => { ··· 766 810 onEditEvent={handleEventEdit} 767 811 onDeleteEvent={(event) => handleEventDelete(event.id)} 768 812 onShareEvent={(event) => { 769 - setPreviewEvent(event); 770 - setPreviewOpen(true); 771 - setOpenShareImmediately(true); 813 + handleShare(event, true); 772 814 }} 773 815 onBookmarkEvent={toggleBookmark} 774 816 onEventDrop={(event, newStartDate, newEndDate) => { ··· 795 837 onEditEvent={handleEventEdit} 796 838 onDeleteEvent={(event) => handleEventDelete(event.id)} 797 839 onShareEvent={(event) => { 798 - setPreviewEvent(event); 799 - setPreviewOpen(true); 800 - setOpenShareImmediately(true); 840 + handleShare(event, true); 801 841 }} 802 842 onBookmarkEvent={toggleBookmark} 803 843 onEventDrop={(event, newStartDate, newEndDate) => { ··· 863 903 events={events} 864 904 onImportEvents={handleImportEvents} 865 905 focusUserProfileSection={focusUserProfileSection} 906 + toastPosition={toastPosition} 907 + setToastPosition={setToastPosition} 866 908 /> 867 909 )} 868 910 </div> ··· 893 935 language={language} 894 936 timezone={timezone} 895 937 openShareImmediately={openShareImmediately} 938 + shareOnlyMode={shareOnlyMode} 896 939 /> 897 940 898 941 <EventDialog
+148 -129
components/app/event/event-preview.tsx
··· 48 48 language: Language; 49 49 timezone: string; 50 50 openShareImmediately?: boolean; 51 + shareOnlyMode?: boolean; 51 52 } 52 53 53 54 export default function EventPreview({ ··· 60 61 language, 61 62 timezone, 62 63 openShareImmediately, 64 + shareOnlyMode = false, 63 65 }: EventPreviewProps) { 64 66 const { calendars } = useCalendar(); 65 67 const isZh = isZhLanguage(language); ··· 151 153 } 152 154 }, [event, bookmarks]); 153 155 154 - if (!event || !open) return null; 156 + if (!event || (!open && !shareDialogOpen)) return null; 155 157 156 158 const getCalendarName = () => { 157 159 if (!event) return ""; ··· 457 459 }; 458 460 459 461 return ( 460 - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => onOpenChange(false)}> 461 - <div 462 - className="bg-background rounded-xl shadow-lg w-full max-w-md mx-4 overflow-hidden" 463 - onClick={(e) => e.stopPropagation()} 464 - > 465 - <div className="flex justify-between items-center p-5"> 466 - <div className="w-24"></div> 467 - <div className="flex space-x-2 ml-auto"> 468 - <Button variant="ghost" size="icon" onClick={() => onEdit()} className="h-8 w-8"> 469 - <Edit2 className="h-5 w-5" /> 470 - </Button> 471 - <Button 472 - variant="ghost" 473 - size="icon" 474 - onClick={(e) => { 475 - e.stopPropagation(); 476 - if (!isSignedIn && !atprotoSignedIn) { 477 - toast.error(t.shareSignInRequiredTitle, { 478 - description: t.shareSignInRequiredDescription, 479 - }); 480 - return; 481 - } 482 - handleShareDialogChange(true); 483 - }} 484 - className="h-8 w-8" 485 - > 486 - <Share2 className="h-5 w-5" /> 487 - </Button> 488 - <Button variant="ghost" size="icon" onClick={toggleBookmark} className="h-8 w-8"> 489 - <Bookmark className={cn("h-5 w-5", isBookmarked ? "fill-blue-500 text-blue-500" : "")} /> 490 - </Button> 491 - <Button variant="ghost" size="icon" onClick={handleDeleteClick} className="h-8 w-8"> 492 - <Trash2 className="h-5 w-5" /> 493 - </Button> 494 - <Button variant="ghost" size="icon" onClick={() => onOpenChange(false)} className="h-8 w-8 ml-2"> 495 - <X className="h-5 w-5" /> 496 - </Button> 497 - </div> 498 - </div> 499 - 500 - <div className="px-5 pb-5 flex"> 462 + <> 463 + {!shareOnlyMode && ( 464 + <div 465 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/10" 466 + onClick={() => onOpenChange(false)} 467 + > 501 468 <div 502 - className="w-2 self-stretch rounded-full mr-4" 503 - style={{ backgroundColor: colorMapping[event.color] }} 504 - /> 469 + className="bg-background rounded-xl shadow-lg w-full max-w-md mx-4 overflow-hidden" 470 + onClick={(e) => e.stopPropagation()} 471 + > 472 + <div className="flex justify-between items-center p-5"> 473 + <div className="w-24" /> 474 + <div className="flex space-x-2 ml-auto"> 475 + <Button variant="ghost" size="icon" onClick={() => onEdit()} className="h-8 w-8"> 476 + <Edit2 className="h-5 w-5" /> 477 + </Button> 478 + <Button 479 + variant="ghost" 480 + size="icon" 481 + onClick={(e) => { 482 + e.stopPropagation(); 483 + if (!isSignedIn && !atprotoSignedIn) { 484 + toast.error(t.shareSignInRequiredTitle, { 485 + description: t.shareSignInRequiredDescription, 486 + }); 487 + return; 488 + } 489 + handleShareDialogChange(true); 490 + }} 491 + className="h-8 w-8" 492 + > 493 + <Share2 className="h-5 w-5" /> 494 + </Button> 495 + <Button variant="ghost" size="icon" onClick={toggleBookmark} className="h-8 w-8"> 496 + <Bookmark className={cn("h-5 w-5", isBookmarked ? "fill-blue-500 text-blue-500" : "")} /> 497 + </Button> 498 + <Button variant="ghost" size="icon" onClick={handleDeleteClick} className="h-8 w-8"> 499 + <Trash2 className="h-5 w-5" /> 500 + </Button> 501 + <Button variant="ghost" size="icon" onClick={() => onOpenChange(false)} className="h-8 w-8 ml-2"> 502 + <X className="h-5 w-5" /> 503 + </Button> 504 + </div> 505 + </div> 505 506 506 - <div className="flex-1"> 507 - <h2 508 - className="mb-1 text-2xl font-bold break-words break-all overflow-hidden [overflow-wrap:anywhere]" 509 - style={{ 510 - display: "-webkit-box", 511 - WebkitLineClamp: 2, 512 - WebkitBoxOrient: "vertical", 513 - }} 514 - > 515 - {event.title} 516 - </h2> 517 - <p className="text-muted-foreground">{formatDateRange()}</p> 518 - </div> 519 - </div> 507 + <div className="px-5 pb-5 flex"> 508 + <div className="w-2 self-stretch rounded-full mr-4" style={{ backgroundColor: colorMapping[event.color] }} /> 520 509 521 - <div className="px-5 pb-5 space-y-4"> 522 - {event.location && event.location.trim() !== "" && ( 523 - <div className="flex items-start"> 524 - <MapPin className="h-5 w-5 mr-3 mt-0.5 text-muted-foreground" /> 525 510 <div className="flex-1"> 526 - <p>{event.location}</p> 511 + <h2 512 + className="mb-1 text-2xl font-bold break-words break-all overflow-hidden [overflow-wrap:anywhere]" 513 + style={{ 514 + display: "-webkit-box", 515 + WebkitLineClamp: 2, 516 + WebkitBoxOrient: "vertical", 517 + }} 518 + > 519 + {event.title} 520 + </h2> 521 + <p className="text-muted-foreground">{formatDateRange()}</p> 527 522 </div> 528 523 </div> 529 - )} 530 524 531 - {hasParticipants && ( 532 - <div className="flex items-start"> 533 - <Users className="h-5 w-5 mr-3 mt-0.5 text-muted-foreground" /> 534 - <div className="flex-1"> 535 - <div className="flex items-center justify-between cursor-pointer" onClick={toggleParticipants}> 536 - <p> 537 - {event.participants.filter((p) => p.trim() !== "").length}{" "} 538 - {isZh ? "参与者" : "participants"} 539 - </p> 540 - <ChevronDown className={cn("h-4 w-4 transition-transform duration-200", participantsOpen ? "transform rotate-180" : "")} /> 525 + <div className="px-5 pb-5 space-y-4"> 526 + {event.location && event.location.trim() !== "" && ( 527 + <div className="flex items-start"> 528 + <MapPin className="h-5 w-5 mr-3 mt-0.5 text-muted-foreground" /> 529 + <div className="flex-1"> 530 + <p>{event.location}</p> 531 + </div> 541 532 </div> 542 - {participantsOpen && ( 543 - <div className="mt-2 space-y-2"> 544 - {event.participants 545 - .filter((p) => p.trim() !== "") 546 - .map((participant, index) => ( 547 - <div key={index} className="flex items-center"> 548 - <div className="bg-gray-200 rounded-full h-8 w-8 flex items-center justify-center mr-2"> 549 - <span className="font-medium">{getInitials(participant)}</span> 550 - </div> 551 - <p>{participant}</p> 552 - </div> 553 - ))} 533 + )} 534 + 535 + {hasParticipants && ( 536 + <div className="flex items-start"> 537 + <Users className="h-5 w-5 mr-3 mt-0.5 text-muted-foreground" /> 538 + <div className="flex-1"> 539 + <div className="flex items-center justify-between cursor-pointer" onClick={toggleParticipants}> 540 + <p> 541 + {event.participants.filter((p) => p.trim() !== "").length}{" "} 542 + {isZh ? "参与者" : "participants"} 543 + </p> 544 + <ChevronDown 545 + className={cn( 546 + "h-4 w-4 transition-transform duration-200", 547 + participantsOpen ? "transform rotate-180" : "", 548 + )} 549 + /> 550 + </div> 551 + {participantsOpen && ( 552 + <div className="mt-2 space-y-2"> 553 + {event.participants 554 + .filter((p) => p.trim() !== "") 555 + .map((participant, index) => ( 556 + <div key={index} className="flex items-center"> 557 + <div className="bg-gray-200 rounded-full h-8 w-8 flex items-center justify-center mr-2"> 558 + <span className="font-medium">{getInitials(participant)}</span> 559 + </div> 560 + <p>{participant}</p> 561 + </div> 562 + ))} 563 + </div> 564 + )} 554 565 </div> 555 - )} 556 - </div> 557 - </div> 558 - )} 566 + </div> 567 + )} 559 568 560 - {getCalendarName() && ( 561 - <div className="flex items-start"> 562 - <Calendar className="h-5 w-5 mr-3 mt-0.5 text-muted-foreground" /> 563 - <div className="flex-1"> 564 - <p>{getCalendarName()}</p> 565 - </div> 566 - </div> 567 - )} 569 + {getCalendarName() && ( 570 + <div className="flex items-start"> 571 + <Calendar className="h-5 w-5 mr-3 mt-0.5 text-muted-foreground" /> 572 + <div className="flex-1"> 573 + <p>{getCalendarName()}</p> 574 + </div> 575 + </div> 576 + )} 568 577 569 - {event.notification > 0 && ( 570 - <div className="flex items-start"> 571 - <Bell className="h-5 w-5 mr-3 mt-0.5 text-muted-foreground" /> 572 - <div className="flex-1"> 573 - <p>{formatNotificationTime()}</p> 574 - <p className="text-sm text-muted-foreground"> 575 - {isZh ? `${event.notification} 分钟前 按电子邮件` : `${event.notification} minutes before by email`} 576 - </p> 577 - </div> 578 - </div> 579 - )} 578 + {event.notification > 0 && ( 579 + <div className="flex items-start"> 580 + <Bell className="h-5 w-5 mr-3 mt-0.5 text-muted-foreground" /> 581 + <div className="flex-1"> 582 + <p>{formatNotificationTime()}</p> 583 + <p className="text-sm text-muted-foreground"> 584 + {isZh 585 + ? `${event.notification} 分钟前 按电子邮件` 586 + : `${event.notification} minutes before by email`} 587 + </p> 588 + </div> 589 + </div> 590 + )} 580 591 581 - {event.description && event.description.trim() !== "" && ( 582 - <div className="flex items-start"> 583 - <AlignLeft className="h-5 w-5 mr-3 mt-0.5 text-muted-foreground" /> 584 - <div className="flex-1"> 585 - <p 586 - className="whitespace-pre-wrap break-words break-all overflow-hidden [overflow-wrap:anywhere]" 587 - style={{ 588 - display: "-webkit-box", 589 - WebkitLineClamp: 4, 590 - WebkitBoxOrient: "vertical", 591 - }} 592 - > 593 - {event.description} 594 - </p> 595 - </div> 592 + {event.description && event.description.trim() !== "" && ( 593 + <div className="flex items-start"> 594 + <AlignLeft className="h-5 w-5 mr-3 mt-0.5 text-muted-foreground" /> 595 + <div className="flex-1"> 596 + <p 597 + className="whitespace-pre-wrap break-words break-all overflow-hidden [overflow-wrap:anywhere]" 598 + style={{ 599 + display: "-webkit-box", 600 + WebkitLineClamp: 4, 601 + WebkitBoxOrient: "vertical", 602 + }} 603 + > 604 + {event.description} 605 + </p> 606 + </div> 607 + </div> 608 + )} 596 609 </div> 597 - )} 610 + </div> 598 611 </div> 599 - </div> 612 + )} 600 613 601 - <Dialog open={shareDialogOpen} onOpenChange={handleShareDialogChange}> 614 + <Dialog 615 + open={shareDialogOpen} 616 + onOpenChange={(nextOpen) => { 617 + handleShareDialogChange(nextOpen); 618 + if (!nextOpen && shareOnlyMode) onOpenChange(false); 619 + }} 620 + > 602 621 <DialogContent className="sm:max-w-md" ref={dialogContentRef} onClick={handleDialogClick}> 603 622 <DialogHeader> 604 623 <DialogTitle>{t.shareEvent}</DialogTitle> ··· 762 781 )} 763 782 </DialogContent> 764 783 </Dialog> 765 - </div> 784 + </> 766 785 ); 767 786 }
+18
components/app/profile/settings.tsx
··· 31 31 events: CalendarEvent[] 32 32 onImportEvents: (events: CalendarEvent[]) => void 33 33 focusUserProfileSection?: UserProfileSection | null 34 + toastPosition: "bottom-left" | "bottom-center" | "bottom-right" 35 + setToastPosition: (position: "bottom-left" | "bottom-center" | "bottom-right") => void 34 36 } 35 37 36 38 export default function Settings({ ··· 51 53 events, 52 54 onImportEvents, 53 55 focusUserProfileSection = null, 56 + toastPosition, 57 + setToastPosition, 54 58 }: SettingsProps) { 55 59 const { theme, setTheme } = useTheme() 56 60 const t = translations[language] ··· 216 220 <SelectContent> 217 221 <SelectItem value="24h">{t.timeFormat24h}</SelectItem> 218 222 <SelectItem value="12h">{t.timeFormat12hWithMeridiem}</SelectItem> 223 + </SelectContent> 224 + </Select> 225 + </div> 226 + 227 + <div className="space-y-2"> 228 + <Label htmlFor="toast-position">{t.toastPosition}</Label> 229 + <Select value={toastPosition} onValueChange={(value: "bottom-left" | "bottom-center" | "bottom-right") => setToastPosition(value)}> 230 + <SelectTrigger id="toast-position"> 231 + <SelectValue /> 232 + </SelectTrigger> 233 + <SelectContent> 234 + <SelectItem value="bottom-left">{t.toastPositionBottomLeft}</SelectItem> 235 + <SelectItem value="bottom-center">{t.toastPositionBottomCenter}</SelectItem> 236 + <SelectItem value="bottom-right">{t.toastPositionBottomRight}</SelectItem> 219 237 </SelectContent> 220 238 </Select> 221 239 </div>
+129 -128
components/app/profile/user-profile-button.tsx
··· 12 12 Link as LinkIcon, 13 13 RefreshCcw, 14 14 Camera, 15 + BarChart2, 16 + Settings, 17 + ShieldCheck, 18 + MessageSquare, 19 + FileText, 20 + ScrollText, 15 21 } from "lucide-react" 16 22 import { Button } from "@/components/ui/button" 17 23 import { ··· 49 55 import { useRouter } from "next/navigation" 50 56 import { decryptPayload, encryptPayload, isEncryptedPayload } from "@/lib/crypto" 51 57 import { 58 + readInMemoryStorage, 52 59 clearEncryptionPassword, 53 - encryptSnapshots, 54 60 markEncryptedSnapshot, 55 - persistEncryptedSnapshots, 56 61 readEncryptedLocalStorage, 57 62 setEncryptionPassword, 63 + writeInMemoryStorage, 58 64 } from "@/hooks/useLocalStorage" 59 65 60 66 const AUTO_KEY = "auto-backup-enabled" 67 + const BACKUP_STATUS_KEY = "auto-backup-sync-status" 61 68 const BACKUP_VERSION = 1 62 69 const BACKUP_KEYS = [ 63 70 "calendar-events", ··· 73 80 "preferred-language", 74 81 "skip-landing", 75 82 "today-toast", 83 + "toast-position", 76 84 ] 77 85 78 86 async function apiGet() { ··· 99 107 function collectLocalStorage() { 100 108 const storage: Record<string, string> = {} 101 109 BACKUP_KEYS.forEach((key) => { 102 - const value = localStorage.getItem(key) 110 + const value = readInMemoryStorage(key) ?? localStorage.getItem(key) 103 111 if (value !== null) storage[key] = value 104 112 }) 105 113 return storage 106 114 } 107 115 108 - function applyLocalStorage(storage: Record<string, string>) { 109 - Object.entries(storage).forEach(([key, value]) => { 110 - localStorage.setItem(key, value) 111 - markEncryptedSnapshot(key, value) 112 - }) 113 - } 114 - 115 - async function encryptLocalStorage(password: string) { 116 - BACKUP_KEYS.forEach((key) => { 117 - const value = localStorage.getItem(key) 118 - if (value !== null) markEncryptedSnapshot(key, value) 119 - }) 120 - await encryptSnapshots(password) 121 - await persistEncryptedSnapshots() 122 - } 123 - 124 - async function decryptLocalStorage(password: string) { 116 + async function applyCloudStorageToMemory(storage: Record<string, string>, password: string) { 125 117 await Promise.all( 126 - BACKUP_KEYS.map(async (key) => { 127 - const value = localStorage.getItem(key) 128 - if (!value) return 118 + Object.entries(storage).map(async ([key, value]) => { 119 + let normalized = value 129 120 try { 130 121 const parsed = JSON.parse(value) 131 122 if (isEncryptedPayload(parsed)) { 132 - const plain = await decryptPayload(password, parsed.ciphertext, parsed.iv) 133 - localStorage.setItem(key, plain) 134 - markEncryptedSnapshot(key, plain) 135 - } else { 136 - markEncryptedSnapshot(key, value) 123 + normalized = await decryptPayload(password, parsed.ciphertext, parsed.iv) 137 124 } 138 125 } catch { 139 - markEncryptedSnapshot(key, value) 126 + normalized = value 140 127 } 128 + writeInMemoryStorage(key, normalized) 129 + markEncryptedSnapshot(key, normalized) 141 130 }), 142 131 ) 143 132 } 144 133 145 - async function reencryptLocalStorage(oldPassword: string, newPassword: string) { 146 - await Promise.all( 147 - BACKUP_KEYS.map(async (key) => { 148 - const value = localStorage.getItem(key) 149 - if (!value) return 150 - try { 151 - const parsed = JSON.parse(value) 152 - if (isEncryptedPayload(parsed)) { 153 - const plain = await decryptPayload(oldPassword, parsed.ciphertext, parsed.iv) 154 - markEncryptedSnapshot(key, plain) 155 - } else { 156 - markEncryptedSnapshot(key, value) 157 - } 158 - } catch { 159 - markEncryptedSnapshot(key, value) 160 - } 161 - }), 162 - ) 163 - await encryptSnapshots(newPassword) 164 - await persistEncryptedSnapshots() 165 - } 166 - 167 134 export type UserProfileSection = "profile" | "backup" | "key" | "delete" | "signout" 168 135 169 136 type UserProfileButtonProps = { ··· 171 138 className?: string 172 139 mode?: "dropdown" | "settings" 173 140 onNavigateToSettings?: (section: UserProfileSection) => void 141 + onNavigateToView?: (view: "analytics" | "settings") => void 174 142 focusSection?: UserProfileSection | null 175 143 } 176 144 ··· 179 147 className = "", 180 148 mode = "dropdown", 181 149 onNavigateToSettings, 150 + onNavigateToView, 182 151 focusSection = null, 183 152 }: UserProfileButtonProps) { 184 153 const [language] = useLanguage() ··· 199 168 const [unlockOpen, setUnlockOpen] = useState(false) 200 169 const [rotateOpen, setRotateOpen] = useState(false) 201 170 const [deleteAccountOpen, setDeleteAccountOpen] = useState(false) 171 + const [deleteCloudOpen, setDeleteCloudOpen] = useState(false) 202 172 const [isDeletingAccount, setIsDeletingAccount] = useState(false) 203 173 const [isUnlocking, setIsUnlocking] = useState(false) 204 174 const [deleteAccountConfirmText, setDeleteAccountConfirmText] = useState("") 175 + const [deleteCloudConfirmText, setDeleteCloudConfirmText] = useState("") 205 176 const [profileSection, setProfileSection] = useState<"basic" | "emails" | "oauth">("basic") 206 177 207 178 const [password, setPassword] = useState("") ··· 218 189 const keyRef = useRef<string | null>(null) 219 190 const restoredRef = useRef(false) 220 191 const timerRef = useRef<any>(null) 192 + const [backupTick, setBackupTick] = useState(0) 193 + 194 + const broadcastBackupStatus = (status: "uploading" | "failed" | "done") => { 195 + localStorage.setItem(BACKUP_STATUS_KEY, status) 196 + window.dispatchEvent(new CustomEvent("backup-status-change", { detail: { status } })) 197 + } 221 198 222 199 useEffect(() => { 223 200 fetch("/api/atproto/session") ··· 260 237 if (cloud) setUnlockOpen(true) 261 238 }) 262 239 }, [isAnySignedIn, mode]) 240 + 241 + useEffect(() => { 242 + const watchKeys = new Set(BACKUP_KEYS) 243 + const handleLocalWrite = (event: Event) => { 244 + const customEvent = event as CustomEvent<{ key?: string }> 245 + if (!customEvent.detail?.key || watchKeys.has(customEvent.detail.key)) { 246 + setBackupTick((prev) => prev + 1) 247 + } 248 + } 249 + 250 + window.addEventListener("local-storage-written", handleLocalWrite) 251 + const handleLanguageChange = () => setBackupTick((prev) => prev + 1) 252 + window.addEventListener("languagechange", handleLanguageChange) 253 + return () => { 254 + window.removeEventListener("local-storage-written", handleLocalWrite) 255 + window.removeEventListener("languagechange", handleLanguageChange) 256 + } 257 + }, []) 263 258 264 259 useEffect(() => { 265 260 if (!enabled || !keyRef.current || !restoredRef.current) return 266 261 if (timerRef.current) clearTimeout(timerRef.current) 267 262 268 263 timerRef.current = setTimeout(async () => { 269 - const payload = await encryptPayload( 270 - keyRef.current!, 271 - JSON.stringify({ v: BACKUP_VERSION, storage: collectLocalStorage() }), 272 - ) 273 - await apiPost(payload) 274 - timerRef.current = null 264 + try { 265 + broadcastBackupStatus("uploading") 266 + const payload = await encryptPayload( 267 + keyRef.current!, 268 + JSON.stringify({ v: BACKUP_VERSION, storage: collectLocalStorage() }), 269 + ) 270 + await apiPost(payload) 271 + broadcastBackupStatus("done") 272 + } catch { 273 + broadcastBackupStatus("failed") 274 + } finally { 275 + timerRef.current = null 276 + } 275 277 }, 800) 276 - }, [events, calendars, enabled]) 278 + }, [events, calendars, enabled, backupTick]) 277 279 278 280 async function saveProfile() { 279 281 if (!user) return ··· 395 397 try { 396 398 const data = JSON.parse(plain) 397 399 if (data?.storage) { 398 - applyLocalStorage(data.storage) 400 + await applyCloudStorageToMemory(data.storage, password) 399 401 } else if (data?.events || data?.calendars) { 400 402 const fallbackStorage: Record<string, string> = {} 401 403 if (data?.events) fallbackStorage["calendar-events"] = JSON.stringify(data.events) 402 404 if (data?.calendars) fallbackStorage["calendar-categories"] = JSON.stringify(data.calendars) 403 - applyLocalStorage(fallbackStorage) 405 + await applyCloudStorageToMemory(fallbackStorage, password) 404 406 } 405 407 await setEncryptionPassword(password) 406 408 const restoredEvents = await readEncryptedLocalStorage("calendar-events", []) ··· 413 415 restoredRef.current = true 414 416 localStorage.setItem(AUTO_KEY, "true") 415 417 setEnabled(true) 418 + broadcastBackupStatus("done") 416 419 417 420 setPassword("") 418 421 setUnlockOpen(false) ··· 428 431 return 429 432 } 430 433 await setEncryptionPassword(password) 431 - await encryptLocalStorage(password) 432 434 const payload = await encryptPayload(password, JSON.stringify({ v: BACKUP_VERSION, storage: collectLocalStorage() })) 433 435 await apiPost(payload) 434 436 localStorage.setItem(AUTO_KEY, "true") 435 437 keyRef.current = password 436 438 restoredRef.current = true 437 439 setEnabled(true) 440 + broadcastBackupStatus("done") 438 441 setPassword("") 439 442 setConfirm("") 440 443 setSetPwdOpen(false) ··· 456 459 return 457 460 } 458 461 459 - await reencryptLocalStorage(oldPassword, password) 460 462 const next = await encryptPayload(password, JSON.stringify({ v: BACKUP_VERSION, storage: collectLocalStorage() })) 461 463 await apiPost(next) 462 464 await setEncryptionPassword(password) ··· 469 471 } 470 472 471 473 function disableAutoBackup() { 472 - const currentPassword = keyRef.current 473 474 localStorage.removeItem(AUTO_KEY) 475 + localStorage.removeItem(BACKUP_STATUS_KEY) 474 476 keyRef.current = null 475 477 restoredRef.current = false 476 478 setEnabled(false) 477 - if (currentPassword) { 478 - void decryptLocalStorage(currentPassword) 479 - } 480 479 clearEncryptionPassword() 481 480 toast(t.autoBackupDisabled) 482 481 } ··· 484 483 async function destroy() { 485 484 await apiDelete() 486 485 localStorage.removeItem(AUTO_KEY) 486 + localStorage.removeItem(BACKUP_STATUS_KEY) 487 487 keyRef.current = null 488 488 restoredRef.current = false 489 489 setEnabled(false) ··· 544 544 </DropdownMenuTrigger> 545 545 546 546 <DropdownMenuContent align="end"> 547 - {isAnySignedIn ? ( 548 - <> 549 - {isSignedIn ? ( 550 - <DropdownMenuItem onClick={() => onNavigateToSettings ? onNavigateToSettings("profile") : setProfileOpen(true)}> 551 - <CircleUser className="mr-2 h-4 w-4" /> 552 - {t.profile} 553 - </DropdownMenuItem> 554 - ) : null} 555 - 556 - <DropdownMenuItem onClick={() => onNavigateToSettings ? onNavigateToSettings("backup") : setBackupOpen(true)}> 557 - <CloudUpload className="mr-2 h-4 w-4" /> 558 - {t.autoBackup} 559 - </DropdownMenuItem> 560 - 561 - <DropdownMenuItem onClick={() => onNavigateToSettings ? onNavigateToSettings("key") : setRotateOpen(true)}> 562 - <KeyRound className="mr-2 h-4 w-4" /> 563 - {t.changeKey} 564 - </DropdownMenuItem> 565 - 566 - <DropdownMenuItem onClick={() => onNavigateToSettings ? onNavigateToSettings("delete") : destroy()}> 567 - <Trash2 className="mr-2 h-4 w-4" /> 568 - {t.deleteData} 569 - </DropdownMenuItem> 570 - 571 - {isSignedIn ? ( 572 - <DropdownMenuItem onClick={() => setDeleteAccountOpen(true)} className="text-red-600 focus:text-red-600"> 573 - <Trash2 className="mr-2 h-4 w-4" /> 574 - {t.deleteAccount} 575 - </DropdownMenuItem> 576 - ) : null} 577 - 578 - {isSignedIn ? ( 579 - onNavigateToSettings ? ( 580 - <DropdownMenuItem onClick={() => onNavigateToSettings("signout")}> 581 - <LogOut className="mr-2 h-4 w-4" /> 582 - {t.signOut} 583 - </DropdownMenuItem> 584 - ) : ( 585 - <SignOutButton> 586 - <DropdownMenuItem> 587 - <LogOut className="mr-2 h-4 w-4" /> 588 - {t.signOut} 589 - </DropdownMenuItem> 590 - </SignOutButton> 591 - ) 592 - ) : ( 593 - <DropdownMenuItem onClick={async () => { 594 - await fetch("/api/atproto/logout", { method: "POST" }) 595 - setAtprotoSignedIn(false) 596 - setAtprotoHandle("") 597 - setAtprotoDisplayName("") 598 - setAtprotoAvatar("") 599 - router.refresh() 600 - }}> 601 - <LogOut className="mr-2 h-4 w-4" /> 602 - {t.signOut} 603 - </DropdownMenuItem> 604 - )} 605 - </> 606 - ) : ( 547 + {!isAnySignedIn ? ( 607 548 <> 608 549 <DropdownMenuItem onClick={() => router.push("/sign-in")}>{t.signIn}</DropdownMenuItem> 609 550 <DropdownMenuItem onClick={() => router.push("/sign-up")}>{t.signUp}</DropdownMenuItem> 610 551 </> 611 - )} 552 + ) : null} 553 + <DropdownMenuItem onClick={() => onNavigateToView?.("settings")}> 554 + <Settings className="mr-2 h-4 w-4" /> 555 + {t.settings} 556 + </DropdownMenuItem> 557 + <DropdownMenuItem onClick={() => onNavigateToView?.("analytics")}> 558 + <BarChart2 className="mr-2 h-4 w-4" /> 559 + {t.analytics} 560 + </DropdownMenuItem> 561 + <DropdownMenuItem onClick={() => window.open("https://calendarstatus.xyehr.cn", "_blank", "noopener,noreferrer")}> 562 + <ShieldCheck className="mr-2 h-4 w-4" /> 563 + {t.status} 564 + </DropdownMenuItem> 565 + <DropdownMenuItem onClick={() => window.location.href = "mailto:evan.huang000@proton.me"}> 566 + <MessageSquare className="mr-2 h-4 w-4" /> 567 + {t.feedback} 568 + </DropdownMenuItem> 569 + <DropdownMenuItem onClick={() => router.push("/privacy")}> 570 + <FileText className="mr-2 h-4 w-4" /> 571 + {t.privacy} 572 + </DropdownMenuItem> 573 + <DropdownMenuItem onClick={() => router.push("/terms")}> 574 + <ScrollText className="mr-2 h-4 w-4" /> 575 + {t.tos} 576 + </DropdownMenuItem> 612 577 </DropdownMenuContent> 613 578 </DropdownMenu> 614 579 ) : ( ··· 689 654 <div className="space-y-3 rounded-md border border-destructive/50 p-3"> 690 655 <p className="text-sm font-semibold text-destructive">{t.deleteData}</p> 691 656 <p className="text-xs text-muted-foreground">{t.deleteAccountDataHelp}</p> 692 - <Button id="settings-account-delete" variant="destructive" onClick={destroy}><Trash2 className="h-4 w-4 mr-2" />{t.deleteData}</Button> 657 + <Button id="settings-account-delete" variant="destructive" onClick={() => setDeleteCloudOpen(true)}><Trash2 className="h-4 w-4 mr-2" />{t.deleteData}</Button> 693 658 </div> 694 659 {isSignedIn ? ( 695 660 <div className="space-y-3 rounded-md border border-destructive/50 p-3"> ··· 873 838 </AlertDialogContent> 874 839 </AlertDialog> 875 840 841 + 842 + <AlertDialog open={deleteCloudOpen} onOpenChange={setDeleteCloudOpen}> 843 + <AlertDialogContent> 844 + <AlertDialogHeader> 845 + <AlertDialogTitle>{t.deleteCloudConfirmTitle}</AlertDialogTitle> 846 + <AlertDialogDescription>{t.deleteCloudConfirmDescription}</AlertDialogDescription> 847 + </AlertDialogHeader> 848 + <div className="space-y-2"> 849 + <Label htmlFor="delete-cloud-confirm-input">DELETE CLOUD DATA</Label> 850 + <Input 851 + id="delete-cloud-confirm-input" 852 + value={deleteCloudConfirmText} 853 + onChange={(e) => setDeleteCloudConfirmText(e.target.value)} 854 + placeholder="DELETE CLOUD DATA" 855 + autoComplete="off" 856 + /> 857 + </div> 858 + <AlertDialogFooter> 859 + <AlertDialogCancel>{t.cancel}</AlertDialogCancel> 860 + <AlertDialogAction 861 + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 862 + onClick={(e) => { 863 + e.preventDefault() 864 + if (deleteCloudConfirmText !== "DELETE CLOUD DATA") return 865 + void destroy().finally(() => { 866 + setDeleteCloudOpen(false) 867 + setDeleteCloudConfirmText("") 868 + }) 869 + }} 870 + disabled={deleteCloudConfirmText !== "DELETE CLOUD DATA"} 871 + > 872 + {t.confirmDeleteData} 873 + </AlertDialogAction> 874 + </AlertDialogFooter> 875 + </AlertDialogContent> 876 + </AlertDialog> 876 877 <Dialog open={backupOpen} onOpenChange={setBackupOpen}> 877 878 <DialogContent> 878 879 <DialogHeader>
+12 -8
components/app/sidebar/bookmark-panel.tsx
··· 17 17 import { zhCN, enUS } from "date-fns/locale"; 18 18 import { toast } from "sonner"; 19 19 import { cn } from "@/lib/utils"; 20 + import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; 20 21 import { isZhLanguage, translations, useLanguage } from "@/lib/i18n"; 21 22 import { 22 23 getEncryptionState, ··· 150 151 151 152 <ScrollArea className="h-[calc(100vh-180px)] pr-4"> 152 153 {filteredBookmarks.length === 0 ? ( 153 - <div className="flex flex-col items-center justify-center h-32 text-center text-muted-foreground"> 154 - <Bookmark className="h-10 w-10 mb-2 opacity-20" /> 155 - {searchTerm ? ( 156 - <p>{t.noMatchingBookmarks}</p> 157 - ) : ( 158 - <p>{t.noBookmarks}</p> 159 - )} 160 - </div> 154 + <Empty className="h-32 border-0 p-0"> 155 + <EmptyHeader> 156 + <EmptyMedia variant="icon"> 157 + <Bookmark className="h-4 w-4" /> 158 + </EmptyMedia> 159 + <EmptyTitle>{t.bookmarks}</EmptyTitle> 160 + <EmptyDescription> 161 + {searchTerm ? t.noMatchingBookmarks : t.noBookmarks} 162 + </EmptyDescription> 163 + </EmptyHeader> 164 + </Empty> 161 165 ) : ( 162 166 <div className="space-y-3"> 163 167 {filteredBookmarks.map((bookmark) => (
+128 -32
components/app/sidebar/countdown.tsx
··· 2 2 3 3 import { useLocalStorage } from "@/hooks/useLocalStorage"; 4 4 import { Button } from "@/components/ui/button"; 5 - import { useState } from "react"; 5 + import { useMemo, useState } from "react"; 6 6 import { 7 7 Sheet, 8 8 SheetContent, ··· 35 35 Clock, 36 36 Search, 37 37 } from "lucide-react"; 38 + import { icons as lucideIcons } from "lucide-react"; 38 39 import { Avatar, AvatarFallback } from "@/components/ui/avatar"; 39 40 import { cn } from "@/lib/utils"; 40 41 import { format } from "date-fns"; 41 42 import { zhCN, enUS } from "date-fns/locale"; 42 43 import { isZhLanguage, translations, useLanguage } from "@/lib/i18n"; 44 + import { toast } from "sonner"; 43 45 import { ClockDashed } from "@/components/icons/clock-dashed"; 46 + import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; 44 47 45 48 interface Countdown { 46 49 id: string; ··· 49 52 repeat: "none" | "weekly" | "monthly" | "yearly"; 50 53 description?: string; 51 54 color: string; 55 + icon?: string; 52 56 } 53 57 54 58 interface CountdownToolProps { ··· 73 77 const [year, month, day] = dateStr.split("-").map(Number); 74 78 return new Date(year, month - 1, day); 75 79 }; 80 + 81 + 82 + const TEXT_COLOR_MAP: Record<string, string> = { 83 + "bg-red-500": "#ef4444", 84 + "bg-blue-500": "#3b82f6", 85 + "bg-green-500": "#22c55e", 86 + "bg-yellow-500": "#eab308", 87 + "bg-purple-500": "#a855f7", 88 + "bg-pink-500": "#ec4899", 89 + "bg-indigo-500": "#6366f1", 90 + "bg-orange-500": "#f97316", 91 + }; 92 + 93 + const allIconNames = Object.keys(lucideIcons).sort((a, b) => a.localeCompare(b)); 76 94 77 95 const toDateString = (date: Date) => { 78 96 const year = date.getFullYear(); ··· 91 109 ); 92 110 const [newCountdown, setNewCountdown] = useState<Partial<Countdown>>({ 93 111 color: "bg-blue-500", 112 + icon: "Clock", 94 113 }); 95 114 const [view, setView] = useState<"list" | "detail" | "edit">("list"); 96 115 const [language] = useLanguage(); ··· 101 120 new Date(), 102 121 ); 103 122 const [calendarOpen, setCalendarOpen] = useState(false); 123 + const [iconSearch, setIconSearch] = useState(""); 124 + 125 + const filteredIcons = useMemo(() => { 126 + const keyword = iconSearch.trim().toLowerCase(); 127 + const target = keyword 128 + ? allIconNames.filter((name) => name.toLowerCase().includes(keyword)) 129 + : allIconNames; 130 + return target.slice(0, 200); 131 + }, [iconSearch]); 132 + 133 + const renderCountdownIcon = (iconName: string | undefined, colorClass: string, size = 20, withBackground = false) => { 134 + const iconColor = TEXT_COLOR_MAP[colorClass] ?? "#3b82f6"; 135 + const IconComponent = lucideIcons[(iconName || "Clock") as keyof typeof lucideIcons] ?? lucideIcons.Clock; 136 + if (withBackground) { 137 + return ( 138 + <div className="h-10 w-10 rounded-full bg-muted/70 dark:bg-muted/40 flex items-center justify-center"> 139 + <IconComponent size={size} style={{ color: iconColor }} /> 140 + </div> 141 + ); 142 + } 143 + 144 + return <IconComponent size={size} style={{ color: iconColor }} />; 145 + }; 104 146 105 147 const formatDate = (dateStr: string) => { 106 148 const date = parseDateString(dateStr); ··· 127 169 today.setHours(0, 0, 0, 0); 128 170 129 171 const targetDate = parseDateString(dateStr); 130 - let nextDate = new Date( 131 - today.getFullYear(), 132 - targetDate.getMonth(), 133 - targetDate.getDate(), 134 - ); 172 + 173 + const daysInMonth = (year: number, monthIndex: number) => 174 + new Date(year, monthIndex + 1, 0).getDate(); 175 + 176 + const buildClampedDate = (year: number, monthIndex: number, day: number) => { 177 + const clampedDay = Math.min(day, daysInMonth(year, monthIndex)); 178 + return new Date(year, monthIndex, clampedDay); 179 + }; 180 + 181 + let nextDate = new Date(targetDate); 135 182 136 183 if (repeat === "weekly") { 137 184 const targetDay = targetDate.getDay(); ··· 140 187 nextDate = new Date(today); 141 188 nextDate.setDate(today.getDate() + daysToAdd); 142 189 } else if (repeat === "monthly") { 143 - nextDate = new Date( 144 - today.getFullYear(), 145 - today.getMonth(), 146 - targetDate.getDate(), 147 - ); 148 - if (nextDate < today) nextDate.setMonth(nextDate.getMonth() + 1); 190 + nextDate = buildClampedDate(today.getFullYear(), today.getMonth(), targetDate.getDate()); 191 + if (nextDate < today) { 192 + const nextMonth = today.getMonth() + 1; 193 + const year = today.getFullYear() + Math.floor(nextMonth / 12); 194 + const month = nextMonth % 12; 195 + nextDate = buildClampedDate(year, month, targetDate.getDate()); 196 + } 149 197 } else if (repeat === "yearly") { 150 - nextDate = new Date( 151 - today.getFullYear(), 152 - targetDate.getMonth(), 153 - targetDate.getDate(), 154 - ); 155 - if (nextDate < today) nextDate.setFullYear(today.getFullYear() + 1); 198 + nextDate = buildClampedDate(today.getFullYear(), targetDate.getMonth(), targetDate.getDate()); 199 + if (nextDate < today) { 200 + nextDate = buildClampedDate(today.getFullYear() + 1, targetDate.getMonth(), targetDate.getDate()); 201 + } 202 + } 203 + 204 + if (repeat === "none") { 205 + const diffTime = targetDate.getTime() - today.getTime(); 206 + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); 156 207 } 157 208 158 209 const diffTime = nextDate.getTime() - today.getTime(); ··· 180 231 repeat: "none", 181 232 description: "", 182 233 color: "bg-blue-500", 234 + icon: "Clock", 183 235 }); 184 236 setSelectedDate(today); 185 237 setSelectedCountdown(null); ··· 212 264 repeat: newCountdown.repeat || "none", 213 265 description: newCountdown.description || "", 214 266 color: newCountdown.color, 267 + icon: newCountdown.icon || "Clock", 215 268 }; 216 269 217 270 if (selectedCountdown) { 218 271 setCountdowns((prev) => 219 272 prev.map((c) => (c.id === countdown.id ? countdown : c)), 220 273 ); 274 + toast(t.countdownUpdated, { description: countdown.name }); 221 275 } else { 222 276 setCountdowns((prev) => [...prev, countdown]); 277 + toast(t.countdownAdded, { description: countdown.name }); 223 278 } 224 279 225 280 setView("list"); 226 281 setSelectedCountdown(null); 227 - setNewCountdown({ color: "bg-blue-500" }); 282 + setNewCountdown({ color: "bg-blue-500", icon: "Clock" }); 228 283 setSelectedDate(new Date()); 229 284 }; 230 285 231 286 const deleteCountdown = (id: string) => { 287 + const target = countdowns.find((c) => c.id === id); 232 288 setCountdowns((prev) => prev.filter((c) => c.id !== id)); 233 289 setView("list"); 234 290 setSelectedCountdown(null); 291 + toast(t.countdownDeleted, { description: target?.name || "" }); 235 292 }; 236 293 237 294 const renderCountdownListView = () => ( ··· 265 322 </Button> 266 323 <ScrollArea className="h-[calc(100vh-200px)]"> 267 324 {countdowns.length === 0 ? ( 268 - <div className="text-center py-8 text-muted-foreground"> 269 - {t.countdownNoEvents} 270 - </div> 325 + <Empty className="border-0 py-8"> 326 + <EmptyHeader> 327 + <EmptyMedia variant="icon"> 328 + <ClockDashed className="h-4 w-4" /> 329 + </EmptyMedia> 330 + <EmptyTitle>{t.countdownTitle}</EmptyTitle> 331 + <EmptyDescription>{t.countdownNoEvents}</EmptyDescription> 332 + </EmptyHeader> 333 + </Empty> 271 334 ) : ( 272 335 <div className="space-y-2"> 273 336 {countdowns ··· 294 357 onClick={() => viewCountdownDetail(countdown)} 295 358 > 296 359 <Avatar className="h-12 w-12 mr-3"> 297 - <AvatarFallback className={countdown.color}> 298 - <span className="text-white font-semibold"> 299 - {countdown.name.charAt(0).toUpperCase()} 300 - </span> 360 + <AvatarFallback className="bg-transparent"> 361 + {renderCountdownIcon(countdown.icon, countdown.color, 20, true)} 301 362 </AvatarFallback> 302 363 </Avatar> 303 364 <div className="flex-1"> ··· 314 375 {Math.abs(daysLeft)} 315 376 </div> 316 377 <div className="text-xs text-muted-foreground"> 317 - {t.countdownDaysLeft} 378 + {daysLeft < 0 ? t.countdownDaysAgo : t.countdownDaysLeft} 318 379 </div> 319 380 </div> 320 381 </div> ··· 353 414 <div className="p-4"> 354 415 <div className="flex items-center mb-6"> 355 416 <Avatar className="h-16 w-16 mr-4"> 356 - <AvatarFallback className={selectedCountdown.color}> 357 - <span className="text-white text-xl font-bold"> 358 - {selectedCountdown.name.charAt(0).toUpperCase()} 359 - </span> 417 + <AvatarFallback className="bg-transparent"> 418 + {renderCountdownIcon(selectedCountdown.icon, selectedCountdown.color, 26)} 360 419 </AvatarFallback> 361 420 </Avatar> 362 421 <div> ··· 364 423 <div 365 424 className={`text-2xl font-bold mt-1 ${daysLeft < 0 ? "text-red-500" : "text-primary"}`} 366 425 > 367 - {Math.abs(daysLeft)} {t.countdownDaysLeft} 426 + {Math.abs(daysLeft)} {daysLeft < 0 ? t.countdownDaysAgo : t.countdownDaysLeft} 368 427 </div> 369 428 </div> 370 429 </div> ··· 485 544 ))} 486 545 </SelectContent> 487 546 </Select> 547 + </div> 548 + 549 + 550 + <div className="space-y-2"> 551 + <Label>{t.countdownIcon}</Label> 552 + <Popover> 553 + <PopoverTrigger asChild> 554 + <Button variant="outline" className="w-full justify-start gap-2"> 555 + {renderCountdownIcon(newCountdown.icon, newCountdown.color || "bg-blue-500")} 556 + <span>{newCountdown.icon || "Clock"}</span> 557 + </Button> 558 + </PopoverTrigger> 559 + <PopoverContent className="w-[320px] p-3" align="start"> 560 + <Input 561 + placeholder={t.countdownSearchIcon} 562 + value={iconSearch} 563 + onChange={(e) => setIconSearch(e.target.value)} 564 + className="mb-2" 565 + /> 566 + <ScrollArea className="h-52"> 567 + <div className="grid grid-cols-8 gap-1"> 568 + {filteredIcons.map((iconName) => ( 569 + <div 570 + key={iconName} 571 + className={cn( 572 + "h-11 w-11 flex items-center justify-center rounded-md cursor-pointer hover:bg-accent", 573 + newCountdown.icon === iconName && "ring-2 ring-primary bg-accent/60", 574 + )} 575 + onClick={() => setNewCountdown({ ...newCountdown, icon: iconName })} 576 + > 577 + {renderCountdownIcon(iconName, newCountdown.color || "bg-blue-500", 18)} 578 + </div> 579 + ))} 580 + </div> 581 + </ScrollArea> 582 + </PopoverContent> 583 + </Popover> 488 584 </div> 489 585 490 586 <div className="space-y-2">
+10 -3
components/app/sidebar/mini-calendar-sheet.tsx
··· 22 22 import { Button } from "@/components/ui/button"; 23 23 import { useState, useEffect } from "react"; 24 24 import { cn } from "@/lib/utils"; 25 + import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; 25 26 26 27 const getWeekStartsOn = (locale: string): 0 | 1 => { 27 28 try { ··· 218 219 219 220 <ScrollArea className="h-[calc(100vh-300px)]"> 220 221 {dayEvents.length === 0 ? ( 221 - <div className="text-center py-8 text-muted-foreground"> 222 - {t.noEventsToday} 223 - </div> 222 + <Empty className="border-0 py-8"> 223 + <EmptyHeader> 224 + <EmptyMedia variant="icon"> 225 + <CalendarDays className="h-4 w-4" /> 226 + </EmptyMedia> 227 + <EmptyTitle>{t.today}</EmptyTitle> 228 + <EmptyDescription>{t.noEventsToday}</EmptyDescription> 229 + </EmptyHeader> 230 + </Empty> 224 231 ) : ( 225 232 <div className="space-y-4"> 226 233 {dayEvents.map((event) => (
+129 -28
components/app/sidebar/sidebar.tsx
··· 1 1 "use client" 2 2 3 3 import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog" 4 + import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 4 5 import { useCalendar } from "@/components/providers/calendar-context" 5 6 import { translations, type Language } from "@/lib/i18n" 6 7 import { Calendar } from "@/components/ui/calendar" ··· 8 9 import { Button } from "@/components/ui/button" 9 10 import { Input } from "@/components/ui/input" 10 11 import { Label } from "@/components/ui/label" 11 - import { Plus, X } from "lucide-react" 12 + import { Plus, X, Edit2 } from "lucide-react" 12 13 import { useState } from "react" 13 14 import { cn } from "@/lib/utils" 14 15 import { toast } from "sonner" ··· 35 36 } 36 37 37 38 const CALENDAR_COLOR_OPTIONS = [ 38 - { value: "bg-blue-500", hex: "#3b82f6" }, 39 - { value: "bg-green-500", hex: "#10b981" }, 40 - { value: "bg-yellow-500", hex: "#f59e0b" }, 41 - { value: "bg-red-500", hex: "#ef4444" }, 42 - { value: "bg-purple-500", hex: "#8b5cf6" }, 43 - { value: "bg-pink-500", hex: "#ec4899" }, 44 - { value: "bg-teal-500", hex: "#14b8a6" }, 45 - ] 39 + { value: "bg-blue-500", hex: "#3b82f6", labelKey: "colorBlue" }, 40 + { value: "bg-green-500", hex: "#10b981", labelKey: "colorGreen" }, 41 + { value: "bg-yellow-500", hex: "#f59e0b", labelKey: "colorYellow" }, 42 + { value: "bg-red-500", hex: "#ef4444", labelKey: "colorRed" }, 43 + { value: "bg-purple-500", hex: "#8b5cf6", labelKey: "colorPurple" }, 44 + { value: "bg-pink-500", hex: "#ec4899", labelKey: "colorPink" }, 45 + { value: "bg-teal-500", hex: "#14b8a6", labelKey: "colorTeal" }, 46 + ] as const 46 47 47 48 const CALENDAR_COLOR_MAP = Object.fromEntries(CALENDAR_COLOR_OPTIONS.map((option) => [option.value, option.hex])) 48 49 ··· 59 60 onCollapseTransitionEnd, 60 61 }: SidebarProps) { 61 62 62 - const { calendars, addCategory: addCategoryToContext, removeCategory: removeCategoryFromContext } = useCalendar() 63 + const { 64 + calendars, 65 + events, 66 + setEvents, 67 + addCategory: addCategoryToContext, 68 + removeCategory: removeCategoryFromContext, 69 + updateCategory: updateCategoryInContext, 70 + } = useCalendar() 63 71 64 72 const [newCategoryName, setNewCategoryName] = useState("") 65 73 const [newCategoryColor, setNewCategoryColor] = useState("bg-blue-500") ··· 68 76 const [manageCategoriesOpen, setManageCategoriesOpen] = useState(false) 69 77 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) 70 78 const [categoryToDelete, setCategoryToDelete] = useState<string | null>(null) 79 + const [deleteCategoryEvents, setDeleteCategoryEvents] = useState(false) 80 + const [editDialogOpen, setEditDialogOpen] = useState(false) 81 + const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null) 82 + const [editingCategoryName, setEditingCategoryName] = useState("") 83 + const [editingCategoryColor, setEditingCategoryColor] = useState("bg-blue-500") 71 84 const t = translations[language || "zh-CN"] 72 85 const weekdayNames = t.sidebarCalendarWeekdaysShort 73 86 const monthNames = t.sidebarCalendarMonthsLong ··· 87 100 toastSuccess: t.categoryDeleted, 88 101 toastDescription: t.categoryDeletedDescription, 89 102 } 103 + const deleteCategoryEventsLabel = t.deleteCategoryEvents || "同时删除此分类下的所有日程" 90 104 91 105 if (selectedDate && (!localSelectedDate || selectedDate.getTime() !== localSelectedDate.getTime())) { 92 106 setLocalSelectedDate(selectedDate) ··· 113 127 114 128 const handleDeleteClick = (id: string) => { 115 129 setCategoryToDelete(id) 130 + setDeleteCategoryEvents(false) 116 131 setDeleteDialogOpen(true) 117 132 } 118 133 134 + const handleEditClick = (id: string) => { 135 + const category = calendars.find((calendar) => calendar.id === id) 136 + if (!category) return 137 + setEditingCategoryId(id) 138 + setEditingCategoryName(category.name) 139 + setEditingCategoryColor(category.color) 140 + setEditDialogOpen(true) 141 + } 142 + 143 + const saveCategoryEdit = () => { 144 + if (!editingCategoryId || !editingCategoryName.trim()) return 145 + updateCategoryInContext(editingCategoryId, { 146 + name: editingCategoryName.trim(), 147 + color: editingCategoryColor, 148 + }) 149 + setEditDialogOpen(false) 150 + setEditingCategoryId(null) 151 + toast(t.categoryUpdated || "分类已更新") 152 + } 153 + 119 154 const confirmDelete = () => { 120 155 if (categoryToDelete) { 156 + if (deleteCategoryEvents) { 157 + setEvents(events.filter((event) => event.calendarId !== categoryToDelete)) 158 + } 121 159 removeCategoryFromContext(categoryToDelete) 122 160 toast(deleteText.toastSuccess, { 123 - description: deleteText.toastDescription, 161 + description: deleteCategoryEvents ? t.categoryDeletedWithEvents : deleteText.toastDescription, 124 162 }) 125 163 } 126 164 setDeleteDialogOpen(false) 127 165 setCategoryToDelete(null) 166 + setDeleteCategoryEvents(false) 128 167 } 129 168 130 169 return ( ··· 191 230 /> 192 231 <span className="text-sm">{calendar.name}</span> 193 232 </div> 194 - <Button variant="ghost" size="sm" onClick={() => handleDeleteClick(calendar.id)}> 195 - <X className="h-4 w-4" /> 196 - </Button> 233 + <div className="flex items-center"> 234 + <Button variant="ghost" size="sm" onClick={() => handleEditClick(calendar.id)}> 235 + <Edit2 className="h-4 w-4" /> 236 + </Button> 237 + <Button variant="ghost" size="sm" onClick={() => handleDeleteClick(calendar.id)}> 238 + <X className="h-4 w-4" /> 239 + </Button> 240 + </div> 197 241 </div> 198 242 ))} 199 243 {calendars.length > 0 && ( ··· 238 282 <DialogTitle>{deleteText.title}</DialogTitle> 239 283 <DialogDescription> 240 284 {deleteText.description} 285 + <div className="mt-3 flex items-center space-x-2"> 286 + <Checkbox 287 + id="delete-category-events" 288 + checked={deleteCategoryEvents} 289 + onCheckedChange={(checked) => setDeleteCategoryEvents(checked === true)} 290 + /> 291 + <Label htmlFor="delete-category-events">{deleteCategoryEventsLabel}</Label> 292 + </div> 241 293 </DialogDescription> 242 294 </DialogHeader> 243 295 <DialogFooter> ··· 267 319 /> 268 320 </div> 269 321 <div className="space-y-2"> 270 - <Label>{t.color}</Label> 271 - <div className="flex flex-wrap gap-2"> 272 - {CALENDAR_COLOR_OPTIONS.map((option) => ( 273 - <div 274 - key={option.value} 275 - className={cn( 276 - option.value, 277 - "w-6 h-6 rounded-full cursor-pointer", 278 - newCategoryColor === option.value ? "ring-2 ring-offset-2 ring-black" : "", 279 - )} 280 - onClick={() => setNewCategoryColor(option.value)} 281 - /> 282 - ))} 283 - </div> 322 + <Label htmlFor="category-color">{t.color}</Label> 323 + <Select value={newCategoryColor} onValueChange={setNewCategoryColor}> 324 + <SelectTrigger id="category-color"> 325 + <SelectValue placeholder={t.selectColor} /> 326 + </SelectTrigger> 327 + <SelectContent> 328 + {CALENDAR_COLOR_OPTIONS.map((option) => ( 329 + <SelectItem key={option.value} value={option.value}> 330 + <div className="flex items-center"> 331 + <div 332 + className="w-4 h-4 rounded-full mr-2" 333 + style={{ backgroundColor: CALENDAR_COLOR_MAP[option.value] }} 334 + /> 335 + {t[option.labelKey]} 336 + </div> 337 + </SelectItem> 338 + ))} 339 + </SelectContent> 340 + </Select> 284 341 </div> 285 342 </div> 286 343 <DialogFooter className="justify-end"> ··· 289 346 <Plus className="mr-2 h-4 w-4" /> 290 347 {t.addCategory} 291 348 </Button> 349 + </DialogFooter> 350 + </DialogContent> 351 + </Dialog> 352 + 353 + <Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}> 354 + <DialogContent className="max-w-md"> 355 + <DialogHeader> 356 + <DialogTitle>{t.editCategory}</DialogTitle> 357 + </DialogHeader> 358 + <div className="space-y-4"> 359 + <div className="space-y-2"> 360 + <Label htmlFor="edit-category-name">{t.categoryName}</Label> 361 + <Input 362 + id="edit-category-name" 363 + value={editingCategoryName} 364 + onChange={(e) => setEditingCategoryName(e.target.value)} 365 + placeholder={t.categoryName} 366 + /> 367 + </div> 368 + <div className="space-y-2"> 369 + <Label htmlFor="edit-category-color">{t.color}</Label> 370 + <Select value={editingCategoryColor} onValueChange={setEditingCategoryColor}> 371 + <SelectTrigger id="edit-category-color"> 372 + <SelectValue placeholder={t.selectColor} /> 373 + </SelectTrigger> 374 + <SelectContent> 375 + {CALENDAR_COLOR_OPTIONS.map((option) => ( 376 + <SelectItem key={option.value} value={option.value}> 377 + <div className="flex items-center"> 378 + <div 379 + className="w-4 h-4 rounded-full mr-2" 380 + style={{ backgroundColor: CALENDAR_COLOR_MAP[option.value] }} 381 + /> 382 + {t[option.labelKey]} 383 + </div> 384 + </SelectItem> 385 + ))} 386 + </SelectContent> 387 + </Select> 388 + </div> 389 + </div> 390 + <DialogFooter> 391 + <Button variant="outline" onClick={() => setEditDialogOpen(false)}>{t.cancel}</Button> 392 + <Button onClick={saveCategoryEdit} disabled={!editingCategoryName.trim()}>{t.save}</Button> 292 393 </DialogFooter> 293 394 </DialogContent> 294 395 </Dialog>
+31 -14
components/app/views/day-view.tsx
··· 2 2 3 3 import { useEffect, useRef, useState } from "react"; 4 4 import type React from "react"; 5 - import { 6 - ContextMenu, 7 - ContextMenuContent, 8 - ContextMenuItem, 9 - ContextMenuTrigger, 10 - } from "@/components/ui/context-menu"; 11 5 import { Edit3, Share2, Bookmark, Trash2 } from "lucide-react"; 12 6 import { 13 7 format, ··· 20 14 import { cn } from "@/lib/utils"; 21 15 import type { CalendarEvent } from "../calendar"; 22 16 import { translations, type Language } from "@/lib/i18n"; 17 + 18 + const ContextMenu = ({ children }: { children: React.ReactNode }) => <>{children}</>; 19 + const ContextMenuTrigger = ({ children }: { children: React.ReactNode; asChild?: boolean }) => <>{children}</>; 20 + const ContextMenuContent = () => null; 21 + const ContextMenuItem = (_props: any) => null; 23 22 24 23 interface DayViewProps { 25 24 date: Date; ··· 76 75 } | null>(null); 77 76 const [dragEventDuration, setDragEventDuration] = useState<number>(0); 78 77 const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null); 78 + const ignoreNextEventClickRef = useRef(false); 79 79 const isDraggingRef = useRef(false); 80 + 81 + const queueIgnoreEventClick = () => { 82 + ignoreNextEventClickRef.current = true; 83 + window.setTimeout(() => { 84 + ignoreNextEventClickRef.current = false; 85 + }, 0); 86 + }; 87 + 80 88 81 89 const [createSelection, setCreateSelection] = useState<{ 82 90 startMinute: number; ··· 511 519 onMouseDown={(e) => handleEventDragStart(event, e)} 512 520 onMouseUp={handleEventDragEnd} 513 521 onMouseLeave={handleEventDragEnd} 522 + onContextMenu={(e) => { 523 + e.preventDefault(); 524 + e.stopPropagation(); 525 + queueIgnoreEventClick(); 526 + }} 514 527 onClick={(e) => { 515 528 e.stopPropagation(); 529 + if (ignoreNextEventClickRef.current) return; 516 530 if (!isDraggingRef.current) { 517 531 onEventClick(event); 518 532 } 533 + 519 534 }} 520 535 > 521 536 <div ··· 532 547 </ContextMenuTrigger> 533 548 534 549 <ContextMenuContent className="w-40"> 535 - <ContextMenuItem onClick={() => onEditEvent?.(event)}> 550 + <ContextMenuItem onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onEditEvent?.(event); }}> 536 551 <Edit3 className="mr-2 h-4 w-4" /> 537 552 {menuLabels.edit} 538 553 </ContextMenuItem> 539 - <ContextMenuItem onClick={() => onShareEvent?.(event)}> 554 + <ContextMenuItem onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onShareEvent?.(event); }}> 540 555 <Share2 className="mr-2 h-4 w-4" /> 541 556 {menuLabels.share} 542 557 </ContextMenuItem> 543 - <ContextMenuItem onClick={() => onBookmarkEvent?.(event)}> 558 + <ContextMenuItem onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onBookmarkEvent?.(event); }}> 544 559 <Bookmark className="mr-2 h-4 w-4" /> 545 560 {menuLabels.bookmark} 546 561 </ContextMenuItem> 547 562 <ContextMenuItem 548 - onClick={() => onDeleteEvent?.(event)} 563 + onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onDeleteEvent?.(event); }} 549 564 className="text-red-600" 550 565 > 551 566 <Trash2 className="mr-2 h-4 w-4" /> ··· 723 738 onMouseLeave={handleEventDragEnd} 724 739 onClick={(e) => { 725 740 e.stopPropagation(); 741 + if (ignoreNextEventClickRef.current) return; 726 742 if (!isDraggingRef.current) { 727 743 onEventClick(event); 728 744 } 745 + 729 746 }} 730 747 > 731 748 <div ··· 767 784 </ContextMenuTrigger> 768 785 769 786 <ContextMenuContent className="w-40"> 770 - <ContextMenuItem onClick={() => onEditEvent?.(event)}> 787 + <ContextMenuItem onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onEditEvent?.(event); }}> 771 788 <Edit3 className="mr-2 h-4 w-4" /> 772 789 {menuLabels.edit} 773 790 </ContextMenuItem> 774 - <ContextMenuItem onClick={() => onShareEvent?.(event)}> 791 + <ContextMenuItem onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onShareEvent?.(event); }}> 775 792 <Share2 className="mr-2 h-4 w-4" /> 776 793 {menuLabels.share} 777 794 </ContextMenuItem> 778 - <ContextMenuItem onClick={() => onBookmarkEvent?.(event)}> 795 + <ContextMenuItem onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onBookmarkEvent?.(event); }}> 779 796 <Bookmark className="mr-2 h-4 w-4" /> 780 797 {menuLabels.bookmark} 781 798 </ContextMenuItem> 782 799 <ContextMenuItem 783 - onClick={() => onDeleteEvent?.(event)} 800 + onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onDeleteEvent?.(event); }} 784 801 className="text-red-600" 785 802 > 786 803 <Trash2 className="mr-2 h-4 w-4" />
+30 -15
components/app/views/week-view.tsx
··· 2 2 3 3 import { useEffect, useRef, useState } from "react"; 4 4 import type React from "react"; 5 - import { 6 - ContextMenu, 7 - ContextMenuContent, 8 - ContextMenuItem, 9 - ContextMenuTrigger, 10 - } from "@/components/ui/context-menu"; 11 5 import { Edit3, Share2, Bookmark, Trash2 } from "lucide-react"; 12 6 import { 13 7 format, ··· 22 16 } from "date-fns"; 23 17 import { cn } from "@/lib/utils"; 24 18 import { translations, type Language } from "@/lib/i18n"; 19 + 20 + const ContextMenu = ({ children }: { children: React.ReactNode }) => <>{children}</>; 21 + const ContextMenuTrigger = ({ children }: { children: React.ReactNode; asChild?: boolean }) => <>{children}</>; 22 + const ContextMenuContent = () => null; 23 + const ContextMenuItem = (_props: any) => null; 25 24 26 25 interface WeekViewProps { 27 26 date: Date; ··· 104 103 } | null>(null); 105 104 const [dragEventDuration, setDragEventDuration] = useState<number>(0); 106 105 const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null); 106 + const ignoreNextEventClickRef = useRef(false); 107 107 const isDraggingRef = useRef(false); 108 + 109 + const queueIgnoreEventClick = () => { 110 + ignoreNextEventClickRef.current = true; 111 + window.setTimeout(() => { 112 + ignoreNextEventClickRef.current = false; 113 + }, 0); 114 + }; 115 + 108 116 109 117 const [createSelection, setCreateSelection] = useState<{ 110 118 dayIndex: number; ··· 619 627 onMouseDown={(e) => handleEventDragStart(event, e)} 620 628 onMouseUp={handleEventDragEnd} 621 629 onMouseLeave={handleEventDragEnd} 630 + onContextMenu={(e) => { 631 + e.preventDefault(); 632 + e.stopPropagation(); 633 + queueIgnoreEventClick(); 634 + }} 622 635 onClick={(e) => { 623 636 e.stopPropagation(); 637 + if (ignoreNextEventClickRef.current) return; 624 638 if (!isDraggingRef.current) { 625 639 onEventClick(event); 626 640 } 641 + 627 642 }} 628 643 > 629 644 <div ··· 640 655 </ContextMenuTrigger> 641 656 642 657 <ContextMenuContent className="w-40"> 643 - <ContextMenuItem onClick={() => onEditEvent?.(event)}> 658 + <ContextMenuItem onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onEditEvent?.(event); }}> 644 659 <Edit3 className="mr-2 h-4 w-4" /> 645 660 {menuLabels.edit} 646 661 </ContextMenuItem> 647 - <ContextMenuItem onClick={() => onShareEvent?.(event)}> 662 + <ContextMenuItem onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onShareEvent?.(event); }}> 648 663 <Share2 className="mr-2 h-4 w-4" /> 649 664 {menuLabels.share} 650 665 </ContextMenuItem> 651 - <ContextMenuItem onClick={() => onBookmarkEvent?.(event)}> 666 + <ContextMenuItem onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onBookmarkEvent?.(event); }}> 652 667 <Bookmark className="mr-2 h-4 w-4" /> 653 668 {menuLabels.bookmark} 654 669 </ContextMenuItem> 655 670 <ContextMenuItem 656 - onClick={() => onDeleteEvent?.(event)} 671 + onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onDeleteEvent?.(event); }} 657 672 className="text-red-600" 658 673 > 659 674 <Trash2 className="mr-2 h-4 w-4" /> ··· 717 732 return ( 718 733 <div className="flex flex-col h-full"> 719 734 <div 720 - className="grid divide-x relative z-30 bg-background" 735 + className="grid divide-x relative z-30 bg-background border-b" 721 736 style={{ gridTemplateColumns }} 722 737 > 723 738 <div className="sticky top-0 z-30 bg-background" /> ··· 893 908 </ContextMenuTrigger> 894 909 895 910 <ContextMenuContent className="w-40"> 896 - <ContextMenuItem onClick={() => onEditEvent?.(event)}> 911 + <ContextMenuItem onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onEditEvent?.(event); }}> 897 912 <Edit3 className="mr-2 h-4 w-4" /> 898 913 {menuLabels.edit} 899 914 </ContextMenuItem> 900 - <ContextMenuItem onClick={() => onShareEvent?.(event)}> 915 + <ContextMenuItem onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onShareEvent?.(event); }}> 901 916 <Share2 className="mr-2 h-4 w-4" /> 902 917 {menuLabels.share} 903 918 </ContextMenuItem> 904 919 <ContextMenuItem 905 - onClick={() => onBookmarkEvent?.(event)} 920 + onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onBookmarkEvent?.(event); }} 906 921 > 907 922 <Bookmark className="mr-2 h-4 w-4" /> 908 923 {menuLabels.bookmark} 909 924 </ContextMenuItem> 910 925 <ContextMenuItem 911 - onClick={() => onDeleteEvent?.(event)} 926 + onSelect={(e) => { e.preventDefault(); e.stopPropagation(); queueIgnoreEventClick(); onDeleteEvent?.(event); }} 912 927 className="text-red-600" 913 928 > 914 929 <Trash2 className="mr-2 h-4 w-4" />
+1 -1
components/app/views/year-view.tsx
··· 166 166 "relative w-full cursor-pointer truncate rounded-md p-1.5 pl-3 text-left text-xs", 167 167 event.color, 168 168 )} 169 - onClick={() => onEventClick(event)} 169 + onClick={() => { setOpenDayKey(null); onEventClick(event); }} 170 170 style={{ 171 171 backgroundColor: isDark ? getDarkModeEventBackgroundColor(event.color) : undefined, 172 172 }}
+2 -2
components/auth/login-form.tsx
··· 80 80 81 81 if (result.status === "complete") { 82 82 await setActive({ session: result.createdSessionId }); 83 - router.push("/"); 83 + router.push("/app"); 84 84 } 85 85 } catch (err: any) { 86 86 setError(err.errors?.[0]?.longMessage || "Login failed. Please try again."); ··· 104 104 signIn.authenticateWithRedirect({ 105 105 strategy, 106 106 redirectUrl: "/sign-in/sso-callback", 107 - redirectUrlComplete: "/", 107 + redirectUrlComplete: "/app", 108 108 }); 109 109 }; 110 110
+2 -2
components/auth/sign-up-form.tsx
··· 100 100 signUp.authenticateWithRedirect({ 101 101 strategy, 102 102 redirectUrl: "/sign-up/sso-callback", 103 - redirectUrlComplete: "/", 103 + redirectUrlComplete: "/app", 104 104 }); 105 105 }; 106 106 ··· 136 136 }); 137 137 if (completeSignUp.status === "complete") { 138 138 await setActive({ session: completeSignUp.createdSessionId }); 139 - router.push("/"); 139 + router.push("/app"); 140 140 } 141 141 } 142 142 } catch (err: any) {
+3 -3
components/providers/calendar-context.tsx
··· 1 1 "use client"; 2 2 3 3 import { useLocalStorage } from "@/hooks/useLocalStorage"; 4 - import { createContext, useContext } from "react"; 4 + import { createContext, useContext, type Dispatch, type SetStateAction } from "react"; 5 5 import type React from "react"; 6 6 7 7 export interface CalendarCategory { ··· 28 28 29 29 interface CalendarContextType { 30 30 calendars: CalendarCategory[]; 31 - setCalendars: (calendars: CalendarCategory[]) => void; 31 + setCalendars: Dispatch<SetStateAction<CalendarCategory[]>>; 32 32 events: CalendarEvent[]; 33 - setEvents: (events: CalendarEvent[]) => void; 33 + setEvents: Dispatch<SetStateAction<CalendarEvent[]>>; 34 34 addCategory: (category: CalendarCategory) => void; 35 35 removeCategory: (id: string) => void; 36 36 updateCategory: (id: string, category: Partial<CalendarCategory>) => void;
+105
components/ui/empty.tsx
··· 1 + import type React from "react" 2 + import { cva, type VariantProps } from "class-variance-authority" 3 + 4 + import { cn } from "@/lib/utils" 5 + 6 + function Empty({ className, ...props }: React.ComponentProps<"div">) { 7 + return ( 8 + <div 9 + data-slot="empty" 10 + className={cn( 11 + "gap-4 rounded-xl border-dashed p-6 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance", 12 + className 13 + )} 14 + {...props} 15 + /> 16 + ) 17 + } 18 + 19 + function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { 20 + return ( 21 + <div 22 + data-slot="empty-header" 23 + className={cn( 24 + "gap-2 flex max-w-sm flex-col items-center", 25 + className 26 + )} 27 + {...props} 28 + /> 29 + ) 30 + } 31 + 32 + const emptyMediaVariants = cva( 33 + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", 34 + { 35 + variants: { 36 + variant: { 37 + default: "bg-transparent", 38 + icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4", 39 + }, 40 + }, 41 + defaultVariants: { 42 + variant: "default", 43 + }, 44 + } 45 + ) 46 + 47 + function EmptyMedia({ 48 + className, 49 + variant = "default", 50 + ...props 51 + }: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) { 52 + return ( 53 + <div 54 + data-slot="empty-icon" 55 + data-variant={variant} 56 + className={cn(emptyMediaVariants({ variant, className }))} 57 + {...props} 58 + /> 59 + ) 60 + } 61 + 62 + function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { 63 + return ( 64 + <div 65 + data-slot="empty-title" 66 + className={cn("text-sm font-medium tracking-tight", className)} 67 + {...props} 68 + /> 69 + ) 70 + } 71 + 72 + function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { 73 + return ( 74 + <div 75 + data-slot="empty-description" 76 + className={cn( 77 + "text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", 78 + className 79 + )} 80 + {...props} 81 + /> 82 + ) 83 + } 84 + 85 + function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { 86 + return ( 87 + <div 88 + data-slot="empty-content" 89 + className={cn( 90 + "gap-2.5 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance", 91 + className 92 + )} 93 + {...props} 94 + /> 95 + ) 96 + } 97 + 98 + export { 99 + Empty, 100 + EmptyHeader, 101 + EmptyTitle, 102 + EmptyDescription, 103 + EmptyContent, 104 + EmptyMedia, 105 + }
+3
components/ui/sonner.tsx
··· 3 3 import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" 4 4 import { Toaster as Sonner, type ToasterProps } from "sonner" 5 5 import { useTheme } from "next-themes" 6 + import { useLocalStorage } from "@/hooks/useLocalStorage" 6 7 7 8 const Toaster = ({ ...props }: ToasterProps) => { 8 9 const { theme = "system" } = useTheme() 10 + const [toastPosition] = useLocalStorage<"bottom-left" | "bottom-center" | "bottom-right">("toast-position", "bottom-right") 9 11 10 12 return ( 11 13 <Sonner ··· 36 38 "--border-radius": "var(--radius)", 37 39 } as React.CSSProperties 38 40 } 41 + position={toastPosition} 39 42 toastOptions={{ 40 43 classNames: { 41 44 toast: "cn-toast",
+65
hooks/useLocalStorage.ts
··· 21 21 } 22 22 23 23 const encryptedSnapshots = new Map<string, EncryptedSnapshot>() 24 + const inMemoryStorage = new Map<string, string>() 24 25 const subscribers = new Set<() => void>() 26 + 27 + const SENSITIVE_KEYS = new Set([ 28 + "calendar-events", 29 + "calendar-categories", 30 + "bookmarked-events", 31 + "shared-events", 32 + "countdowns", 33 + ]) 25 34 26 35 function tryParse(value: string) { 27 36 try { ··· 44 53 subscribers.forEach((callback) => callback()) 45 54 } 46 55 56 + function emitStorageWrite(key: string) { 57 + if (typeof window === "undefined") return 58 + window.dispatchEvent(new CustomEvent("local-storage-written", { detail: { key } })) 59 + } 60 + 61 + export function isSensitiveStorageKey(key: string) { 62 + return SENSITIVE_KEYS.has(key) 63 + } 64 + 47 65 export function getEncryptionState() { 48 66 return { ...ENCRYPTION_STATE } 49 67 } ··· 71 89 ENCRYPTION_STATE.enabled = false 72 90 ENCRYPTION_STATE.ready = false 73 91 encryptedSnapshots.clear() 92 + inMemoryStorage.clear() 74 93 notifySubscribers() 75 94 } 76 95 ··· 87 106 encryptedSnapshots.clear() 88 107 } 89 108 109 + export function writeInMemoryStorage(key: string, value: string) { 110 + inMemoryStorage.set(key, value) 111 + } 112 + 113 + export function readInMemoryStorage(key: string) { 114 + return inMemoryStorage.get(key) ?? null 115 + } 116 + 117 + export function clearInMemoryStorage() { 118 + inMemoryStorage.clear() 119 + } 120 + 90 121 export async function decryptSnapshots(password: string) { 91 122 const entries = Array.from(encryptedSnapshots.entries()) 92 123 if (entries.length === 0) return ··· 133 164 export async function persistEncryptedSnapshots() { 134 165 encryptedSnapshots.forEach((snapshot, key) => { 135 166 if (!snapshot.value) return 167 + if (isSensitiveStorageKey(key)) return 136 168 window.localStorage.setItem(key, snapshot.value) 137 169 }) 138 170 } ··· 142 174 return initialValue 143 175 } 144 176 try { 177 + const inMemoryValue = inMemoryStorage.get(key) 178 + if (inMemoryValue !== undefined) { 179 + return coerceStoredValue(inMemoryValue, initialValue) 180 + } 145 181 const snapshot = encryptedSnapshots.get(key) 146 182 if (snapshot?.value) { 147 183 const parsedSnapshot = tryParse(snapshot.value) ··· 168 204 parsed.parsed.iv, 169 205 ) 170 206 encryptedSnapshots.set(key, { value: plain, failed: false }) 207 + if (isSensitiveStorageKey(key)) { 208 + inMemoryStorage.set(key, plain) 209 + window.localStorage.removeItem(key) 210 + } 171 211 return coerceStoredValue<T>(plain, initialValue) 172 212 } 173 213 return coerceStoredValue(item, initialValue) ··· 182 222 try { 183 223 const raw = JSON.stringify(value) 184 224 if (ENCRYPTION_STATE.enabled && ENCRYPTION_STATE.password) { 225 + if (isSensitiveStorageKey(key)) { 226 + inMemoryStorage.set(key, raw) 227 + encryptedSnapshots.set(key, { value: raw, failed: false }) 228 + window.localStorage.removeItem(key) 229 + emitStorageWrite(key) 230 + return 231 + } 185 232 const encrypted = await encryptPayload(ENCRYPTION_STATE.password, raw) 186 233 const payload = JSON.stringify(encrypted) 187 234 window.localStorage.setItem(key, payload) 188 235 encryptedSnapshots.set(key, { value: raw, failed: false }) 236 + emitStorageWrite(key) 189 237 return 190 238 } 191 239 window.localStorage.setItem(key, raw) 192 240 encryptedSnapshots.set(key, { value: raw, failed: false }) 241 + emitStorageWrite(key) 193 242 } catch (error) { 194 243 console.log(error) 195 244 } ··· 200 249 return initialValue 201 250 } 202 251 try { 252 + const inMemoryValue = inMemoryStorage.get(key) 253 + if (inMemoryValue !== undefined) { 254 + return coerceStoredValue(inMemoryValue, initialValue) 255 + } 203 256 const item = window.localStorage.getItem(key) 204 257 if (!item) return initialValue 205 258 const snapshot = encryptedSnapshots.get(key) ··· 221 274 const parsed = tryParse(item) 222 275 if (parsed.ok && isEncryptedPayload(parsed.parsed)) { 223 276 encryptedSnapshots.set(key, { value: item, failed: false }) 277 + if (isSensitiveStorageKey(key)) { 278 + window.localStorage.removeItem(key) 279 + } 224 280 return initialValue 225 281 } 226 282 return coerceStoredValue(item, initialValue) ··· 235 291 try { 236 292 const raw = JSON.stringify(value) 237 293 if (ENCRYPTION_STATE.enabled && ENCRYPTION_STATE.password) { 294 + if (isSensitiveStorageKey(key)) { 295 + inMemoryStorage.set(key, raw) 296 + encryptedSnapshots.set(key, { value: raw, failed: false }) 297 + window.localStorage.removeItem(key) 298 + emitStorageWrite(key) 299 + return 300 + } 238 301 const encrypted = await encryptPayload(ENCRYPTION_STATE.password, raw) 239 302 const payload = JSON.stringify(encrypted) 240 303 window.localStorage.setItem(key, payload) 241 304 encryptedSnapshots.set(key, { value: raw, failed: false }) 305 + emitStorageWrite(key) 242 306 return 243 307 } 244 308 window.localStorage.setItem(key, raw) 245 309 encryptedSnapshots.set(key, { value: raw, failed: false }) 310 + emitStorageWrite(key) 246 311 } catch (error) { 247 312 console.log(error) 248 313 }
+23 -1
locales/bn.json
··· 478 478 "নভেম্বর", 479 479 "ডিসেম্বর" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "এই কাজটি আপনার এনক্রিপ্টেড ক্লাউড ব্যাকআপ স্থায়ীভাবে মুছে দেবে। চালিয়ে যেতে DELETE CLOUD DATA লিখুন।", 486 + "confirmDeleteData": "ডেটা মুছে ফেলা নিশ্চিত করুন", 487 + "countdownIcon": "আইকন", 488 + "countdownSearchIcon": "আইকন খুঁজুন", 489 + "countdownAdded": "কাউন্টডাউন যোগ করা হয়েছে", 490 + "countdownUpdated": "কাউন্টডাউন আপডেট করা হয়েছে", 491 + "countdownDeleted": "কাউন্টডাউন মুছে ফেলা হয়েছে", 492 + "toastPosition": "নোটিফিকেশনের অবস্থান", 493 + "toastPositionBottomLeft": "নিচে বামে", 494 + "toastPositionBottomCenter": "নিচে মাঝখানে", 495 + "toastPositionBottomRight": "নিচে ডানে", 496 + "countdownDaysAgo": "দিন আগে", 497 + "deleteCategoryEvents": "এই বিভাগের সব ইভেন্টও মুছে দিন", 498 + "categoryDeletedWithEvents": "বিভাগ এবং এর সব ইভেন্ট মুছে ফেলা হয়েছে", 499 + "importToCalendarCategory": "ক্যালেন্ডার বিভাগে ইমপোর্ট করুন", 500 + "status": "স্ট্যাটাস", 501 + "feedback": "প্রতিক্রিয়া", 502 + "privacy": "গোপনীয়তা", 503 + "tos": "সেবার শর্তাবলী" 482 504 }
+23 -1
locales/de.json
··· 478 478 "November", 479 479 "Dezember" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Diese Aktion löscht Ihr verschlüsseltes Cloud-Backup dauerhaft. Geben Sie zum Fortfahren DELETE CLOUD DATA ein.", 486 + "confirmDeleteData": "Datenlöschung bestätigen", 487 + "countdownIcon": "Symbol", 488 + "countdownSearchIcon": "Symbole suchen", 489 + "countdownAdded": "Countdown hinzugefügt", 490 + "countdownUpdated": "Countdown aktualisiert", 491 + "countdownDeleted": "Countdown gelöscht", 492 + "toastPosition": "Toast-Position", 493 + "toastPositionBottomLeft": "Unten links", 494 + "toastPositionBottomCenter": "Unten mittig", 495 + "toastPositionBottomRight": "Unten rechts", 496 + "countdownDaysAgo": "Tage zuvor", 497 + "deleteCategoryEvents": "Alle Ereignisse in dieser Kategorie ebenfalls löschen", 498 + "categoryDeletedWithEvents": "Kategorie und alle zugehörigen Ereignisse wurden gelöscht", 499 + "importToCalendarCategory": "In Kalenderkategorie importieren", 500 + "status": "Status", 501 + "feedback": "Feedback", 502 + "privacy": "Datenschutz", 503 + "tos": "Nutzungsbedingungen" 482 504 }
+23 -1
locales/el.json
··· 478 478 "Νοεμβρίου", 479 479 "Δεκεμβρίου" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Αυτή η ενέργεια διαγράφει οριστικά το κρυπτογραφημένο αντίγραφο ασφαλείας cloud. Πληκτρολογήστε DELETE CLOUD DATA για συνέχεια.", 486 + "confirmDeleteData": "Επιβεβαίωση διαγραφής δεδομένων", 487 + "countdownIcon": "Εικονίδιο", 488 + "countdownSearchIcon": "Αναζήτηση εικονιδίων", 489 + "countdownAdded": "Η αντίστροφη μέτρηση προστέθηκε", 490 + "countdownUpdated": "Η αντίστροφη μέτρηση ενημερώθηκε", 491 + "countdownDeleted": "Η αντίστροφη μέτρηση διαγράφηκε", 492 + "toastPosition": "Θέση ειδοποίησης", 493 + "toastPositionBottomLeft": "Κάτω αριστερά", 494 + "toastPositionBottomCenter": "Κάτω κέντρο", 495 + "toastPositionBottomRight": "Κάτω δεξιά", 496 + "countdownDaysAgo": "ημέρες πριν", 497 + "deleteCategoryEvents": "Διαγραφή και όλων των συμβάντων αυτής της κατηγορίας", 498 + "categoryDeletedWithEvents": "Η κατηγορία και όλα τα συμβάντα της διαγράφηκαν", 499 + "importToCalendarCategory": "Εισαγωγή σε κατηγορία ημερολογίου", 500 + "status": "Κατάσταση", 501 + "feedback": "Σχόλια", 502 + "privacy": "Απόρρητο", 503 + "tos": "Όροι χρήσης" 482 504 }
+23 -1
locales/en-GB.json
··· 478 478 "November", 479 479 "December" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "This action permanently deletes your encrypted cloud backup. Type DELETE CLOUD DATA to continue.", 486 + "confirmDeleteData": "Confirm delete data", 487 + "countdownIcon": "Icon", 488 + "countdownSearchIcon": "Search icons", 489 + "countdownAdded": "Countdown added", 490 + "countdownUpdated": "Countdown updated", 491 + "countdownDeleted": "Countdown deleted", 492 + "toastPosition": "Toast position", 493 + "toastPositionBottomLeft": "Bottom left", 494 + "toastPositionBottomCenter": "Bottom center", 495 + "toastPositionBottomRight": "Bottom right", 496 + "countdownDaysAgo": "days ago", 497 + "deleteCategoryEvents": "Delete all events under this category as well", 498 + "categoryDeletedWithEvents": "Category and all its events were deleted", 499 + "importToCalendarCategory": "Import into calendar category", 500 + "status": "Status", 501 + "feedback": "Feedback", 502 + "privacy": "Privacy", 503 + "tos": "ToS" 482 504 }
+23 -1
locales/en.json
··· 478 478 "November", 479 479 "December" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "This action permanently deletes your encrypted cloud backup. Type DELETE CLOUD DATA to continue.", 486 + "confirmDeleteData": "Confirm delete data", 487 + "countdownIcon": "Icon", 488 + "countdownSearchIcon": "Search icons", 489 + "countdownAdded": "Countdown added", 490 + "countdownUpdated": "Countdown updated", 491 + "countdownDeleted": "Countdown deleted", 492 + "toastPosition": "Toast position", 493 + "toastPositionBottomLeft": "Bottom left", 494 + "toastPositionBottomCenter": "Bottom center", 495 + "toastPositionBottomRight": "Bottom right", 496 + "countdownDaysAgo": "days ago", 497 + "deleteCategoryEvents": "Delete all events under this category as well", 498 + "categoryDeletedWithEvents": "Category and all its events were deleted", 499 + "importToCalendarCategory": "Import into calendar category", 500 + "status": "Status", 501 + "feedback": "Feedback", 502 + "privacy": "Privacy", 503 + "tos": "ToS" 482 504 }
+23 -1
locales/es.json
··· 478 478 "noviembre", 479 479 "diciembre" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} de {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} de {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Esta acción elimina permanentemente tu copia de seguridad cifrada en la nube. Escribe DELETE CLOUD DATA para continuar.", 486 + "confirmDeleteData": "Confirmar eliminación de datos", 487 + "countdownIcon": "Icono", 488 + "countdownSearchIcon": "Buscar iconos", 489 + "countdownAdded": "Cuenta regresiva añadida", 490 + "countdownUpdated": "Cuenta regresiva actualizada", 491 + "countdownDeleted": "Cuenta regresiva eliminada", 492 + "toastPosition": "Posición de notificación", 493 + "toastPositionBottomLeft": "Abajo a la izquierda", 494 + "toastPositionBottomCenter": "Abajo al centro", 495 + "toastPositionBottomRight": "Abajo a la derecha", 496 + "countdownDaysAgo": "días atrás", 497 + "deleteCategoryEvents": "Eliminar también todos los eventos de esta categoría", 498 + "categoryDeletedWithEvents": "La categoría y todos sus eventos fueron eliminados", 499 + "importToCalendarCategory": "Importar a categoría del calendario", 500 + "status": "Estado", 501 + "feedback": "Comentarios", 502 + "privacy": "Privacidad", 503 + "tos": "Términos de servicio" 482 504 }
+23 -1
locales/fi.json
··· 478 478 "marraskuu", 479 479 "joulukuu" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Tämä toiminto poistaa salatun pilvivarmuuskopiosi pysyvästi. Kirjoita DELETE CLOUD DATA jatkaaksesi.", 486 + "confirmDeleteData": "Vahvista tietojen poisto", 487 + "countdownIcon": "Kuvake", 488 + "countdownSearchIcon": "Hae kuvakkeita", 489 + "countdownAdded": "Laskuri lisätty", 490 + "countdownUpdated": "Laskuri päivitetty", 491 + "countdownDeleted": "Laskuri poistettu", 492 + "toastPosition": "Ilmoituksen sijainti", 493 + "toastPositionBottomLeft": "Alhaalla vasemmalla", 494 + "toastPositionBottomCenter": "Alhaalla keskellä", 495 + "toastPositionBottomRight": "Alhaalla oikealla", 496 + "countdownDaysAgo": "päivää sitten", 497 + "deleteCategoryEvents": "Poista myös kaikki tämän kategorian tapahtumat", 498 + "categoryDeletedWithEvents": "Kategoria ja kaikki sen tapahtumat poistettiin", 499 + "importToCalendarCategory": "Tuo kalenterikategoriaan", 500 + "status": "Tila", 501 + "feedback": "Palaute", 502 + "privacy": "Tietosuoja", 503 + "tos": "Käyttöehdot" 482 504 }
+23 -1
locales/fr.json
··· 478 478 "novembre", 479 479 "décembre" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Cette action supprime définitivement votre sauvegarde cloud chiffrée. Saisissez DELETE CLOUD DATA pour continuer.", 486 + "confirmDeleteData": "Confirmer la suppression des données", 487 + "countdownIcon": "Icône", 488 + "countdownSearchIcon": "Rechercher des icônes", 489 + "countdownAdded": "Compte à rebours ajouté", 490 + "countdownUpdated": "Compte à rebours mis à jour", 491 + "countdownDeleted": "Compte à rebours supprimé", 492 + "toastPosition": "Position de la notification", 493 + "toastPositionBottomLeft": "En bas à gauche", 494 + "toastPositionBottomCenter": "En bas au centre", 495 + "toastPositionBottomRight": "En bas à droite", 496 + "countdownDaysAgo": "jours auparavant", 497 + "deleteCategoryEvents": "Supprimer également tous les événements de cette catégorie", 498 + "categoryDeletedWithEvents": "La catégorie et tous ses événements ont été supprimés", 499 + "importToCalendarCategory": "Importer dans la catégorie du calendrier", 500 + "status": "Statut", 501 + "feedback": "Retour", 502 + "privacy": "Confidentialité", 503 + "tos": "Conditions d'utilisation" 482 504 }
+23 -1
locales/hi.json
··· 478 478 "नवंबर", 479 479 "दिसंबर" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "यह कार्रवाई आपके एन्क्रिप्टेड क्लाउड बैकअप को स्थायी रूप से हटाती है। जारी रखने के लिए DELETE CLOUD DATA टाइप करें।", 486 + "confirmDeleteData": "डेटा हटाने की पुष्टि करें", 487 + "countdownIcon": "आइकन", 488 + "countdownSearchIcon": "आइकन खोजें", 489 + "countdownAdded": "काउंटडाउन जोड़ा गया", 490 + "countdownUpdated": "काउंटडाउन अपडेट किया गया", 491 + "countdownDeleted": "काउंटडाउन हटाया गया", 492 + "toastPosition": "सूचना स्थिति", 493 + "toastPositionBottomLeft": "नीचे बायाँ", 494 + "toastPositionBottomCenter": "नीचे मध्य", 495 + "toastPositionBottomRight": "नीचे दायाँ", 496 + "countdownDaysAgo": "दिन पहले", 497 + "deleteCategoryEvents": "इस श्रेणी के सभी इवेंट भी हटाएँ", 498 + "categoryDeletedWithEvents": "श्रेणी और उसके सभी इवेंट हटा दिए गए", 499 + "importToCalendarCategory": "कैलेंडर श्रेणी में आयात करें", 500 + "status": "स्थिति", 501 + "feedback": "प्रतिक्रिया", 502 + "privacy": "गोपनीयता", 503 + "tos": "सेवा की शर्तें" 482 504 }
+23 -1
locales/is.json
··· 478 478 "nóvember", 479 479 "desember" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Þessi aðgerð eyðir dulkóðuðu skýjaafriti þínu varanlega. Sláðu inn DELETE CLOUD DATA til að halda áfram.", 486 + "confirmDeleteData": "Staðfesta eyðingu gagna", 487 + "countdownIcon": "Tákn", 488 + "countdownSearchIcon": "Leita að táknum", 489 + "countdownAdded": "Niðurtalning bætt við", 490 + "countdownUpdated": "Niðurtalning uppfærð", 491 + "countdownDeleted": "Niðurtalning eytt", 492 + "toastPosition": "Staðsetning tilkynningar", 493 + "toastPositionBottomLeft": "Neðst til vinstri", 494 + "toastPositionBottomCenter": "Neðst í miðju", 495 + "toastPositionBottomRight": "Neðst til hægri", 496 + "countdownDaysAgo": "dögum síðan", 497 + "deleteCategoryEvents": "Eyða einnig öllum viðburðum í þessum flokki", 498 + "categoryDeletedWithEvents": "Flokki og öllum viðburðum hans var eytt", 499 + "importToCalendarCategory": "Flytja inn í dagatalflokk", 500 + "status": "Staða", 501 + "feedback": "Endurgjöf", 502 + "privacy": "Persónuvernd", 503 + "tos": "Notkunarskilmálar" 482 504 }
+23 -1
locales/it.json
··· 478 478 "novembre", 479 479 "dicembre" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Questa azione elimina definitivamente il tuo backup cloud crittografato. Digita DELETE CLOUD DATA per continuare.", 486 + "confirmDeleteData": "Conferma eliminazione dati", 487 + "countdownIcon": "Icona", 488 + "countdownSearchIcon": "Cerca icone", 489 + "countdownAdded": "Conto alla rovescia aggiunto", 490 + "countdownUpdated": "Conto alla rovescia aggiornato", 491 + "countdownDeleted": "Conto alla rovescia eliminato", 492 + "toastPosition": "Posizione notifica", 493 + "toastPositionBottomLeft": "In basso a sinistra", 494 + "toastPositionBottomCenter": "In basso al centro", 495 + "toastPositionBottomRight": "In basso a destra", 496 + "countdownDaysAgo": "giorni fa", 497 + "deleteCategoryEvents": "Elimina anche tutti gli eventi in questa categoria", 498 + "categoryDeletedWithEvents": "La categoria e tutti i suoi eventi sono stati eliminati", 499 + "importToCalendarCategory": "Importa nella categoria del calendario", 500 + "status": "Stato", 501 + "feedback": "Feedback", 502 + "privacy": "Privacy", 503 + "tos": "Termini di servizio" 482 504 }
+23 -1
locales/ja.json
··· 478 478 "11月", 479 479 "12月" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{year}}年{{month}}月" 481 + "sidebarCalendarMonthYearFormat": "{{year}}年{{month}}月", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "この操作により暗号化されたクラウドバックアップが完全に削除されます。続行するには DELETE CLOUD DATA と入力してください。", 486 + "confirmDeleteData": "データ削除を確認", 487 + "countdownIcon": "アイコン", 488 + "countdownSearchIcon": "アイコンを検索", 489 + "countdownAdded": "カウントダウンを追加しました", 490 + "countdownUpdated": "カウントダウンを更新しました", 491 + "countdownDeleted": "カウントダウンを削除しました", 492 + "toastPosition": "通知位置", 493 + "toastPositionBottomLeft": "左下", 494 + "toastPositionBottomCenter": "下中央", 495 + "toastPositionBottomRight": "右下", 496 + "countdownDaysAgo": "日前", 497 + "deleteCategoryEvents": "このカテゴリ内のすべての予定も削除する", 498 + "categoryDeletedWithEvents": "カテゴリとそのすべての予定を削除しました", 499 + "importToCalendarCategory": "カレンダーカテゴリにインポート", 500 + "status": "ステータス", 501 + "feedback": "フィードバック", 502 + "privacy": "プライバシー", 503 + "tos": "利用規約" 482 504 }
+23 -1
locales/ko.json
··· 478 478 "11월", 479 479 "12월" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{year}}년 {{month}}" 481 + "sidebarCalendarMonthYearFormat": "{{year}}년 {{month}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "이 작업은 암호화된 클라우드 백업을 영구적으로 삭제합니다. 계속하려면 DELETE CLOUD DATA를 입력하세요.", 486 + "confirmDeleteData": "데이터 삭제 확인", 487 + "countdownIcon": "아이콘", 488 + "countdownSearchIcon": "아이콘 검색", 489 + "countdownAdded": "카운트다운이 추가되었습니다", 490 + "countdownUpdated": "카운트다운이 업데이트되었습니다", 491 + "countdownDeleted": "카운트다운이 삭제되었습니다", 492 + "toastPosition": "알림 위치", 493 + "toastPositionBottomLeft": "왼쪽 아래", 494 + "toastPositionBottomCenter": "하단 중앙", 495 + "toastPositionBottomRight": "오른쪽 아래", 496 + "countdownDaysAgo": "일 전", 497 + "deleteCategoryEvents": "이 카테고리의 모든 일정도 삭제", 498 + "categoryDeletedWithEvents": "카테고리와 모든 일정이 삭제되었습니다", 499 + "importToCalendarCategory": "캘린더 카테고리로 가져오기", 500 + "status": "상태", 501 + "feedback": "피드백", 502 + "privacy": "개인정보", 503 + "tos": "이용약관" 482 504 }
+23 -1
locales/lt.json
··· 478 478 "lapkritis", 479 479 "gruodis" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{year}} m. {{month}}" 481 + "sidebarCalendarMonthYearFormat": "{{year}} m. {{month}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Šis veiksmas visam laikui ištrins jūsų užšifruotą debesijos atsarginę kopiją. Norėdami tęsti, įveskite DELETE CLOUD DATA.", 486 + "confirmDeleteData": "Patvirtinti duomenų ištrynimą", 487 + "countdownIcon": "Piktograma", 488 + "countdownSearchIcon": "Ieškoti piktogramų", 489 + "countdownAdded": "Atgalinis skaičiavimas pridėtas", 490 + "countdownUpdated": "Atgalinis skaičiavimas atnaujintas", 491 + "countdownDeleted": "Atgalinis skaičiavimas ištrintas", 492 + "toastPosition": "Pranešimo vieta", 493 + "toastPositionBottomLeft": "Apačioje kairėje", 494 + "toastPositionBottomCenter": "Apačioje centre", 495 + "toastPositionBottomRight": "Apačioje dešinėje", 496 + "countdownDaysAgo": "prieš dienas", 497 + "deleteCategoryEvents": "Taip pat ištrinti visus šios kategorijos įvykius", 498 + "categoryDeletedWithEvents": "Kategorija ir visi jos įvykiai buvo ištrinti", 499 + "importToCalendarCategory": "Importuoti į kalendoriaus kategoriją", 500 + "status": "Būsena", 501 + "feedback": "Atsiliepimai", 502 + "privacy": "Privatumas", 503 + "tos": "Paslaugos sąlygos" 482 504 }
+23 -1
locales/lv.json
··· 478 478 "novembris", 479 479 "decembris" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{year}}. g. {{month}}" 481 + "sidebarCalendarMonthYearFormat": "{{year}}. g. {{month}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Šī darbība neatgriezeniski dzēsīs jūsu šifrēto mākoņdublējumu. Lai turpinātu, ierakstiet DELETE CLOUD DATA.", 486 + "confirmDeleteData": "Apstiprināt datu dzēšanu", 487 + "countdownIcon": "Ikona", 488 + "countdownSearchIcon": "Meklēt ikonas", 489 + "countdownAdded": "Atpakaļskaitīšana pievienota", 490 + "countdownUpdated": "Atpakaļskaitīšana atjaunināta", 491 + "countdownDeleted": "Atpakaļskaitīšana dzēsta", 492 + "toastPosition": "Paziņojuma pozīcija", 493 + "toastPositionBottomLeft": "Apakšā pa kreisi", 494 + "toastPositionBottomCenter": "Apakšā centrā", 495 + "toastPositionBottomRight": "Apakšā pa labi", 496 + "countdownDaysAgo": "dienas atpakaļ", 497 + "deleteCategoryEvents": "Dzēst arī visus šīs kategorijas notikumus", 498 + "categoryDeletedWithEvents": "Kategorija un visi tās notikumi tika dzēsti", 499 + "importToCalendarCategory": "Importēt kalendāra kategorijā", 500 + "status": "Statuss", 501 + "feedback": "Atsauksmes", 502 + "privacy": "Privātums", 503 + "tos": "Pakalpojuma noteikumi" 482 504 }
+23 -1
locales/mk.json
··· 478 478 "ноември", 479 479 "декември" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}} г." 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}} г.", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Оваа акција трајно ќе ја избрише вашата шифрирана cloud резервна копија. Напишете DELETE CLOUD DATA за да продолжите.", 486 + "confirmDeleteData": "Потврди бришење податоци", 487 + "countdownIcon": "Икона", 488 + "countdownSearchIcon": "Пребарај икони", 489 + "countdownAdded": "Одбројувањето е додадено", 490 + "countdownUpdated": "Одбројувањето е ажурирано", 491 + "countdownDeleted": "Одбројувањето е избришано", 492 + "toastPosition": "Позиција на известување", 493 + "toastPositionBottomLeft": "Долу лево", 494 + "toastPositionBottomCenter": "Долу средина", 495 + "toastPositionBottomRight": "Долу десно", 496 + "countdownDaysAgo": "дена претходно", 497 + "deleteCategoryEvents": "Избриши ги и сите настани во оваа категорија", 498 + "categoryDeletedWithEvents": "Категоријата и сите нејзини настани се избришани", 499 + "importToCalendarCategory": "Увези во категорија на календар", 500 + "status": "Статус", 501 + "feedback": "Повратни информации", 502 + "privacy": "Приватност", 503 + "tos": "Услови за користење" 482 504 }
+23 -1
locales/nb.json
··· 478 478 "november", 479 479 "desember" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Denne handlingen sletter den krypterte skylagringskopien din permanent. Skriv DELETE CLOUD DATA for å fortsette.", 486 + "confirmDeleteData": "Bekreft sletting av data", 487 + "countdownIcon": "Ikon", 488 + "countdownSearchIcon": "Søk ikoner", 489 + "countdownAdded": "Nedtelling lagt til", 490 + "countdownUpdated": "Nedtelling oppdatert", 491 + "countdownDeleted": "Nedtelling slettet", 492 + "toastPosition": "Varselposisjon", 493 + "toastPositionBottomLeft": "Nederst til venstre", 494 + "toastPositionBottomCenter": "Nederst i midten", 495 + "toastPositionBottomRight": "Nederst til høyre", 496 + "countdownDaysAgo": "dager siden", 497 + "deleteCategoryEvents": "Slett også alle hendelser i denne kategorien", 498 + "categoryDeletedWithEvents": "Kategori og alle hendelser i den ble slettet", 499 + "importToCalendarCategory": "Importer til kalenderkategori", 500 + "status": "Status", 501 + "feedback": "Tilbakemelding", 502 + "privacy": "Personvern", 503 + "tos": "Vilkår for bruk" 482 504 }
+23 -1
locales/nl.json
··· 478 478 "november", 479 479 "december" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Deze actie verwijdert permanent je versleutelde cloudback-up. Typ DELETE CLOUD DATA om door te gaan.", 486 + "confirmDeleteData": "Gegevens verwijderen bevestigen", 487 + "countdownIcon": "Pictogram", 488 + "countdownSearchIcon": "Pictogrammen zoeken", 489 + "countdownAdded": "Aftellen toegevoegd", 490 + "countdownUpdated": "Aftellen bijgewerkt", 491 + "countdownDeleted": "Aftellen verwijderd", 492 + "toastPosition": "Meldingspositie", 493 + "toastPositionBottomLeft": "Linksonder", 494 + "toastPositionBottomCenter": "Midden onder", 495 + "toastPositionBottomRight": "Rechtsonder", 496 + "countdownDaysAgo": "dagen geleden", 497 + "deleteCategoryEvents": "Verwijder ook alle gebeurtenissen in deze categorie", 498 + "categoryDeletedWithEvents": "Categorie en alle bijbehorende gebeurtenissen zijn verwijderd", 499 + "importToCalendarCategory": "Importeren in kalendercategorie", 500 + "status": "Status", 501 + "feedback": "Feedback", 502 + "privacy": "Privacy", 503 + "tos": "Servicevoorwaarden" 482 504 }
+23 -1
locales/pl.json
··· 478 478 "listopad", 479 479 "grudzień" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Ta akcja trwale usuwa zaszyfrowaną kopię zapasową w chmurze. Wpisz DELETE CLOUD DATA, aby kontynuować.", 486 + "confirmDeleteData": "Potwierdź usunięcie danych", 487 + "countdownIcon": "Ikona", 488 + "countdownSearchIcon": "Szukaj ikon", 489 + "countdownAdded": "Odliczanie dodane", 490 + "countdownUpdated": "Odliczanie zaktualizowane", 491 + "countdownDeleted": "Odliczanie usunięte", 492 + "toastPosition": "Pozycja powiadomień", 493 + "toastPositionBottomLeft": "Lewy dolny róg", 494 + "toastPositionBottomCenter": "Dół na środku", 495 + "toastPositionBottomRight": "Prawy dolny róg", 496 + "countdownDaysAgo": "dni temu", 497 + "deleteCategoryEvents": "Usuń także wszystkie wydarzenia w tej kategorii", 498 + "categoryDeletedWithEvents": "Kategoria i wszystkie jej wydarzenia zostały usunięte", 499 + "importToCalendarCategory": "Importuj do kategorii kalendarza", 500 + "status": "Status", 501 + "feedback": "Opinie", 502 + "privacy": "Prywatność", 503 + "tos": "Warunki korzystania" 482 504 }
+23 -1
locales/pt.json
··· 478 478 "novembro", 479 479 "dezembro" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} de {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} de {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Esta ação exclui permanentemente seu backup em nuvem criptografado. Digite DELETE CLOUD DATA para continuar.", 486 + "confirmDeleteData": "Confirmar exclusão de dados", 487 + "countdownIcon": "Ícone", 488 + "countdownSearchIcon": "Pesquisar ícones", 489 + "countdownAdded": "Contagem regressiva adicionada", 490 + "countdownUpdated": "Contagem regressiva atualizada", 491 + "countdownDeleted": "Contagem regressiva excluída", 492 + "toastPosition": "Posição da notificação", 493 + "toastPositionBottomLeft": "Inferior esquerdo", 494 + "toastPositionBottomCenter": "Inferior central", 495 + "toastPositionBottomRight": "Inferior direito", 496 + "countdownDaysAgo": "dias atrás", 497 + "deleteCategoryEvents": "Excluir também todos os eventos desta categoria", 498 + "categoryDeletedWithEvents": "A categoria e todos os eventos dela foram excluídos", 499 + "importToCalendarCategory": "Importar para categoria do calendário", 500 + "status": "Estado", 501 + "feedback": "Feedback", 502 + "privacy": "Privacidade", 503 + "tos": "Termos de serviço" 482 504 }
+23 -1
locales/ro.json
··· 478 478 "noiembrie", 479 479 "decembrie" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Această acțiune șterge definitiv backupul tău cloud criptat. Tastează DELETE CLOUD DATA pentru a continua.", 486 + "confirmDeleteData": "Confirmă ștergerea datelor", 487 + "countdownIcon": "Pictogramă", 488 + "countdownSearchIcon": "Caută pictograme", 489 + "countdownAdded": "Numărătoare adăugată", 490 + "countdownUpdated": "Numărătoare actualizată", 491 + "countdownDeleted": "Numărătoare ștearsă", 492 + "toastPosition": "Poziția notificării", 493 + "toastPositionBottomLeft": "Jos stânga", 494 + "toastPositionBottomCenter": "Jos centru", 495 + "toastPositionBottomRight": "Jos dreapta", 496 + "countdownDaysAgo": "zile în urmă", 497 + "deleteCategoryEvents": "Șterge și toate evenimentele din această categorie", 498 + "categoryDeletedWithEvents": "Categoria și toate evenimentele sale au fost șterse", 499 + "importToCalendarCategory": "Importă în categoria calendarului", 500 + "status": "Stare", 501 + "feedback": "Feedback", 502 + "privacy": "Confidențialitate", 503 + "tos": "Termeni de utilizare" 482 504 }
+23 -1
locales/ru.json
··· 478 478 "ноябрь", 479 479 "декабрь" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}} г." 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}} г.", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Это действие навсегда удалит вашу зашифрованную облачную резервную копию. Введите DELETE CLOUD DATA, чтобы продолжить.", 486 + "confirmDeleteData": "Подтвердить удаление данных", 487 + "countdownIcon": "Иконка", 488 + "countdownSearchIcon": "Поиск иконок", 489 + "countdownAdded": "Обратный отсчёт добавлен", 490 + "countdownUpdated": "Обратный отсчёт обновлён", 491 + "countdownDeleted": "Обратный отсчёт удалён", 492 + "toastPosition": "Позиция уведомления", 493 + "toastPositionBottomLeft": "Внизу слева", 494 + "toastPositionBottomCenter": "Внизу по центру", 495 + "toastPositionBottomRight": "Внизу справа", 496 + "countdownDaysAgo": "дней назад", 497 + "deleteCategoryEvents": "Также удалить все события в этой категории", 498 + "categoryDeletedWithEvents": "Категория и все её события были удалены", 499 + "importToCalendarCategory": "Импортировать в категорию календаря", 500 + "status": "Статус", 501 + "feedback": "Обратная связь", 502 + "privacy": "Конфиденциальность", 503 + "tos": "Условия использования" 482 504 }
+23 -1
locales/sl.json
··· 478 478 "november", 479 479 "december" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "To dejanje trajno izbriše vašo šifrirano varnostno kopijo v oblaku. Za nadaljevanje vnesite DELETE CLOUD DATA.", 486 + "confirmDeleteData": "Potrdi brisanje podatkov", 487 + "countdownIcon": "Ikona", 488 + "countdownSearchIcon": "Išči ikone", 489 + "countdownAdded": "Odštevanje dodano", 490 + "countdownUpdated": "Odštevanje posodobljeno", 491 + "countdownDeleted": "Odštevanje izbrisano", 492 + "toastPosition": "Položaj obvestila", 493 + "toastPositionBottomLeft": "Spodaj levo", 494 + "toastPositionBottomCenter": "Spodaj na sredini", 495 + "toastPositionBottomRight": "Spodaj desno", 496 + "countdownDaysAgo": "dni nazaj", 497 + "deleteCategoryEvents": "Izbriši tudi vse dogodke v tej kategoriji", 498 + "categoryDeletedWithEvents": "Kategorija in vsi njeni dogodki so bili izbrisani", 499 + "importToCalendarCategory": "Uvozi v kategorijo koledarja", 500 + "status": "Stanje", 501 + "feedback": "Povratne informacije", 502 + "privacy": "Zasebnost", 503 + "tos": "Pogoji storitve" 482 504 }
+23 -1
locales/sq.json
··· 478 478 "nëntor", 479 479 "dhjetor" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Ky veprim fshin përgjithmonë kopjen rezervë të koduar në cloud. Shkruani DELETE CLOUD DATA për të vazhduar.", 486 + "confirmDeleteData": "Konfirmo fshirjen e të dhënave", 487 + "countdownIcon": "Ikona", 488 + "countdownSearchIcon": "Kërko ikona", 489 + "countdownAdded": "Numërimi mbrapsht u shtua", 490 + "countdownUpdated": "Numërimi mbrapsht u përditësua", 491 + "countdownDeleted": "Numërimi mbrapsht u fshi", 492 + "toastPosition": "Pozicioni i njoftimit", 493 + "toastPositionBottomLeft": "Poshtë majtas", 494 + "toastPositionBottomCenter": "Poshtë në qendër", 495 + "toastPositionBottomRight": "Poshtë djathtas", 496 + "countdownDaysAgo": "ditë më parë", 497 + "deleteCategoryEvents": "Fshi edhe të gjitha ngjarjet në këtë kategori", 498 + "categoryDeletedWithEvents": "Kategoria dhe të gjitha ngjarjet e saj u fshinë", 499 + "importToCalendarCategory": "Importo në kategorinë e kalendarit", 500 + "status": "Statusi", 501 + "feedback": "Komentet", 502 + "privacy": "Privatësia", 503 + "tos": "Kushtet e shërbimit" 482 504 }
+23 -1
locales/sr.json
··· 478 478 "новембар", 479 479 "децембар" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}." 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}.", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Ова радња трајно брише вашу шифровану cloud резервну копију. Унесите DELETE CLOUD DATA да наставите.", 486 + "confirmDeleteData": "Потврди брисање података", 487 + "countdownIcon": "Икона", 488 + "countdownSearchIcon": "Претражи иконе", 489 + "countdownAdded": "Одбројавање је додато", 490 + "countdownUpdated": "Одбројавање је ажурирано", 491 + "countdownDeleted": "Одбројавање је обрисано", 492 + "toastPosition": "Позиција обавештења", 493 + "toastPositionBottomLeft": "Доле лево", 494 + "toastPositionBottomCenter": "Доле средина", 495 + "toastPositionBottomRight": "Доле десно", 496 + "countdownDaysAgo": "дана раније", 497 + "deleteCategoryEvents": "Обриши и све догађаје у овој категорији", 498 + "categoryDeletedWithEvents": "Категорија и сви њени догађаји су обрисани", 499 + "importToCalendarCategory": "Увези у категорију календара", 500 + "status": "Статус", 501 + "feedback": "Повратне информације", 502 + "privacy": "Приватност", 503 + "tos": "Услови коришћења" 482 504 }
+23 -1
locales/sv.json
··· 478 478 "november", 479 479 "december" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Denna åtgärd tar permanent bort din krypterade molnbackup. Skriv DELETE CLOUD DATA för att fortsätta.", 486 + "confirmDeleteData": "Bekräfta radering av data", 487 + "countdownIcon": "Ikon", 488 + "countdownSearchIcon": "Sök ikoner", 489 + "countdownAdded": "Nedräkning tillagd", 490 + "countdownUpdated": "Nedräkning uppdaterad", 491 + "countdownDeleted": "Nedräkning borttagen", 492 + "toastPosition": "Notifieringsposition", 493 + "toastPositionBottomLeft": "Nedre vänster", 494 + "toastPositionBottomCenter": "Nedre mitten", 495 + "toastPositionBottomRight": "Nedre höger", 496 + "countdownDaysAgo": "dagar sedan", 497 + "deleteCategoryEvents": "Radera även alla händelser i denna kategori", 498 + "categoryDeletedWithEvents": "Kategori och alla dess händelser togs bort", 499 + "importToCalendarCategory": "Importera till kalenderkategori", 500 + "status": "Status", 501 + "feedback": "Feedback", 502 + "privacy": "Integritet", 503 + "tos": "Användarvillkor" 482 504 }
+23 -1
locales/sw.json
··· 478 478 "Novemba", 479 479 "Desemba" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Kitendo hiki kitafuta kabisa nakala yako fiche ya hifadhi ya wingu. Andika DELETE CLOUD DATA kuendelea.", 486 + "confirmDeleteData": "Thibitisha kufuta data", 487 + "countdownIcon": "Aikoni", 488 + "countdownSearchIcon": "Tafuta aikoni", 489 + "countdownAdded": "Hesabu ya kurudi nyuma imeongezwa", 490 + "countdownUpdated": "Hesabu ya kurudi nyuma imesasishwa", 491 + "countdownDeleted": "Hesabu ya kurudi nyuma imefutwa", 492 + "toastPosition": "Nafasi ya arifa", 493 + "toastPositionBottomLeft": "Chini kushoto", 494 + "toastPositionBottomCenter": "Chini katikati", 495 + "toastPositionBottomRight": "Chini kulia", 496 + "countdownDaysAgo": "siku zilizopita", 497 + "deleteCategoryEvents": "Futa pia matukio yote katika kategoria hii", 498 + "categoryDeletedWithEvents": "Kategoria na matukio yake yote yamefutwa", 499 + "importToCalendarCategory": "Ingiza kwenye kategoria ya kalenda", 500 + "status": "Hali", 501 + "feedback": "Maoni", 502 + "privacy": "Faragha", 503 + "tos": "Masharti ya Huduma" 482 504 }
+23 -1
locales/th.json
··· 478 478 "พฤศจิกายน", 479 479 "ธันวาคม" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "การดำเนินการนี้จะลบข้อมูลสำรองคลาวด์ที่เข้ารหัสของคุณอย่างถาวร พิมพ์ DELETE CLOUD DATA เพื่อดำเนินการต่อ", 486 + "confirmDeleteData": "ยืนยันการลบข้อมูล", 487 + "countdownIcon": "ไอคอน", 488 + "countdownSearchIcon": "ค้นหาไอคอน", 489 + "countdownAdded": "เพิ่มการนับถอยหลังแล้ว", 490 + "countdownUpdated": "อัปเดตการนับถอยหลังแล้ว", 491 + "countdownDeleted": "ลบการนับถอยหลังแล้ว", 492 + "toastPosition": "ตำแหน่งการแจ้งเตือน", 493 + "toastPositionBottomLeft": "ล่างซ้าย", 494 + "toastPositionBottomCenter": "ล่างกลาง", 495 + "toastPositionBottomRight": "ล่างขวา", 496 + "countdownDaysAgo": "วันที่แล้ว", 497 + "deleteCategoryEvents": "ลบอีเวนต์ทั้งหมดในหมวดหมู่นี้ด้วย", 498 + "categoryDeletedWithEvents": "ลบหมวดหมู่และอีเวนต์ทั้งหมดแล้ว", 499 + "importToCalendarCategory": "นำเข้าไปยังหมวดหมู่ปฏิทิน", 500 + "status": "สถานะ", 501 + "feedback": "ข้อเสนอแนะ", 502 + "privacy": "ความเป็นส่วนตัว", 503 + "tos": "ข้อกำหนดการให้บริการ" 482 504 }
+23 -1
locales/tr.json
··· 478 478 "Kasım", 479 479 "Aralık" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Bu işlem şifrelenmiş bulut yedeğinizi kalıcı olarak siler. Devam etmek için DELETE CLOUD DATA yazın.", 486 + "confirmDeleteData": "Veri silmeyi onayla", 487 + "countdownIcon": "Simge", 488 + "countdownSearchIcon": "Simgeleri ara", 489 + "countdownAdded": "Geri sayım eklendi", 490 + "countdownUpdated": "Geri sayım güncellendi", 491 + "countdownDeleted": "Geri sayım silindi", 492 + "toastPosition": "Bildirim konumu", 493 + "toastPositionBottomLeft": "Sol alt", 494 + "toastPositionBottomCenter": "Alt orta", 495 + "toastPositionBottomRight": "Sağ alt", 496 + "countdownDaysAgo": "gün önce", 497 + "deleteCategoryEvents": "Bu kategorideki tüm etkinlikleri de sil", 498 + "categoryDeletedWithEvents": "Kategori ve tüm etkinlikleri silindi", 499 + "importToCalendarCategory": "Takvim kategorisine aktar", 500 + "status": "Durum", 501 + "feedback": "Geri Bildirim", 502 + "privacy": "Gizlilik", 503 + "tos": "Hizmet Şartları" 482 504 }
+23 -1
locales/uk.json
··· 478 478 "листопад", 479 479 "грудень" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} {{year}} р." 481 + "sidebarCalendarMonthYearFormat": "{{month}} {{year}} р.", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Ця дія назавжди видалить вашу зашифровану хмарну резервну копію. Введіть DELETE CLOUD DATA, щоб продовжити.", 486 + "confirmDeleteData": "Підтвердити видалення даних", 487 + "countdownIcon": "Іконка", 488 + "countdownSearchIcon": "Пошук іконок", 489 + "countdownAdded": "Зворотний відлік додано", 490 + "countdownUpdated": "Зворотний відлік оновлено", 491 + "countdownDeleted": "Зворотний відлік видалено", 492 + "toastPosition": "Позиція сповіщення", 493 + "toastPositionBottomLeft": "Внизу ліворуч", 494 + "toastPositionBottomCenter": "Внизу по центру", 495 + "toastPositionBottomRight": "Внизу праворуч", 496 + "countdownDaysAgo": "днів тому", 497 + "deleteCategoryEvents": "Також видалити всі події в цій категорії", 498 + "categoryDeletedWithEvents": "Категорію та всі її події видалено", 499 + "importToCalendarCategory": "Імпортувати до категорії календаря", 500 + "status": "Статус", 501 + "feedback": "Відгук", 502 + "privacy": "Конфіденційність", 503 + "tos": "Умови користування" 482 504 }
+23 -1
locales/vi.json
··· 478 478 "Tháng 11", 479 479 "Tháng 12" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{month}} năm {{year}}" 481 + "sidebarCalendarMonthYearFormat": "{{month}} năm {{year}}", 482 + "editCategory": "Edit category", 483 + "categoryUpdated": "Category updated", 484 + "deleteCloudConfirmTitle": "Delete cloud backup data", 485 + "deleteCloudConfirmDescription": "Hành động này sẽ xóa vĩnh viễn bản sao lưu đám mây đã mã hóa của bạn. Nhập DELETE CLOUD DATA để tiếp tục.", 486 + "confirmDeleteData": "Xác nhận xóa dữ liệu", 487 + "countdownIcon": "Biểu tượng", 488 + "countdownSearchIcon": "Tìm biểu tượng", 489 + "countdownAdded": "Đã thêm đếm ngược", 490 + "countdownUpdated": "Đã cập nhật đếm ngược", 491 + "countdownDeleted": "Đã xóa đếm ngược", 492 + "toastPosition": "Vị trí thông báo", 493 + "toastPositionBottomLeft": "Dưới bên trái", 494 + "toastPositionBottomCenter": "Dưới chính giữa", 495 + "toastPositionBottomRight": "Dưới bên phải", 496 + "countdownDaysAgo": "ngày trước", 497 + "deleteCategoryEvents": "Đồng thời xóa tất cả sự kiện trong danh mục này", 498 + "categoryDeletedWithEvents": "Danh mục và tất cả sự kiện của nó đã bị xóa", 499 + "importToCalendarCategory": "Nhập vào danh mục lịch", 500 + "status": "Trạng thái", 501 + "feedback": "Phản hồi", 502 + "privacy": "Quyền riêng tư", 503 + "tos": "Điều khoản dịch vụ" 482 504 }
+23 -1
locales/yue.json
··· 478 478 "11月", 479 479 "12月" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{year}}年{{month}}" 481 + "sidebarCalendarMonthYearFormat": "{{year}}年{{month}}", 482 + "editCategory": "编辑分类", 483 + "categoryUpdated": "分类已更新", 484 + "deleteCloudConfirmTitle": "删除云端备份数据", 485 + "deleteCloudConfirmDescription": "此操作会永久删除你的加密云端备份。请输入 DELETE CLOUD DATA 以继续。", 486 + "confirmDeleteData": "确认删除数据", 487 + "countdownIcon": "图标", 488 + "countdownSearchIcon": "搜索图标", 489 + "countdownAdded": "倒数日已添加", 490 + "countdownUpdated": "倒数日已更新", 491 + "countdownDeleted": "倒数日已删除", 492 + "toastPosition": "通知位置", 493 + "toastPositionBottomLeft": "左下", 494 + "toastPositionBottomCenter": "中下", 495 + "toastPositionBottomRight": "右下", 496 + "countdownDaysAgo": "天前", 497 + "deleteCategoryEvents": "同时删除此分类下的全部日程", 498 + "categoryDeletedWithEvents": "分类及其全部日程已删除", 499 + "importToCalendarCategory": "导入到日历分类", 500 + "status": "狀態", 501 + "feedback": "回饋", 502 + "privacy": "私隱", 503 + "tos": "服務條款" 482 504 }
+23 -1
locales/zh-CN.json
··· 478 478 "十一月", 479 479 "十二月" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{year}}年{{month}}" 481 + "sidebarCalendarMonthYearFormat": "{{year}}年{{month}}", 482 + "editCategory": "编辑分类", 483 + "categoryUpdated": "分类已更新", 484 + "deleteCloudConfirmTitle": "删除云端备份数据", 485 + "deleteCloudConfirmDescription": "此操作会永久删除你的加密云端备份。请输入 DELETE CLOUD DATA 以继续。", 486 + "confirmDeleteData": "确认删除数据", 487 + "countdownIcon": "图标", 488 + "countdownSearchIcon": "搜索图标", 489 + "countdownAdded": "倒数日已添加", 490 + "countdownUpdated": "倒数日已更新", 491 + "countdownDeleted": "倒数日已删除", 492 + "toastPosition": "通知位置", 493 + "toastPositionBottomLeft": "左下", 494 + "toastPositionBottomCenter": "中下", 495 + "toastPositionBottomRight": "右下", 496 + "countdownDaysAgo": "天前", 497 + "deleteCategoryEvents": "同时删除此分类下的全部日程", 498 + "categoryDeletedWithEvents": "分类及其全部日程已删除", 499 + "importToCalendarCategory": "导入到日历分类", 500 + "status": "状态", 501 + "feedback": "反馈", 502 + "privacy": "隐私", 503 + "tos": "服务条款" 482 504 }
+23 -1
locales/zh-HK.json
··· 478 478 "11月", 479 479 "12月" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{year}}年{{month}}" 481 + "sidebarCalendarMonthYearFormat": "{{year}}年{{month}}", 482 + "editCategory": "编辑分类", 483 + "categoryUpdated": "分类已更新", 484 + "deleteCloudConfirmTitle": "删除云端备份数据", 485 + "deleteCloudConfirmDescription": "此操作会永久删除你的加密云端备份。请输入 DELETE CLOUD DATA 以继续。", 486 + "confirmDeleteData": "确认删除数据", 487 + "countdownIcon": "图标", 488 + "countdownSearchIcon": "搜索图标", 489 + "countdownAdded": "倒数日已添加", 490 + "countdownUpdated": "倒数日已更新", 491 + "countdownDeleted": "倒数日已删除", 492 + "toastPosition": "通知位置", 493 + "toastPositionBottomLeft": "左下", 494 + "toastPositionBottomCenter": "中下", 495 + "toastPositionBottomRight": "右下", 496 + "countdownDaysAgo": "天前", 497 + "deleteCategoryEvents": "同时删除此分类下的全部日程", 498 + "categoryDeletedWithEvents": "分类及其全部日程已删除", 499 + "importToCalendarCategory": "导入到日历分类", 500 + "status": "狀態", 501 + "feedback": "回饋", 502 + "privacy": "私隱", 503 + "tos": "服務條款" 482 504 }
+23 -1
locales/zh-TW.json
··· 478 478 "11月", 479 479 "12月" 480 480 ], 481 - "sidebarCalendarMonthYearFormat": "{{year}}年{{month}}" 481 + "sidebarCalendarMonthYearFormat": "{{year}}年{{month}}", 482 + "editCategory": "编辑分类", 483 + "categoryUpdated": "分类已更新", 484 + "deleteCloudConfirmTitle": "删除云端备份数据", 485 + "deleteCloudConfirmDescription": "此操作会永久删除你的加密云端备份。请输入 DELETE CLOUD DATA 以继续。", 486 + "confirmDeleteData": "确认删除数据", 487 + "countdownIcon": "图标", 488 + "countdownSearchIcon": "搜索图标", 489 + "countdownAdded": "倒数日已添加", 490 + "countdownUpdated": "倒数日已更新", 491 + "countdownDeleted": "倒数日已删除", 492 + "toastPosition": "通知位置", 493 + "toastPositionBottomLeft": "左下", 494 + "toastPositionBottomCenter": "中下", 495 + "toastPositionBottomRight": "右下", 496 + "countdownDaysAgo": "天前", 497 + "deleteCategoryEvents": "同时删除此分类下的全部日程", 498 + "categoryDeletedWithEvents": "分类及其全部日程已删除", 499 + "importToCalendarCategory": "导入到日历分类", 500 + "status": "狀態", 501 + "feedback": "回饋", 502 + "privacy": "隱私", 503 + "tos": "服務條款" 482 504 }
+1 -1
package.json
··· 1 1 { 2 2 "name": "one-calendar", 3 - "version": "2.2.0", 3 + "version": "2.2.6", 4 4 "private": true, 5 5 "packageManager": "bun@1.3.6", 6 6 "scripts": {
+2 -2
tsconfig.json
··· 11 11 "moduleResolution": "bundler", 12 12 "resolveJsonModule": true, 13 13 "isolatedModules": true, 14 - "jsx": "preserve", 14 + "jsx": "react-jsx", 15 15 "incremental": true, 16 16 "plugins": [ 17 17 { ··· 22 22 "@/*": ["./*"] 23 23 } 24 24 }, 25 - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], 26 26 "exclude": ["node_modules"] 27 27 }