feat: add JMAP sharing for calendars and address books
This commit is contained in:
@@ -47,7 +47,11 @@ import { getEventStartDate } from "@/lib/calendar-utils";
|
|||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
|
||||||
|
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
|
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
@@ -72,7 +76,8 @@ export default function CalendarPage() {
|
|||||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||||
|
removeCalendar, clearCalendarEvents,
|
||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
@@ -91,6 +96,11 @@ export default function CalendarPage() {
|
|||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||||
|
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
|
||||||
|
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
|
||||||
|
const [showCreateCalendar, setShowCreateCalendar] = useState(false);
|
||||||
|
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
|
||||||
|
const tMgmt = useTranslations("calendar.management");
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
@@ -1102,6 +1112,42 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||||
|
onCreateEvent={(cal: Calendar) => {
|
||||||
|
setDefaultCalendarIdForCreate(cal.id);
|
||||||
|
openCreateModal();
|
||||||
|
}}
|
||||||
|
onClearCalendar={client ? async (cal: Calendar) => {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: tMgmt("clear_events"),
|
||||||
|
message: tMgmt("confirm_clear", { name: cal.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: tMgmt("clear_events"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
const count = await clearCalendarEvents(client, cal.id);
|
||||||
|
toast.success(tMgmt("events_cleared", { count }));
|
||||||
|
} catch {
|
||||||
|
toast.error(tMgmt("error_clear"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
onDeleteCalendar={client ? async (cal: Calendar) => {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: tMgmt("delete"),
|
||||||
|
message: tMgmt("confirm_delete", { name: cal.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: tMgmt("delete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await removeCalendar(client, cal.id);
|
||||||
|
toast.success(tMgmt("calendar_deleted"));
|
||||||
|
} catch {
|
||||||
|
toast.error(tMgmt("error_delete"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
|
||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
@@ -1165,11 +1211,12 @@ export default function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
|
defaultCalendarId={defaultCalendarIdForCreate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||||
onPreviewChange={setPendingPreview}
|
onPreviewChange={setPendingPreview}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
@@ -1261,11 +1308,12 @@ export default function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
|
defaultCalendarId={defaultCalendarIdForCreate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={true}
|
isMobile={true}
|
||||||
/>
|
/>
|
||||||
@@ -1305,6 +1353,33 @@ export default function CalendarPage() {
|
|||||||
onSelect={handleScopeSelect}
|
onSelect={handleScopeSelect}
|
||||||
onClose={() => setPendingScopeAction(null)}
|
onClose={() => setPendingScopeAction(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
|
||||||
|
{showCreateCalendar && client && (
|
||||||
|
<CreateCalendarModal
|
||||||
|
client={client}
|
||||||
|
onClose={() => setShowCreateCalendar(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sharingCalendarId && client && (() => {
|
||||||
|
const cal = allCalendars.find((c) => c.id === sharingCalendarId);
|
||||||
|
if (!cal) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="calendar"
|
||||||
|
collectionName={cal.name}
|
||||||
|
shareWith={cal.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingCalendarId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
|||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||||
|
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| "list"
|
| "list"
|
||||||
@@ -75,6 +76,8 @@ export default function ContactsPage() {
|
|||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
renameAddressBook,
|
renameAddressBook,
|
||||||
|
removeAddressBook,
|
||||||
|
shareAddressBook,
|
||||||
renameKeyword,
|
renameKeyword,
|
||||||
importContacts,
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
@@ -83,6 +86,8 @@ export default function ContactsPage() {
|
|||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||||
|
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||||
|
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
@@ -329,6 +334,7 @@ export default function ContactsPage() {
|
|||||||
addLocalContact(localContact);
|
addLocalContact(localContact);
|
||||||
toast.success(t("toast.created"));
|
toast.success(t("toast.created"));
|
||||||
}
|
}
|
||||||
|
setDefaultBookIdForCreate(undefined);
|
||||||
setView("list");
|
setView("list");
|
||||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||||
|
|
||||||
@@ -346,6 +352,7 @@ export default function ContactsPage() {
|
|||||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
setDefaultBookIdForCreate(undefined);
|
||||||
if (view === "group-create" || view === "group-edit") {
|
if (view === "group-create" || view === "group-edit") {
|
||||||
setView(selectedGroup ? "group-detail" : "list");
|
setView(selectedGroup ? "group-detail" : "list");
|
||||||
} else if (view === "bulk-add-to-group") {
|
} else if (view === "bulk-add-to-group") {
|
||||||
@@ -517,7 +524,7 @@ export default function ContactsPage() {
|
|||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "create":
|
case "create":
|
||||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
|
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
if (!selectedContact) return null;
|
if (!selectedContact) return null;
|
||||||
@@ -690,6 +697,26 @@ export default function ContactsPage() {
|
|||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
onDropContactsToCategory={handleDropContactsToCategory}
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||||
|
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
|
||||||
|
onCreateContactInBook={(book) => {
|
||||||
|
setDefaultBookIdForCreate(book.id);
|
||||||
|
handleCreateNew();
|
||||||
|
}}
|
||||||
|
onDeleteAddressBook={client ? async (book) => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("address_books.delete"),
|
||||||
|
message: t("address_books.confirm_delete", { name: book.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: t("address_books.delete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await removeAddressBook(client, book);
|
||||||
|
toast.success(t("address_books.deleted"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("address_books.delete_failed"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -838,6 +865,23 @@ export default function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{sharingAddressBookId && client && (() => {
|
||||||
|
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
|
||||||
|
if (!book) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="addressBook"
|
||||||
|
collectionName={book.name}
|
||||||
|
shareWith={book.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingAddressBookId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
|
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
@@ -11,6 +11,8 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
interface CalendarSidebarPanelProps {
|
interface CalendarSidebarPanelProps {
|
||||||
@@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps {
|
|||||||
selectedCalendarIds: string[];
|
selectedCalendarIds: string[];
|
||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
|
onShareCalendar?: (calendar: Calendar) => void;
|
||||||
|
onCreateEvent?: (calendar: Calendar) => void;
|
||||||
|
onClearCalendar?: (calendar: Calendar) => void;
|
||||||
|
onDeleteCalendar?: (calendar: Calendar) => void;
|
||||||
|
onCreateCalendar?: () => void;
|
||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
onEditSubscription?: (subscriptionId: string) => void;
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
client?: IJMAPClient | null;
|
client?: IJMAPClient | null;
|
||||||
@@ -28,12 +35,18 @@ export function CalendarSidebarPanel({
|
|||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
|
onShareCalendar,
|
||||||
|
onCreateEvent,
|
||||||
|
onClearCalendar,
|
||||||
|
onDeleteCalendar,
|
||||||
|
onCreateCalendar,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
onEditSubscription,
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const tSub = useTranslations("calendar.subscription");
|
const tSub = useTranslations("calendar.subscription");
|
||||||
|
const tMgmt = useTranslations("calendar.management");
|
||||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
@@ -49,11 +62,8 @@ export function CalendarSidebarPanel({
|
|||||||
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
|
||||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||||
const sharedAccountGroups = useMemo(() => {
|
const sharedAccountGroups = useMemo(() => {
|
||||||
@@ -69,30 +79,6 @@ export function CalendarSidebarPanel({
|
|||||||
return Array.from(groups.values());
|
return Array.from(groups.values());
|
||||||
}, [calendars]);
|
}, [calendars]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!colorPickerId && !contextMenuCalId) return;
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
|
||||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
|
|
||||||
setColorPickerId(null);
|
|
||||||
}
|
|
||||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setColorPickerId(null);
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
|
||||||
document.addEventListener('keydown', handleKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClick);
|
|
||||||
document.removeEventListener('keydown', handleKey);
|
|
||||||
};
|
|
||||||
}, [colorPickerId, contextMenuCalId]);
|
|
||||||
|
|
||||||
const getSubscriptionForCalendar = (calendarId: string) => {
|
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||||
};
|
};
|
||||||
@@ -100,7 +86,6 @@ export function CalendarSidebarPanel({
|
|||||||
const handleRefreshSubscription = async (subId: string) => {
|
const handleRefreshSubscription = async (subId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
setRefreshingSubId(subId);
|
setRefreshingSubId(subId);
|
||||||
setContextMenuCalId(null);
|
|
||||||
try {
|
try {
|
||||||
await refreshICalSubscription(client, subId);
|
await refreshICalSubscription(client, subId);
|
||||||
toast.success(tSub('refresh_success'));
|
toast.success(tSub('refresh_success'));
|
||||||
@@ -113,7 +98,6 @@ export function CalendarSidebarPanel({
|
|||||||
|
|
||||||
const handleUnsubscribe = async (subId: string) => {
|
const handleUnsubscribe = async (subId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
setContextMenuCalId(null);
|
|
||||||
try {
|
try {
|
||||||
await removeICalSubscription(client, subId);
|
await removeICalSubscription(client, subId);
|
||||||
toast.success(tSub('deleted'));
|
toast.success(tSub('deleted'));
|
||||||
@@ -127,21 +111,13 @@ export function CalendarSidebarPanel({
|
|||||||
const renderCalendarItem = (cal: Calendar) => {
|
const renderCalendarItem = (cal: Calendar) => {
|
||||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
const color = cal.color || "#3b82f6";
|
const color = cal.color || "#3b82f6";
|
||||||
|
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cal.id} className="relative">
|
<div key={cal.id} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleVisibility(cal.id)}
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
|
||||||
e.preventDefault();
|
|
||||||
if (isSubscriptionCalendar(cal.id) && client) {
|
|
||||||
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
|
|
||||||
setColorPickerId(null);
|
|
||||||
} else if (onColorChange) {
|
|
||||||
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||||
"hover:bg-muted"
|
"hover:bg-muted"
|
||||||
@@ -169,70 +145,101 @@ export function CalendarSidebarPanel({
|
|||||||
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Subscription context menu on right-click */}
|
|
||||||
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
|
|
||||||
const sub = getSubscriptionForCalendar(cal.id);
|
|
||||||
if (!sub) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={contextMenuRef}
|
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
onEditSubscription?.(sub.id);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
{tSub('edit')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRefreshSubscription(sub.id)}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
|
||||||
{tSub('refresh')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUnsubscribe(sub.id)}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
{tSub('unsubscribe')}
|
|
||||||
</button>
|
|
||||||
{sub.lastRefreshed && (
|
|
||||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
|
||||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Color picker popover on right-click */}
|
|
||||||
{colorPickerId === cal.id && onColorChange && (
|
|
||||||
<div
|
|
||||||
ref={colorPickerRef}
|
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
|
||||||
>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
|
|
||||||
<CalendarColorPicker
|
|
||||||
value={color}
|
|
||||||
onChange={(c) => {
|
|
||||||
onColorChange(cal.id, c);
|
|
||||||
setColorPickerId(null);
|
|
||||||
}}
|
|
||||||
allowCustom
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderCalendarMenu = () => {
|
||||||
|
const cal = contextMenu.data;
|
||||||
|
if (!cal) return null;
|
||||||
|
|
||||||
|
if (isSubscriptionCalendar(cal.id)) {
|
||||||
|
const sub = getSubscriptionForCalendar(cal.id);
|
||||||
|
if (!sub || !client) return null;
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={tSub('edit')}
|
||||||
|
onClick={() => { closeContextMenu(); onEditSubscription?.(sub.id); }}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={RefreshCw}
|
||||||
|
label={tSub('refresh')}
|
||||||
|
onClick={() => { closeContextMenu(); handleRefreshSubscription(sub.id); }}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={tSub('unsubscribe')}
|
||||||
|
onClick={() => { closeContextMenu(); handleUnsubscribe(sub.id); }}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
{sub.lastRefreshed && (
|
||||||
|
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
||||||
|
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||||
|
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||||
|
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||||
|
const canChangeColor = !!onColorChange;
|
||||||
|
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||||
|
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||||
|
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||||
|
{canCreate && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Plus}
|
||||||
|
label={tMgmt('new_event_in_calendar')}
|
||||||
|
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={tMgmt('share')}
|
||||||
|
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canChangeColor && (
|
||||||
|
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
||||||
|
<div className="px-2 py-1.5 w-[200px]">
|
||||||
|
<CalendarColorPicker
|
||||||
|
value={color}
|
||||||
|
onChange={(c) => { onColorChange(cal.id, c); closeContextMenu(); }}
|
||||||
|
allowCustom
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ContextMenuSubMenu>
|
||||||
|
)}
|
||||||
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
|
{canClear && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Eraser}
|
||||||
|
label={tMgmt('clear_events')}
|
||||||
|
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={tMgmt('delete')}
|
||||||
|
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{enableCalendarTasks && (
|
{enableCalendarTasks && (
|
||||||
@@ -250,9 +257,22 @@ export function CalendarSidebarPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||||
{t("my_calendars")}
|
{onCreateCalendar ? (
|
||||||
</h3>
|
<button
|
||||||
|
onClick={onCreateCalendar}
|
||||||
|
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||||
|
title={tMgmt('add_calendar')}
|
||||||
|
>
|
||||||
|
{t('my_calendars')}
|
||||||
|
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t('my_calendars')}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{personalCalendars.map(renderCalendarItem)}
|
{personalCalendars.map(renderCalendarItem)}
|
||||||
</div>
|
</div>
|
||||||
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{renderCalendarMenu()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
|
||||||
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
|
interface CreateCalendarModalProps {
|
||||||
|
client: IJMAPClient;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) {
|
||||||
|
const t = useTranslations("calendar.management");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const createCalendar = useCalendarStore((s) => s.createCalendar);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [color, setColor] = useState("#3b82f6");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isValid = name.trim().length > 0;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const created = await createCalendar(client, { name: trimmed, color });
|
||||||
|
if (created) {
|
||||||
|
toast.success(t("calendar_created"));
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
toast.error(t("error_create"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("error_create"));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [name, color, client, createCalendar, onClose, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && !isSubmitting) onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [onClose, isSubmitting]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const modal = modalRef.current;
|
||||||
|
if (!modal) return;
|
||||||
|
const focusableEls = modal.querySelectorAll<HTMLElement>(
|
||||||
|
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
const firstEl = focusableEls[0];
|
||||||
|
const lastEl = focusableEls[focusableEls.length - 1];
|
||||||
|
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
if (e.shiftKey && document.activeElement === firstEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastEl?.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === lastEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstEl?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
modal.addEventListener("keydown", handler);
|
||||||
|
firstEl?.focus();
|
||||||
|
return () => modal.removeEventListener("keydown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
|
||||||
|
onClick={() => !isSubmitting && onClose()}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("add_calendar")}
|
||||||
|
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
aria-label={tCommon("close")}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
{t("name")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("name_placeholder")}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
{t("color")}
|
||||||
|
</label>
|
||||||
|
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
{tCommon("loading")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("create")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ interface EventModalProps {
|
|||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
defaultDate?: Date;
|
defaultDate?: Date;
|
||||||
defaultEndDate?: Date;
|
defaultEndDate?: Date;
|
||||||
|
defaultCalendarId?: string;
|
||||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||||
@@ -113,6 +114,7 @@ export function EventModal({
|
|||||||
calendars,
|
calendars,
|
||||||
defaultDate,
|
defaultDate,
|
||||||
defaultEndDate,
|
defaultEndDate,
|
||||||
|
defaultCalendarId,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -200,6 +202,7 @@ export function EventModal({
|
|||||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
||||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||||
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||||
|
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
|
||||||
const defaultCal = calendars.find(c => c.isDefault);
|
const defaultCal = calendars.find(c => c.isDefault);
|
||||||
return defaultCal?.id || calendars[0]?.id || "";
|
return defaultCal?.id || calendars[0]?.id || "";
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface ContactFormProps {
|
|||||||
contact?: ContactCard | null;
|
contact?: ContactCard | null;
|
||||||
addressBooks?: AddressBook[];
|
addressBooks?: AddressBook[];
|
||||||
allKeywords?: string[];
|
allKeywords?: string[];
|
||||||
|
defaultAddressBookId?: string;
|
||||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -143,7 +144,7 @@ function Select({ value, onChange, children, className }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
|
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
|
||||||
const t = useTranslations("contacts.form");
|
const t = useTranslations("contacts.form");
|
||||||
const isEditing = !!contact;
|
const isEditing = !!contact;
|
||||||
|
|
||||||
@@ -330,8 +331,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
return ids[0];
|
return ids[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
|
||||||
|
return defaultAddressBookId;
|
||||||
|
}
|
||||||
return "";
|
return "";
|
||||||
}, [contact]);
|
}, [contact, defaultAddressBookId, addressBooks]);
|
||||||
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||||
|
|
||||||
const initialPhotoEntry = useMemo(() => {
|
const initialPhotoEntry = useMemo(() => {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ interface ContactsSidebarProps {
|
|||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||||
|
onShareAddressBook?: (addressBook: AddressBook) => void;
|
||||||
|
onCreateContactInBook?: (addressBook: AddressBook) => void;
|
||||||
|
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||||
onRenameKeyword?: (keyword: string) => void;
|
onRenameKeyword?: (keyword: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,9 @@ export function ContactsSidebar({
|
|||||||
onDropContacts,
|
onDropContacts,
|
||||||
onDropContactsToCategory,
|
onDropContactsToCategory,
|
||||||
onRenameAddressBook,
|
onRenameAddressBook,
|
||||||
|
onShareAddressBook,
|
||||||
|
onCreateContactInBook,
|
||||||
|
onDeleteAddressBook,
|
||||||
onRenameKeyword,
|
onRenameKeyword,
|
||||||
className,
|
className,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
@@ -288,7 +294,7 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -430,7 +436,7 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,24 +444,65 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address book context menu */}
|
{/* Address book context menu */}
|
||||||
{bookContextMenu.data && onRenameAddressBook && (
|
{bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
|
||||||
<ContextMenu
|
const book = bookContextMenu.data;
|
||||||
ref={bookMenuRef}
|
const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
|
||||||
isOpen={bookContextMenu.isOpen}
|
const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
|
||||||
position={bookContextMenu.position}
|
const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
|
||||||
onClose={closeBookContextMenu}
|
const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
|
||||||
>
|
const showSeparator = (canCreate || canRename || canShare) && canDelete;
|
||||||
<ContextMenuItem
|
return (
|
||||||
icon={Pencil}
|
<ContextMenu
|
||||||
label={t("address_books.rename")}
|
ref={bookMenuRef}
|
||||||
onClick={() => {
|
isOpen={bookContextMenu.isOpen}
|
||||||
const book = bookContextMenu.data!;
|
position={bookContextMenu.position}
|
||||||
closeBookContextMenu();
|
onClose={closeBookContextMenu}
|
||||||
onRenameAddressBook(book);
|
>
|
||||||
}}
|
{canCreate && (
|
||||||
/>
|
<ContextMenuItem
|
||||||
</ContextMenu>
|
icon={UserPlus}
|
||||||
)}
|
label={t("address_books.new_contact_in_book")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onCreateContactInBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canRename && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("address_books.rename")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onRenameAddressBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={t("address_books.share")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onShareAddressBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
|
{canDelete && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("address_books.delete")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onDeleteAddressBook(book);
|
||||||
|
}}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Keyword (category) context menu */}
|
{/* Keyword (category) context menu */}
|
||||||
{keywordContextMenu.data && onRenameKeyword && (
|
{keywordContextMenu.data && onRenameKeyword && (
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Book, Pencil, Share2, Tag } from "lucide-react";
|
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { AddressBook } from "@/lib/jmap/types";
|
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||||
|
import { ShareCollectionDialog } from "./share-collection-dialog";
|
||||||
|
|
||||||
function AddressBookEditRow({
|
function AddressBookEditRow({
|
||||||
initial,
|
initial,
|
||||||
@@ -70,9 +71,10 @@ export function AddressBookManagementSettings() {
|
|||||||
const tContacts = useTranslations("contacts");
|
const tContacts = useTranslations("contacts");
|
||||||
const tSettings = useTranslations("settings.contacts");
|
const tSettings = useTranslations("settings.contacts");
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
|
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||||
|
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -148,6 +150,16 @@ export function AddressBookManagementSettings() {
|
|||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!book.isShared && book.myRights?.mayShare && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSharingId(book.id)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={t("share")}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -242,6 +254,24 @@ export function AddressBookManagementSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sharingId && client && (() => {
|
||||||
|
const book = addressBooks.find((b) => b.id === sharingId);
|
||||||
|
if (!book) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="addressBook"
|
||||||
|
collectionName={book.name}
|
||||||
|
shareWith={book.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { useAuthStore } from '@/stores/auth-store';
|
|||||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import { SettingsSection } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react';
|
||||||
|
import { ShareCollectionDialog } from './share-collection-dialog';
|
||||||
|
import type { CalendarRights } from '@/lib/jmap/types';
|
||||||
import { cn, formatDateTime } from '@/lib/utils';
|
import { cn, formatDateTime } from '@/lib/utils';
|
||||||
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||||
@@ -83,7 +85,7 @@ function CalendarColorPicker({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarEditForm({
|
export function CalendarEditForm({
|
||||||
initial,
|
initial,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -153,7 +155,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
|
|||||||
export function CalendarManagementSettings() {
|
export function CalendarManagementSettings() {
|
||||||
const t = useTranslations('calendar.management');
|
const t = useTranslations('calendar.management');
|
||||||
const { client, serverUrl, username } = useAuthStore();
|
const { client, serverUrl, username } = useAuthStore();
|
||||||
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||||
|
|
||||||
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
|
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
|
||||||
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
|
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
|
||||||
@@ -164,6 +166,7 @@ export function CalendarManagementSettings() {
|
|||||||
const [clearingId, setClearingId] = useState<string | null>(null);
|
const [clearingId, setClearingId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||||
|
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
||||||
@@ -522,6 +525,16 @@ export function CalendarManagementSettings() {
|
|||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSharingId(cal.id)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={t('share')}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setClearingId(cal.id)}
|
onClick={() => setClearingId(cal.id)}
|
||||||
@@ -689,6 +702,24 @@ export function CalendarManagementSettings() {
|
|||||||
onClose={() => setEditingSubscription(null)}
|
onClose={() => setEditingSubscription(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sharingId && client && (() => {
|
||||||
|
const cal = calendars.find((c) => c.id === sharingId);
|
||||||
|
if (!cal) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="calendar"
|
||||||
|
collectionName={cal.name}
|
||||||
|
shareWith={cal.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
|
||||||
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
|
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
|
type ShareKind = "calendar" | "addressBook";
|
||||||
|
type AnyRights = CalendarRights | AddressBookRights;
|
||||||
|
|
||||||
|
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
|
||||||
|
|
||||||
|
const CALENDAR_PRESETS: Record<Exclude<RolePreset, "custom">, CalendarRights> = {
|
||||||
|
freeBusy: {
|
||||||
|
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
|
||||||
|
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
|
||||||
|
},
|
||||||
|
read: {
|
||||||
|
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false,
|
||||||
|
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
|
||||||
|
},
|
||||||
|
readWrite: {
|
||||||
|
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||||
|
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
|
||||||
|
},
|
||||||
|
manager: {
|
||||||
|
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||||
|
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights> = {
|
||||||
|
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
|
||||||
|
readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false },
|
||||||
|
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectCalendarPreset(r: CalendarRights): RolePreset {
|
||||||
|
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
|
||||||
|
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectAddressBookPreset(r: AddressBookRights): RolePreset {
|
||||||
|
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights][]) {
|
||||||
|
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
|
||||||
|
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShareCollectionDialogProps {
|
||||||
|
client: IJMAPClient;
|
||||||
|
kind: ShareKind;
|
||||||
|
collectionName: string;
|
||||||
|
shareWith: Record<string, AnyRights> | null | undefined;
|
||||||
|
ownAccountId: string;
|
||||||
|
onShare: (principalId: string, rights: AnyRights | null) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareCollectionDialog({
|
||||||
|
client,
|
||||||
|
kind,
|
||||||
|
collectionName,
|
||||||
|
shareWith,
|
||||||
|
ownAccountId,
|
||||||
|
onShare,
|
||||||
|
onClose,
|
||||||
|
}: ShareCollectionDialogProps) {
|
||||||
|
const t = useTranslations("sharing");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [principals, setPrincipals] = useState<Principal[]>([]);
|
||||||
|
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
|
// Load principals on mount
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingPrincipals(true);
|
||||||
|
client.getPrincipals().then((list) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
// Exclude the user themselves and any principal that already has a share
|
||||||
|
const existing = new Set(Object.keys(shareWith || {}));
|
||||||
|
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
||||||
|
setPrincipals(filtered);
|
||||||
|
setLoadingPrincipals(false);
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) setLoadingPrincipals(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [client, ownAccountId, shareWith]);
|
||||||
|
|
||||||
|
// Map principal id -> Principal for displayed shares
|
||||||
|
const allPrincipalsById = useMemo(() => {
|
||||||
|
const map = new Map<string, Principal>();
|
||||||
|
for (const p of principals) map.set(p.id, p);
|
||||||
|
return map;
|
||||||
|
}, [principals]);
|
||||||
|
|
||||||
|
// Close on Escape, focus trap, click outside
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleSetRights = async (principalId: string, preset: RolePreset) => {
|
||||||
|
if (preset === "custom") return; // custom is read-only here
|
||||||
|
const rights = kind === "calendar"
|
||||||
|
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
|
||||||
|
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
|
||||||
|
if (!rights) return;
|
||||||
|
setSavingId(principalId);
|
||||||
|
try {
|
||||||
|
await onShare(principalId, rights);
|
||||||
|
toast.success(t("share_updated"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (principalId: string) => {
|
||||||
|
setSavingId(principalId);
|
||||||
|
try {
|
||||||
|
await onShare(principalId, null);
|
||||||
|
toast.success(t("share_removed"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async (principal: Principal) => {
|
||||||
|
const defaultPreset: RolePreset = "read";
|
||||||
|
const rights = kind === "calendar"
|
||||||
|
? CALENDAR_PRESETS[defaultPreset]
|
||||||
|
: ADDRESS_BOOK_PRESETS[defaultPreset];
|
||||||
|
setSavingId(principal.id);
|
||||||
|
try {
|
||||||
|
await onShare(principal.id, rights);
|
||||||
|
// Move principal out of the "to add" list
|
||||||
|
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
|
||||||
|
setShowAdd(false);
|
||||||
|
setSearch("");
|
||||||
|
toast.success(t("share_added"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredPrincipals = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return principals;
|
||||||
|
return principals.filter((p) =>
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.email?.toLowerCase().includes(q) ||
|
||||||
|
p.description?.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [principals, search]);
|
||||||
|
|
||||||
|
const sharedEntries = useMemo(() => {
|
||||||
|
return Object.entries(shareWith || {}) as [string, AnyRights][];
|
||||||
|
}, [shareWith]);
|
||||||
|
|
||||||
|
const presetOptions = kind === "calendar"
|
||||||
|
? ["freeBusy", "read", "readWrite", "manager"] as const
|
||||||
|
: ["read", "readWrite", "manager"] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("title", { name: collectionName })}
|
||||||
|
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={tCommon("close")}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||||
|
|
||||||
|
{sharedEntries.length === 0 && !showAdd && (
|
||||||
|
<div className="text-sm text-muted-foreground italic py-4 text-center">
|
||||||
|
{t("no_shares")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sharedEntries.length > 0 && (
|
||||||
|
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
|
||||||
|
{sharedEntries.map(([principalId, rights]) => {
|
||||||
|
const principal = allPrincipalsById.get(principalId);
|
||||||
|
const preset = kind === "calendar"
|
||||||
|
? detectCalendarPreset(rights as CalendarRights)
|
||||||
|
: detectAddressBookPreset(rights as AddressBookRights);
|
||||||
|
return (
|
||||||
|
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{principal?.name || principal?.email || principalId}
|
||||||
|
</div>
|
||||||
|
{principal?.description && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{principal.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={preset}
|
||||||
|
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
|
||||||
|
disabled={savingId === principalId}
|
||||||
|
className="appearance-none rounded-md border border-input bg-background pl-3 pr-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{presetOptions.map((p) => (
|
||||||
|
<option key={p} value={p}>{t(`preset.${p}`)}</option>
|
||||||
|
))}
|
||||||
|
{preset === "custom" && (
|
||||||
|
<option value="custom">{t("preset.custom")}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(principalId)}
|
||||||
|
disabled={savingId === principalId}
|
||||||
|
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||||
|
aria-label={t("remove")}
|
||||||
|
title={t("remove")}
|
||||||
|
>
|
||||||
|
{savingId === principalId
|
||||||
|
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
: <Trash2 className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showAdd && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowAdd(true)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
{t("add_person")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<div className="space-y-2 border border-border rounded-md p-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t("search_placeholder")}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="max-h-48 overflow-y-auto -mx-1">
|
||||||
|
{loadingPrincipals && (
|
||||||
|
<div className="flex items-center justify-center py-4 text-muted-foreground">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
{t("loading_principals")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingPrincipals && filteredPrincipals.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-3">
|
||||||
|
{search.trim() ? t("no_match") : t("no_principals")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingPrincipals && filteredPrincipals.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleAdd(p)}
|
||||||
|
disabled={savingId === p.id}
|
||||||
|
className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate flex items-center gap-2">
|
||||||
|
{p.name}
|
||||||
|
{p.type === "group" && (
|
||||||
|
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
|
||||||
|
{t("group")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{p.email && p.email !== p.name && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{p.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{savingId === p.id && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setSearch(""); }}>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||||
|
<Button onClick={onClose}>{tCommon("close")}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
|
|||||||
mayWriteOwn: true,
|
mayWriteOwn: true,
|
||||||
mayUpdatePrivate: true,
|
mayUpdatePrivate: true,
|
||||||
mayRSVP: true,
|
mayRSVP: true,
|
||||||
mayAdmin: false,
|
mayShare: false,
|
||||||
mayDelete: false,
|
mayDelete: false,
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function createBirthdayCalendar(name?: string, color?: string): Calendar
|
|||||||
mayWriteOwn: false,
|
mayWriteOwn: false,
|
||||||
mayUpdatePrivate: false,
|
mayUpdatePrivate: false,
|
||||||
mayRSVP: false,
|
mayRSVP: false,
|
||||||
mayAdmin: false,
|
mayShare: false,
|
||||||
mayDelete: false,
|
mayDelete: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-1
@@ -78,6 +78,10 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
supportsCalendars(): boolean { return true; }
|
supportsCalendars(): boolean { return true; }
|
||||||
supportsSieve(): boolean { return true; }
|
supportsSieve(): boolean { return true; }
|
||||||
supportsFiles(): boolean { return true; }
|
supportsFiles(): boolean { return true; }
|
||||||
|
supportsPrincipals(): boolean { return false; }
|
||||||
|
async getPrincipals(): Promise<never[]> { return []; }
|
||||||
|
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
|
||||||
|
async setAddressBookShare(): Promise<void> { /* demo: no-op */ }
|
||||||
|
|
||||||
// ── Push / state ──────────────────────────────────────────────
|
// ── Push / state ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -552,6 +556,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
if (book) Object.assign(book, updates);
|
if (book) Object.assign(book, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteAddressBook(addressBookId: string): Promise<void> {
|
||||||
|
this.data.addressBooks = this.data.addressBooks.filter(b => b.id !== addressBookId);
|
||||||
|
this.data.contacts = this.data.contacts.filter(c => !c.addressBookIds?.[addressBookId]);
|
||||||
|
}
|
||||||
|
|
||||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||||
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||||
return [...this.data.contacts];
|
return [...this.data.contacts];
|
||||||
@@ -609,7 +618,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
includeInAvailability: 'all',
|
includeInAvailability: 'all',
|
||||||
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
|
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
|
||||||
timeZone: null, shareWith: null,
|
timeZone: null, shareWith: null,
|
||||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||||
...calendar,
|
...calendar,
|
||||||
} as Calendar;
|
} as Calendar;
|
||||||
this.data.calendars.push(full);
|
this.data.calendars.push(full);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function createDemoCalendars(): Calendar[] {
|
|||||||
defaultAlertsWithoutTime: null,
|
defaultAlertsWithoutTime: null,
|
||||||
timeZone: null,
|
timeZone: null,
|
||||||
shareWith: null,
|
shareWith: null,
|
||||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false },
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'demo-calendar-work',
|
id: 'demo-calendar-work',
|
||||||
@@ -33,7 +33,7 @@ export function createDemoCalendars(): Calendar[] {
|
|||||||
defaultAlertsWithoutTime: null,
|
defaultAlertsWithoutTime: null,
|
||||||
timeZone: null,
|
timeZone: null,
|
||||||
shareWith: null,
|
shareWith: null,
|
||||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'demo-calendar-birthdays',
|
id: 'demo-calendar-birthdays',
|
||||||
@@ -49,7 +49,7 @@ export function createDemoCalendars(): Calendar[] {
|
|||||||
defaultAlertsWithoutTime: null,
|
defaultAlertsWithoutTime: null,
|
||||||
timeZone: null,
|
timeZone: null,
|
||||||
shareWith: null,
|
shareWith: null,
|
||||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,6 +190,7 @@ export interface IJMAPClient {
|
|||||||
getAllAddressBooks(): Promise<AddressBook[]>;
|
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||||
createAddressBook(name: string): Promise<AddressBook>;
|
createAddressBook(name: string): Promise<AddressBook>;
|
||||||
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
||||||
|
deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void>;
|
||||||
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||||
getAllContacts(): Promise<ContactCard[]>;
|
getAllContacts(): Promise<ContactCard[]>;
|
||||||
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
||||||
@@ -227,6 +228,12 @@ export interface IJMAPClient {
|
|||||||
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
|
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
|
||||||
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
|
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Sharing (RFC 9670 Principals) ─────────────────────────────
|
||||||
|
supportsPrincipals(): boolean;
|
||||||
|
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
|
||||||
|
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
||||||
|
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||||
|
|
||||||
// ── Sieve / Filters ──────────────────────────────────────────
|
// ── Sieve / Filters ──────────────────────────────────────────
|
||||||
getSieveAccountId(): string;
|
getSieveAccountId(): string;
|
||||||
getSieveCapabilities(): SieveCapabilities | null;
|
getSieveCapabilities(): SieveCapabilities | null;
|
||||||
|
|||||||
+101
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
@@ -2826,6 +2826,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return this.hasCapability("urn:ietf:params:jmap:sieve");
|
return this.hasCapability("urn:ietf:params:jmap:sieve");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
supportsPrincipals(): boolean {
|
||||||
|
return this.hasCapability("urn:ietf:params:jmap:principals");
|
||||||
|
}
|
||||||
|
|
||||||
getSieveAccountId(): string {
|
getSieveAccountId(): string {
|
||||||
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
|
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
|
||||||
return sieveAccount || this.accountId;
|
return sieveAccount || this.accountId;
|
||||||
@@ -3198,6 +3202,102 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error("Failed to update address book");
|
throw new Error("Failed to update address book");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void> {
|
||||||
|
const accountId = targetAccountId || this.getContactsAccountId();
|
||||||
|
const response = await this.request([
|
||||||
|
["AddressBook/set", { accountId, destroy: [addressBookId] }, "0"],
|
||||||
|
], this.contactUsing());
|
||||||
|
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
if (result?.notDestroyed?.[addressBookId]) {
|
||||||
|
const err = result.notDestroyed[addressBookId];
|
||||||
|
throw new Error(err.description || "Failed to delete address book");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sharing (RFC 9670) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private principalsUsing(): string[] {
|
||||||
|
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all principals visible to the user (RFC 9670). Stalwart returns the
|
||||||
|
* full directory regardless of `filter`, so we fetch the whole list and let
|
||||||
|
* callers filter client-side.
|
||||||
|
*/
|
||||||
|
async getPrincipals(targetAccountId?: string): Promise<Principal[]> {
|
||||||
|
if (!this.supportsPrincipals()) return [];
|
||||||
|
const accountId = targetAccountId || this.accountId;
|
||||||
|
try {
|
||||||
|
const response = await this.request([
|
||||||
|
["Principal/query", { accountId }, "0"],
|
||||||
|
["Principal/get", {
|
||||||
|
accountId,
|
||||||
|
"#ids": { resultOf: "0", name: "Principal/query", path: "/ids" },
|
||||||
|
}, "1"],
|
||||||
|
], this.principalsUsing());
|
||||||
|
|
||||||
|
const getResp = response.methodResponses?.find((r) => r[0] === "Principal/get");
|
||||||
|
if (!getResp) return [];
|
||||||
|
const list = (getResp[1].list || []) as Principal[];
|
||||||
|
return list.map((p) => ({ ...p, accountId }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch principals:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add, update, or remove a principal's rights on a calendar.
|
||||||
|
* Pass `rights: null` to revoke access.
|
||||||
|
*/
|
||||||
|
async setCalendarShare(
|
||||||
|
calendarId: string,
|
||||||
|
principalId: string,
|
||||||
|
rights: CalendarRights | null,
|
||||||
|
targetAccountId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
const response = await this.request([
|
||||||
|
["Calendar/set", {
|
||||||
|
accountId,
|
||||||
|
update: { [calendarId]: { [`shareWith/${principalId}`]: rights } },
|
||||||
|
}, "0"],
|
||||||
|
], this.calendarUsing());
|
||||||
|
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
if (result?.notUpdated?.[calendarId]) {
|
||||||
|
const err = result.notUpdated[calendarId];
|
||||||
|
throw new Error(err.description || "Failed to update calendar share");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add, update, or remove a principal's rights on an address book.
|
||||||
|
* Pass `rights: null` to revoke access.
|
||||||
|
*/
|
||||||
|
async setAddressBookShare(
|
||||||
|
addressBookId: string,
|
||||||
|
principalId: string,
|
||||||
|
rights: AddressBookRights | null,
|
||||||
|
targetAccountId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const accountId = targetAccountId || this.getContactsAccountId();
|
||||||
|
const response = await this.request([
|
||||||
|
["AddressBook/set", {
|
||||||
|
accountId,
|
||||||
|
update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } },
|
||||||
|
}, "0"],
|
||||||
|
], this.contactUsing());
|
||||||
|
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
if (result?.notUpdated?.[addressBookId]) {
|
||||||
|
const err = result.notUpdated[addressBookId];
|
||||||
|
throw new Error(err.description || "Failed to update address book share");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchPaginatedContacts(
|
private async fetchPaginatedContacts(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
filter?: Record<string, unknown>,
|
filter?: Record<string, unknown>,
|
||||||
|
|||||||
+15
-2
@@ -368,6 +368,7 @@ export interface AddressBook {
|
|||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
isSubscribed?: boolean;
|
isSubscribed?: boolean;
|
||||||
myRights?: AddressBookRights;
|
myRights?: AddressBookRights;
|
||||||
|
shareWith?: Record<string, AddressBookRights> | null;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountName?: string;
|
accountName?: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
@@ -376,10 +377,22 @@ export interface AddressBook {
|
|||||||
export interface AddressBookRights {
|
export interface AddressBookRights {
|
||||||
mayRead: boolean;
|
mayRead: boolean;
|
||||||
mayWrite: boolean;
|
mayWrite: boolean;
|
||||||
mayShare: boolean;
|
mayShare?: boolean;
|
||||||
mayDelete: boolean;
|
mayDelete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JMAP Principals (RFC 9670)
|
||||||
|
export interface Principal {
|
||||||
|
id: string;
|
||||||
|
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
timeZone?: string | null;
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
|
accountId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VacationResponse {
|
export interface VacationResponse {
|
||||||
id: string;
|
id: string;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
@@ -442,7 +455,7 @@ export interface CalendarRights {
|
|||||||
mayWriteOwn: boolean;
|
mayWriteOwn: boolean;
|
||||||
mayUpdatePrivate: boolean;
|
mayUpdatePrivate: boolean;
|
||||||
mayRSVP: boolean;
|
mayRSVP: boolean;
|
||||||
mayAdmin: boolean;
|
mayShare: boolean;
|
||||||
mayDelete: boolean;
|
mayDelete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-13
@@ -426,7 +426,7 @@
|
|||||||
"event_updated": "Aktualizace #{sequence}",
|
"event_updated": "Aktualizace #{sequence}",
|
||||||
"event_status_tentative": "Nezávazně",
|
"event_status_tentative": "Nezávazně",
|
||||||
"event_status_cancelled": "Zrušeno",
|
"event_status_cancelled": "Zrušeno",
|
||||||
"expand": "Zobrazit detaily",
|
"expand": "Zobrazit detaily",
|
||||||
"collapse": "Skrýt detaily"
|
"collapse": "Skrýt detaily"
|
||||||
},
|
},
|
||||||
"send": "Odeslat",
|
"send": "Odeslat",
|
||||||
@@ -675,7 +675,7 @@
|
|||||||
"encryption": "Šifrování",
|
"encryption": "Šifrování",
|
||||||
"sidebar_apps": "Aplikace postranního panelu",
|
"sidebar_apps": "Aplikace postranního panelu",
|
||||||
"notifications": "Oznámení",
|
"notifications": "Oznámení",
|
||||||
"layout": "Vzhled",
|
"layout": "Vzhled",
|
||||||
"reading": "Čtení",
|
"reading": "Čtení",
|
||||||
"composing": "Psaní",
|
"composing": "Psaní",
|
||||||
"content_senders": "Obsah a odesílatelé",
|
"content_senders": "Obsah a odesílatelé",
|
||||||
@@ -688,7 +688,7 @@
|
|||||||
"organization": "Organizace pošty",
|
"organization": "Organizace pošty",
|
||||||
"apps": "Aplikace",
|
"apps": "Aplikace",
|
||||||
"system": "Systém",
|
"system": "Systém",
|
||||||
"appearance": "Vzhled",
|
"appearance": "Vzhled",
|
||||||
"mail": "Pošta",
|
"mail": "Pošta",
|
||||||
"privacy": "Soukromí a zabezpečení",
|
"privacy": "Soukromí a zabezpečení",
|
||||||
"advanced": "Pokročilé"
|
"advanced": "Pokročilé"
|
||||||
@@ -1094,7 +1094,7 @@
|
|||||||
"enable_error": "Nepodařilo se zapnout 2FA",
|
"enable_error": "Nepodařilo se zapnout 2FA",
|
||||||
"disable_error": "Nepodařilo se vypnout 2FA",
|
"disable_error": "Nepodařilo se vypnout 2FA",
|
||||||
"setup_instructions": "Zkopírujte tuto URL adresu do své ověřovací aplikace (Google Authenticator, Authy atd.):",
|
"setup_instructions": "Zkopírujte tuto URL adresu do své ověřovací aplikace (Google Authenticator, Authy atd.):",
|
||||||
"verification_code": "Ověřovací kód",
|
"verification_code": "Ověřovací kód",
|
||||||
"confirm": "Potvrdit",
|
"confirm": "Potvrdit",
|
||||||
"disable": "Vypnout",
|
"disable": "Vypnout",
|
||||||
"disable_confirm_prompt": "Pro vypnutí dvoufázového ověření zadejte heslo.",
|
"disable_confirm_prompt": "Pro vypnutí dvoufázového ověření zadejte heslo.",
|
||||||
@@ -1327,7 +1327,7 @@
|
|||||||
"contacts": {
|
"contacts": {
|
||||||
"title": "Kontakty",
|
"title": "Kontakty",
|
||||||
"description": "Import a export kontaktů",
|
"description": "Import a export kontaktů",
|
||||||
"group_by_letter_label": "Seskupit podle prvního písmene",
|
"group_by_letter_label": "Seskupit podle prvního písmene",
|
||||||
"group_by_letter_description": "Zobrazovat abecední nadpisy v seznamu kontaktů",
|
"group_by_letter_description": "Zobrazovat abecední nadpisy v seznamu kontaktů",
|
||||||
"import_label": "Importovat kontakty",
|
"import_label": "Importovat kontakty",
|
||||||
"import_description": "Importovat kontakty ze souboru vCard (.vcf)",
|
"import_description": "Importovat kontakty ze souboru vCard (.vcf)",
|
||||||
@@ -1595,7 +1595,7 @@
|
|||||||
"items_selected": "{count} vybraných zpráv",
|
"items_selected": "{count} vybraných zpráv",
|
||||||
"edit_draft": "Upravit koncept"
|
"edit_draft": "Upravit koncept"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Označit složku jako přečtenou",
|
"mark_folder_read": "Označit složku jako přečtenou",
|
||||||
"mark_folder_tree_read": "Označit složku a podsložky jako přečtené",
|
"mark_folder_tree_read": "Označit složku a podsložky jako přečtené",
|
||||||
"mark_all_folders_read": "Označit všechny složky jako přečtené",
|
"mark_all_folders_read": "Označit všechny složky jako přečtené",
|
||||||
@@ -1613,10 +1613,10 @@
|
|||||||
"prompt_new_subfolder": "Zadejte název nové podsložky.",
|
"prompt_new_subfolder": "Zadejte název nové podsložky.",
|
||||||
"prompt_new_folder": "Zadejte název nové složky.",
|
"prompt_new_folder": "Zadejte název nové složky.",
|
||||||
"prompt_rename": "Zadejte nový název této složky.",
|
"prompt_rename": "Zadejte nový název této složky.",
|
||||||
"placeholder_folder_name": "Název složky",
|
"placeholder_folder_name": "Název složky",
|
||||||
"create": "Vytvořit",
|
"create": "Vytvořit",
|
||||||
"rename_confirm": "Přejmenovat",
|
"rename_confirm": "Přejmenovat",
|
||||||
"toast_marked_read": "Složka označena jako přečtená",
|
"toast_marked_read": "Složka označena jako přečtená",
|
||||||
"toast_marked_read_count": "{count, plural, one {1 zpráva označena jako přečtená} few {# zprávy označeny jako přečtené} other {# zpráv označeno jako přečtených}}",
|
"toast_marked_read_count": "{count, plural, one {1 zpráva označena jako přečtená} few {# zprávy označeny jako přečtené} other {# zpráv označeno jako přečtených}}",
|
||||||
"toast_already_read": "Žádné nepřečtené zprávy",
|
"toast_already_read": "Žádné nepřečtené zprávy",
|
||||||
"toast_marked_all_read": "Všechny složky označeny jako přečtené",
|
"toast_marked_all_read": "Všechny složky označeny jako přečtené",
|
||||||
@@ -1820,7 +1820,13 @@
|
|||||||
"renamed": "Adresář byl přejmenován",
|
"renamed": "Adresář byl přejmenován",
|
||||||
"rename_failed": "Přejmenování adresáře selhalo",
|
"rename_failed": "Přejmenování adresáře selhalo",
|
||||||
"default": "Výchozí",
|
"default": "Výchozí",
|
||||||
"manage": "Spravovat adresáře"
|
"manage": "Spravovat adresáře",
|
||||||
|
"share": "Sdílet adresář",
|
||||||
|
"new_contact_in_book": "Nový kontakt v tomto adresáři",
|
||||||
|
"delete": "Smazat adresář",
|
||||||
|
"confirm_delete": "Smazat „{name}\"? Všechny kontakty v tomto adresáři budou odstraněny.",
|
||||||
|
"deleted": "Adresář smazán",
|
||||||
|
"delete_failed": "Adresář se nepodařilo smazat"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-mailové adresy",
|
"emails": "E-mailové adresy",
|
||||||
@@ -1995,7 +2001,7 @@
|
|||||||
"email_error_inline": "Neplatný formát e-mailové adresy",
|
"email_error_inline": "Neplatný formát e-mailové adresy",
|
||||||
"save_failed": "Uložení kontaktu selhalo",
|
"save_failed": "Uložení kontaktu selhalo",
|
||||||
"delete": "Odstranit",
|
"delete": "Odstranit",
|
||||||
"upload_photo": "Nahrát fotku",
|
"upload_photo": "Nahrát fotku",
|
||||||
"remove_photo": "Odebrat fotku",
|
"remove_photo": "Odebrat fotku",
|
||||||
"photo_hint": "JPG nebo PNG, max. 10 MB. Velikost se upraví.",
|
"photo_hint": "JPG nebo PNG, max. 10 MB. Velikost se upraví.",
|
||||||
"photo_too_large": "Obrázek je moc velký (max. 10 MB)",
|
"photo_too_large": "Obrázek je moc velký (max. 10 MB)",
|
||||||
@@ -2091,7 +2097,7 @@
|
|||||||
"has_email": "Má e-mail",
|
"has_email": "Má e-mail",
|
||||||
"has_phone": "Má telefon",
|
"has_phone": "Má telefon",
|
||||||
"has_photo": "Má fotku"
|
"has_photo": "Má fotku"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Kalendář",
|
"title": "Kalendář",
|
||||||
@@ -2345,7 +2351,9 @@
|
|||||||
"error_delete": "Odstranění kalendáře selhalo",
|
"error_delete": "Odstranění kalendáře selhalo",
|
||||||
"caldav_url": "URL CalDAV",
|
"caldav_url": "URL CalDAV",
|
||||||
"copy_url": "Kopírovat URL CalDAV",
|
"copy_url": "Kopírovat URL CalDAV",
|
||||||
"url_copied": "URL CalDAV zkopírováno do schránky"
|
"url_copied": "URL CalDAV zkopírováno do schránky",
|
||||||
|
"share": "Sdílet kalendář",
|
||||||
|
"new_event_in_calendar": "Nová událost v tomto kalendáři"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Odběr iCal",
|
"title": "Odběr iCal",
|
||||||
@@ -2711,5 +2719,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "Vyhledávání není ve sjednoceném zobrazení k dispozici"
|
"search_unavailable": "Vyhledávání není ve sjednoceném zobrazení k dispozici"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Sdílet „{name}\"",
|
||||||
|
"description": "Udělte přístup dalším uživatelům nebo skupinám na tomto serveru. Změny se projeví okamžitě.",
|
||||||
|
"no_shares": "Zatím nikomu nesdíleno.",
|
||||||
|
"add_person": "Přidat osobu nebo skupinu",
|
||||||
|
"search_placeholder": "Hledat podle jména nebo e-mailu…",
|
||||||
|
"loading_principals": "Načítání uživatelů…",
|
||||||
|
"no_principals": "Nenalezeni žádní další uživatelé ani skupiny.",
|
||||||
|
"no_match": "Žádné výsledky.",
|
||||||
|
"remove": "Odebrat přístup",
|
||||||
|
"group": "Skupina",
|
||||||
|
"share_added": "Přístup udělen",
|
||||||
|
"share_updated": "Přístup aktualizován",
|
||||||
|
"share_removed": "Přístup odebrán",
|
||||||
|
"share_failed": "Aktualizace sdílení selhala",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Pouze volno/zaneprázdněno",
|
||||||
|
"read": "Pouze čtení",
|
||||||
|
"readWrite": "Čtení a zápis",
|
||||||
|
"manager": "Správce",
|
||||||
|
"custom": "Vlastní"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "Adressbuch umbenannt",
|
"renamed": "Adressbuch umbenannt",
|
||||||
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
|
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
|
||||||
"default": "Standard",
|
"default": "Standard",
|
||||||
"manage": "Adressbücher verwalten"
|
"manage": "Adressbücher verwalten",
|
||||||
|
"share": "Adressbuch freigeben",
|
||||||
|
"new_contact_in_book": "Neuer Kontakt in diesem Adressbuch",
|
||||||
|
"delete": "Adressbuch löschen",
|
||||||
|
"confirm_delete": "„{name}\" löschen? Alle Kontakte in diesem Adressbuch werden entfernt.",
|
||||||
|
"deleted": "Adressbuch gelöscht",
|
||||||
|
"delete_failed": "Adressbuch konnte nicht gelöscht werden"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-Mail-Adressen",
|
"emails": "E-Mail-Adressen",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.",
|
"confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||||
"clear_events": "Ereignisse löschen",
|
"clear_events": "Ereignisse löschen",
|
||||||
"events_cleared": "{count} Ereignisse gelöscht",
|
"events_cleared": "{count} Ereignisse gelöscht",
|
||||||
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden"
|
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden",
|
||||||
|
"share": "Kalender freigeben",
|
||||||
|
"new_event_in_calendar": "Neuer Termin in diesem Kalender"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "iCal-Abonnement",
|
"title": "iCal-Abonnement",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar"
|
"search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "„{name}\" freigeben",
|
||||||
|
"description": "Anderen Benutzern oder Gruppen auf diesem Server Zugriff gewähren. Änderungen werden sofort wirksam.",
|
||||||
|
"no_shares": "Noch nicht freigegeben.",
|
||||||
|
"add_person": "Person oder Gruppe hinzufügen",
|
||||||
|
"search_placeholder": "Nach Name oder E-Mail suchen…",
|
||||||
|
"loading_principals": "Benutzer werden geladen…",
|
||||||
|
"no_principals": "Keine weiteren Benutzer oder Gruppen gefunden.",
|
||||||
|
"no_match": "Keine Treffer.",
|
||||||
|
"remove": "Zugriff entfernen",
|
||||||
|
"group": "Gruppe",
|
||||||
|
"share_added": "Zugriff erteilt",
|
||||||
|
"share_updated": "Zugriff aktualisiert",
|
||||||
|
"share_removed": "Zugriff entfernt",
|
||||||
|
"share_failed": "Freigabe konnte nicht aktualisiert werden",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Nur Frei/Belegt",
|
||||||
|
"read": "Nur lesen",
|
||||||
|
"readWrite": "Lesen & schreiben",
|
||||||
|
"manager": "Verwalten",
|
||||||
|
"custom": "Benutzerdefiniert"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1819,7 +1819,13 @@
|
|||||||
"renamed": "Address book renamed",
|
"renamed": "Address book renamed",
|
||||||
"rename_failed": "Failed to rename address book",
|
"rename_failed": "Failed to rename address book",
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
"manage": "Manage address books"
|
"manage": "Manage address books",
|
||||||
|
"share": "Share address book",
|
||||||
|
"new_contact_in_book": "New contact in this address book",
|
||||||
|
"delete": "Delete address book",
|
||||||
|
"confirm_delete": "Delete \"{name}\"? All contacts in this address book will be removed.",
|
||||||
|
"deleted": "Address book deleted",
|
||||||
|
"delete_failed": "Failed to delete address book"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Email Addresses",
|
"emails": "Email Addresses",
|
||||||
@@ -2344,7 +2350,9 @@
|
|||||||
"error_delete": "Failed to delete calendar",
|
"error_delete": "Failed to delete calendar",
|
||||||
"caldav_url": "CalDAV URL",
|
"caldav_url": "CalDAV URL",
|
||||||
"copy_url": "Copy CalDAV URL",
|
"copy_url": "Copy CalDAV URL",
|
||||||
"url_copied": "CalDAV URL copied to clipboard"
|
"url_copied": "CalDAV URL copied to clipboard",
|
||||||
|
"share": "Share calendar",
|
||||||
|
"new_event_in_calendar": "New event in this calendar"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "iCal Subscription",
|
"title": "iCal Subscription",
|
||||||
@@ -2426,6 +2434,29 @@
|
|||||||
"overdue": "Overdue"
|
"overdue": "Overdue"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Share \"{name}\"",
|
||||||
|
"description": "Grant access to other users or groups on this server. Changes take effect immediately.",
|
||||||
|
"no_shares": "Not shared with anyone yet.",
|
||||||
|
"add_person": "Add person or group",
|
||||||
|
"search_placeholder": "Search by name or email…",
|
||||||
|
"loading_principals": "Loading users…",
|
||||||
|
"no_principals": "No other users or groups found.",
|
||||||
|
"no_match": "No matches.",
|
||||||
|
"remove": "Remove access",
|
||||||
|
"group": "Group",
|
||||||
|
"share_added": "Access granted",
|
||||||
|
"share_updated": "Access updated",
|
||||||
|
"share_removed": "Access removed",
|
||||||
|
"share_failed": "Failed to update sharing",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Free/busy only",
|
||||||
|
"read": "Read only",
|
||||||
|
"readWrite": "Read & write",
|
||||||
|
"manager": "Manager",
|
||||||
|
"custom": "Custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
"advanced_search": {
|
"advanced_search": {
|
||||||
"title": "Advanced Search",
|
"title": "Advanced Search",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "Libreta de direcciones renombrada",
|
"renamed": "Libreta de direcciones renombrada",
|
||||||
"rename_failed": "Error al renombrar la libreta de direcciones",
|
"rename_failed": "Error al renombrar la libreta de direcciones",
|
||||||
"default": "Predeterminada",
|
"default": "Predeterminada",
|
||||||
"manage": "Administrar libretas de direcciones"
|
"manage": "Administrar libretas de direcciones",
|
||||||
|
"share": "Compartir libreta de direcciones",
|
||||||
|
"new_contact_in_book": "Nuevo contacto en esta libreta",
|
||||||
|
"delete": "Eliminar libreta de direcciones",
|
||||||
|
"confirm_delete": "¿Eliminar «{name}»? Todos los contactos de esta libreta se eliminarán.",
|
||||||
|
"deleted": "Libreta de direcciones eliminada",
|
||||||
|
"delete_failed": "No se pudo eliminar la libreta de direcciones"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Direcciones de correo",
|
"emails": "Direcciones de correo",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.",
|
"confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.",
|
||||||
"clear_events": "Borrar eventos",
|
"clear_events": "Borrar eventos",
|
||||||
"events_cleared": "{count} eventos borrados",
|
"events_cleared": "{count} eventos borrados",
|
||||||
"error_clear": "No se pudieron borrar los eventos del calendario"
|
"error_clear": "No se pudieron borrar los eventos del calendario",
|
||||||
|
"share": "Compartir calendario",
|
||||||
|
"new_event_in_calendar": "Nuevo evento en este calendario"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Suscripción iCal",
|
"title": "Suscripción iCal",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "La búsqueda no está disponible en la vista unificada"
|
"search_unavailable": "La búsqueda no está disponible en la vista unificada"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Compartir «{name}»",
|
||||||
|
"description": "Concede acceso a otros usuarios o grupos de este servidor. Los cambios surten efecto inmediatamente.",
|
||||||
|
"no_shares": "Aún no se ha compartido con nadie.",
|
||||||
|
"add_person": "Añadir persona o grupo",
|
||||||
|
"search_placeholder": "Buscar por nombre o correo…",
|
||||||
|
"loading_principals": "Cargando usuarios…",
|
||||||
|
"no_principals": "No se han encontrado otros usuarios ni grupos.",
|
||||||
|
"no_match": "Sin resultados.",
|
||||||
|
"remove": "Quitar acceso",
|
||||||
|
"group": "Grupo",
|
||||||
|
"share_added": "Acceso concedido",
|
||||||
|
"share_updated": "Acceso actualizado",
|
||||||
|
"share_removed": "Acceso retirado",
|
||||||
|
"share_failed": "No se pudo actualizar el uso compartido",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Solo disponibilidad",
|
||||||
|
"read": "Solo lectura",
|
||||||
|
"readWrite": "Lectura y escritura",
|
||||||
|
"manager": "Administrador",
|
||||||
|
"custom": "Personalizado"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "Carnet d'adresses renommé",
|
"renamed": "Carnet d'adresses renommé",
|
||||||
"rename_failed": "Échec du renommage du carnet d'adresses",
|
"rename_failed": "Échec du renommage du carnet d'adresses",
|
||||||
"default": "Par défaut",
|
"default": "Par défaut",
|
||||||
"manage": "Gérer les carnets d'adresses"
|
"manage": "Gérer les carnets d'adresses",
|
||||||
|
"share": "Partager le carnet d'adresses",
|
||||||
|
"new_contact_in_book": "Nouveau contact dans ce carnet d'adresses",
|
||||||
|
"delete": "Supprimer le carnet d'adresses",
|
||||||
|
"confirm_delete": "Supprimer « {name} » ? Tous les contacts de ce carnet d'adresses seront supprimés.",
|
||||||
|
"deleted": "Carnet d'adresses supprimé",
|
||||||
|
"delete_failed": "Échec de la suppression du carnet d'adresses"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Adresses e-mail",
|
"emails": "Adresses e-mail",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.",
|
"confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.",
|
||||||
"clear_events": "Supprimer les événements",
|
"clear_events": "Supprimer les événements",
|
||||||
"events_cleared": "{count} événements supprimés",
|
"events_cleared": "{count} événements supprimés",
|
||||||
"error_clear": "Impossible de supprimer les événements du calendrier"
|
"error_clear": "Impossible de supprimer les événements du calendrier",
|
||||||
|
"share": "Partager le calendrier",
|
||||||
|
"new_event_in_calendar": "Nouvel événement dans ce calendrier"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Abonnement iCal",
|
"title": "Abonnement iCal",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "La recherche n'est pas disponible dans la vue unifiée"
|
"search_unavailable": "La recherche n'est pas disponible dans la vue unifiée"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Partager « {name} »",
|
||||||
|
"description": "Accordez l'accès à d'autres utilisateurs ou groupes de ce serveur. Les modifications sont immédiates.",
|
||||||
|
"no_shares": "Pas encore partagé.",
|
||||||
|
"add_person": "Ajouter une personne ou un groupe",
|
||||||
|
"search_placeholder": "Rechercher par nom ou e-mail…",
|
||||||
|
"loading_principals": "Chargement des utilisateurs…",
|
||||||
|
"no_principals": "Aucun autre utilisateur ou groupe trouvé.",
|
||||||
|
"no_match": "Aucun résultat.",
|
||||||
|
"remove": "Révoquer l'accès",
|
||||||
|
"group": "Groupe",
|
||||||
|
"share_added": "Accès accordé",
|
||||||
|
"share_updated": "Accès mis à jour",
|
||||||
|
"share_removed": "Accès révoqué",
|
||||||
|
"share_failed": "Échec de la mise à jour du partage",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Disponibilité uniquement",
|
||||||
|
"read": "Lecture seule",
|
||||||
|
"readWrite": "Lecture & écriture",
|
||||||
|
"manager": "Gestionnaire",
|
||||||
|
"custom": "Personnalisé"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "Rubrica rinominata",
|
"renamed": "Rubrica rinominata",
|
||||||
"rename_failed": "Impossibile rinominare la rubrica",
|
"rename_failed": "Impossibile rinominare la rubrica",
|
||||||
"default": "Predefinita",
|
"default": "Predefinita",
|
||||||
"manage": "Gestisci rubriche"
|
"manage": "Gestisci rubriche",
|
||||||
|
"share": "Condividi rubrica",
|
||||||
|
"new_contact_in_book": "Nuovo contatto in questa rubrica",
|
||||||
|
"delete": "Elimina rubrica",
|
||||||
|
"confirm_delete": "Eliminare \"{name}\"? Tutti i contatti in questa rubrica verranno rimossi.",
|
||||||
|
"deleted": "Rubrica eliminata",
|
||||||
|
"delete_failed": "Impossibile eliminare la rubrica"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Indirizzi email",
|
"emails": "Indirizzi email",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.",
|
"confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.",
|
||||||
"clear_events": "Cancella eventi",
|
"clear_events": "Cancella eventi",
|
||||||
"events_cleared": "{count} eventi cancellati",
|
"events_cleared": "{count} eventi cancellati",
|
||||||
"error_clear": "Impossibile cancellare gli eventi del calendario"
|
"error_clear": "Impossibile cancellare gli eventi del calendario",
|
||||||
|
"share": "Condividi calendario",
|
||||||
|
"new_event_in_calendar": "Nuovo evento in questo calendario"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Abbonamento iCal",
|
"title": "Abbonamento iCal",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "La ricerca non è disponibile nella vista unificata"
|
"search_unavailable": "La ricerca non è disponibile nella vista unificata"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Condividi \"{name}\"",
|
||||||
|
"description": "Concedi l'accesso ad altri utenti o gruppi su questo server. Le modifiche hanno effetto immediato.",
|
||||||
|
"no_shares": "Non ancora condiviso.",
|
||||||
|
"add_person": "Aggiungi persona o gruppo",
|
||||||
|
"search_placeholder": "Cerca per nome o email…",
|
||||||
|
"loading_principals": "Caricamento utenti…",
|
||||||
|
"no_principals": "Nessun altro utente o gruppo trovato.",
|
||||||
|
"no_match": "Nessun risultato.",
|
||||||
|
"remove": "Rimuovi accesso",
|
||||||
|
"group": "Gruppo",
|
||||||
|
"share_added": "Accesso concesso",
|
||||||
|
"share_updated": "Accesso aggiornato",
|
||||||
|
"share_removed": "Accesso rimosso",
|
||||||
|
"share_failed": "Impossibile aggiornare la condivisione",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Solo libero/occupato",
|
||||||
|
"read": "Sola lettura",
|
||||||
|
"readWrite": "Lettura e scrittura",
|
||||||
|
"manager": "Gestore",
|
||||||
|
"custom": "Personalizzato"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "アドレス帳の名前を変更しました",
|
"renamed": "アドレス帳の名前を変更しました",
|
||||||
"rename_failed": "アドレス帳の名前変更に失敗しました",
|
"rename_failed": "アドレス帳の名前変更に失敗しました",
|
||||||
"default": "デフォルト",
|
"default": "デフォルト",
|
||||||
"manage": "アドレス帳を管理"
|
"manage": "アドレス帳を管理",
|
||||||
|
"share": "アドレス帳を共有",
|
||||||
|
"new_contact_in_book": "このアドレス帳に新規連絡先",
|
||||||
|
"delete": "アドレス帳を削除",
|
||||||
|
"confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。",
|
||||||
|
"deleted": "アドレス帳を削除しました",
|
||||||
|
"delete_failed": "アドレス帳の削除に失敗しました"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "メールアドレス",
|
"emails": "メールアドレス",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。",
|
"confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。",
|
||||||
"clear_events": "イベントを削除",
|
"clear_events": "イベントを削除",
|
||||||
"events_cleared": "{count}件のイベントを削除しました",
|
"events_cleared": "{count}件のイベントを削除しました",
|
||||||
"error_clear": "カレンダーイベントの削除に失敗しました"
|
"error_clear": "カレンダーイベントの削除に失敗しました",
|
||||||
|
"share": "カレンダーを共有",
|
||||||
|
"new_event_in_calendar": "このカレンダーに新規イベント"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "iCal購読",
|
"title": "iCal購読",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "統合ビューでは検索を利用できません"
|
"search_unavailable": "統合ビューでは検索を利用できません"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "「{name}」を共有",
|
||||||
|
"description": "このサーバー上の他のユーザーまたはグループにアクセス権を付与します。変更はすぐに反映されます。",
|
||||||
|
"no_shares": "まだ誰にも共有されていません。",
|
||||||
|
"add_person": "ユーザーまたはグループを追加",
|
||||||
|
"search_placeholder": "名前またはメールで検索…",
|
||||||
|
"loading_principals": "ユーザーを読み込み中…",
|
||||||
|
"no_principals": "他のユーザーまたはグループは見つかりません。",
|
||||||
|
"no_match": "一致する項目がありません。",
|
||||||
|
"remove": "アクセス権を削除",
|
||||||
|
"group": "グループ",
|
||||||
|
"share_added": "アクセス権を付与しました",
|
||||||
|
"share_updated": "アクセス権を更新しました",
|
||||||
|
"share_removed": "アクセス権を削除しました",
|
||||||
|
"share_failed": "共有の更新に失敗しました",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "空き時間情報のみ",
|
||||||
|
"read": "読み取り専用",
|
||||||
|
"readWrite": "読み取り・書き込み",
|
||||||
|
"manager": "管理者",
|
||||||
|
"custom": "カスタム"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "주소록 이름이 변경되었습니다",
|
"renamed": "주소록 이름이 변경되었습니다",
|
||||||
"rename_failed": "주소록 이름 변경 실패",
|
"rename_failed": "주소록 이름 변경 실패",
|
||||||
"default": "기본",
|
"default": "기본",
|
||||||
"manage": "주소록 관리"
|
"manage": "주소록 관리",
|
||||||
|
"share": "주소록 공유",
|
||||||
|
"new_contact_in_book": "이 주소록에 새 연락처",
|
||||||
|
"delete": "주소록 삭제",
|
||||||
|
"confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.",
|
||||||
|
"deleted": "주소록이 삭제되었습니다",
|
||||||
|
"delete_failed": "주소록 삭제에 실패했습니다"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "이메일",
|
"emails": "이메일",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"error_delete": "캘린더를 삭제하지 못했어요",
|
"error_delete": "캘린더를 삭제하지 못했어요",
|
||||||
"caldav_url": "CalDAV URL",
|
"caldav_url": "CalDAV URL",
|
||||||
"copy_url": "CalDAV URL 복사",
|
"copy_url": "CalDAV URL 복사",
|
||||||
"url_copied": "CalDAV URL이 클립보드에 복사되었어요"
|
"url_copied": "CalDAV URL이 클립보드에 복사되었어요",
|
||||||
|
"share": "캘린더 공유",
|
||||||
|
"new_event_in_calendar": "이 캘린더에 새 일정"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "iCal 구독",
|
"title": "iCal 구독",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다"
|
"search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "\"{name}\" 공유",
|
||||||
|
"description": "이 서버의 다른 사용자나 그룹에 액세스 권한을 부여합니다. 변경 사항은 즉시 적용됩니다.",
|
||||||
|
"no_shares": "아직 공유되지 않았습니다.",
|
||||||
|
"add_person": "사용자 또는 그룹 추가",
|
||||||
|
"search_placeholder": "이름 또는 이메일로 검색…",
|
||||||
|
"loading_principals": "사용자 불러오는 중…",
|
||||||
|
"no_principals": "다른 사용자나 그룹을 찾을 수 없습니다.",
|
||||||
|
"no_match": "일치하는 항목이 없습니다.",
|
||||||
|
"remove": "액세스 권한 제거",
|
||||||
|
"group": "그룹",
|
||||||
|
"share_added": "액세스 권한이 부여되었습니다",
|
||||||
|
"share_updated": "액세스 권한이 업데이트되었습니다",
|
||||||
|
"share_removed": "액세스 권한이 제거되었습니다",
|
||||||
|
"share_failed": "공유 업데이트에 실패했습니다",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "한가함/바쁨만",
|
||||||
|
"read": "읽기 전용",
|
||||||
|
"readWrite": "읽기 및 쓰기",
|
||||||
|
"manager": "관리자",
|
||||||
|
"custom": "사용자 지정"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1811,7 +1811,13 @@
|
|||||||
"renamed": "Adrešu grāmata pārdēvēta",
|
"renamed": "Adrešu grāmata pārdēvēta",
|
||||||
"rename_failed": "Neizdevās pārdēvēt adrešu grāmatu",
|
"rename_failed": "Neizdevās pārdēvēt adrešu grāmatu",
|
||||||
"default": "Noklusējuma",
|
"default": "Noklusējuma",
|
||||||
"manage": "Pārvaldīt adrešu grāmatas"
|
"manage": "Pārvaldīt adrešu grāmatas",
|
||||||
|
"share": "Kopīgot adrešu grāmatu",
|
||||||
|
"new_contact_in_book": "Jauns kontakts šajā adrešu grāmatā",
|
||||||
|
"delete": "Dzēst adrešu grāmatu",
|
||||||
|
"confirm_delete": "Dzēst \"{name}\"? Visi kontakti šajā adrešu grāmatā tiks noņemti.",
|
||||||
|
"deleted": "Adrešu grāmata dzēsta",
|
||||||
|
"delete_failed": "Neizdevās dzēst adrešu grāmatu"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-pasta adreses",
|
"emails": "E-pasta adreses",
|
||||||
@@ -2339,7 +2345,9 @@
|
|||||||
"error_delete": "Neizdevās izdzēst kalendāru",
|
"error_delete": "Neizdevās izdzēst kalendāru",
|
||||||
"caldav_url": "CalDAV URL",
|
"caldav_url": "CalDAV URL",
|
||||||
"copy_url": "Kopēt CalDAV URL",
|
"copy_url": "Kopēt CalDAV URL",
|
||||||
"url_copied": "CalDAV URL nokopēts starpliktuvē"
|
"url_copied": "CalDAV URL nokopēts starpliktuvē",
|
||||||
|
"share": "Kopīgot kalendāru",
|
||||||
|
"new_event_in_calendar": "Jauns notikums šajā kalendārā"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "iCal abonements",
|
"title": "iCal abonements",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "Meklēšana nav pieejama apvienotajā skatā"
|
"search_unavailable": "Meklēšana nav pieejama apvienotajā skatā"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Kopīgot \"{name}\"",
|
||||||
|
"description": "Piešķiriet piekļuvi citiem lietotājiem vai grupām šajā serverī. Izmaiņas stājas spēkā nekavējoties.",
|
||||||
|
"no_shares": "Vēl nav kopīgots.",
|
||||||
|
"add_person": "Pievienot personu vai grupu",
|
||||||
|
"search_placeholder": "Meklēt pēc vārda vai e-pasta…",
|
||||||
|
"loading_principals": "Ielādē lietotājus…",
|
||||||
|
"no_principals": "Citi lietotāji vai grupas nav atrastas.",
|
||||||
|
"no_match": "Nav atbilstību.",
|
||||||
|
"remove": "Noņemt piekļuvi",
|
||||||
|
"group": "Grupa",
|
||||||
|
"share_added": "Piekļuve piešķirta",
|
||||||
|
"share_updated": "Piekļuve atjaunināta",
|
||||||
|
"share_removed": "Piekļuve noņemta",
|
||||||
|
"share_failed": "Neizdevās atjaunināt kopīgošanu",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Tikai brīvs/aizņemts",
|
||||||
|
"read": "Tikai lasīšana",
|
||||||
|
"readWrite": "Lasīšana un rakstīšana",
|
||||||
|
"manager": "Pārvaldnieks",
|
||||||
|
"custom": "Pielāgots"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "Adresboek hernoemd",
|
"renamed": "Adresboek hernoemd",
|
||||||
"rename_failed": "Adresboek hernoemen mislukt",
|
"rename_failed": "Adresboek hernoemen mislukt",
|
||||||
"default": "Standaard",
|
"default": "Standaard",
|
||||||
"manage": "Adresboeken beheren"
|
"manage": "Adresboeken beheren",
|
||||||
|
"share": "Adresboek delen",
|
||||||
|
"new_contact_in_book": "Nieuw contact in dit adresboek",
|
||||||
|
"delete": "Adresboek verwijderen",
|
||||||
|
"confirm_delete": "\"{name}\" verwijderen? Alle contacten in dit adresboek worden verwijderd.",
|
||||||
|
"deleted": "Adresboek verwijderd",
|
||||||
|
"delete_failed": "Adresboek kon niet worden verwijderd"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-mailadressen",
|
"emails": "E-mailadressen",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
"confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||||
"clear_events": "Afspraken verwijderen",
|
"clear_events": "Afspraken verwijderen",
|
||||||
"events_cleared": "{count} afspraken verwijderd",
|
"events_cleared": "{count} afspraken verwijderd",
|
||||||
"error_clear": "Kan agendagebeurtenissen niet verwijderen"
|
"error_clear": "Kan agendagebeurtenissen niet verwijderen",
|
||||||
|
"share": "Agenda delen",
|
||||||
|
"new_event_in_calendar": "Nieuwe afspraak in deze agenda"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "iCal-abonnement",
|
"title": "iCal-abonnement",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave"
|
"search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "\"{name}\" delen",
|
||||||
|
"description": "Geef andere gebruikers of groepen op deze server toegang. Wijzigingen zijn direct van kracht.",
|
||||||
|
"no_shares": "Nog niet gedeeld.",
|
||||||
|
"add_person": "Persoon of groep toevoegen",
|
||||||
|
"search_placeholder": "Zoeken op naam of e-mail…",
|
||||||
|
"loading_principals": "Gebruikers laden…",
|
||||||
|
"no_principals": "Geen andere gebruikers of groepen gevonden.",
|
||||||
|
"no_match": "Geen overeenkomsten.",
|
||||||
|
"remove": "Toegang intrekken",
|
||||||
|
"group": "Groep",
|
||||||
|
"share_added": "Toegang verleend",
|
||||||
|
"share_updated": "Toegang bijgewerkt",
|
||||||
|
"share_removed": "Toegang ingetrokken",
|
||||||
|
"share_failed": "Delen kon niet worden bijgewerkt",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Alleen vrij/bezet",
|
||||||
|
"read": "Alleen lezen",
|
||||||
|
"readWrite": "Lezen en schrijven",
|
||||||
|
"manager": "Beheerder",
|
||||||
|
"custom": "Aangepast"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "Zmieniono nazwę książki adresowej",
|
"renamed": "Zmieniono nazwę książki adresowej",
|
||||||
"rename_failed": "Nie udało się zmienić nazwy książki adresowej",
|
"rename_failed": "Nie udało się zmienić nazwy książki adresowej",
|
||||||
"default": "Domyślna",
|
"default": "Domyślna",
|
||||||
"manage": "Zarządzaj książkami adresowymi"
|
"manage": "Zarządzaj książkami adresowymi",
|
||||||
|
"share": "Udostępnij książkę adresową",
|
||||||
|
"new_contact_in_book": "Nowy kontakt w tej książce adresowej",
|
||||||
|
"delete": "Usuń książkę adresową",
|
||||||
|
"confirm_delete": "Usunąć „{name}\"? Wszystkie kontakty w tej książce adresowej zostaną usunięte.",
|
||||||
|
"deleted": "Książka adresowa usunięta",
|
||||||
|
"delete_failed": "Nie udało się usunąć książki adresowej"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Adresy e-mail",
|
"emails": "Adresy e-mail",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"error_delete": "Nie udało się usunąć kalendarza",
|
"error_delete": "Nie udało się usunąć kalendarza",
|
||||||
"caldav_url": "Adres URL CalDAV",
|
"caldav_url": "Adres URL CalDAV",
|
||||||
"copy_url": "Kopiuj adres URL CalDAV",
|
"copy_url": "Kopiuj adres URL CalDAV",
|
||||||
"url_copied": "Adres URL CalDAV skopiowano do schowka"
|
"url_copied": "Adres URL CalDAV skopiowano do schowka",
|
||||||
|
"share": "Udostępnij kalendarz",
|
||||||
|
"new_event_in_calendar": "Nowe wydarzenie w tym kalendarzu"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Subskrypcja iCal",
|
"title": "Subskrypcja iCal",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym"
|
"search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Udostępnij „{name}\"",
|
||||||
|
"description": "Udziel dostępu innym użytkownikom lub grupom na tym serwerze. Zmiany są natychmiastowe.",
|
||||||
|
"no_shares": "Jeszcze nie udostępniono.",
|
||||||
|
"add_person": "Dodaj osobę lub grupę",
|
||||||
|
"search_placeholder": "Szukaj po imieniu lub e-mailu…",
|
||||||
|
"loading_principals": "Ładowanie użytkowników…",
|
||||||
|
"no_principals": "Nie znaleziono innych użytkowników ani grup.",
|
||||||
|
"no_match": "Brak wyników.",
|
||||||
|
"remove": "Usuń dostęp",
|
||||||
|
"group": "Grupa",
|
||||||
|
"share_added": "Dostęp przyznany",
|
||||||
|
"share_updated": "Dostęp zaktualizowany",
|
||||||
|
"share_removed": "Dostęp usunięty",
|
||||||
|
"share_failed": "Nie udało się zaktualizować udostępniania",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Tylko dostępność",
|
||||||
|
"read": "Tylko do odczytu",
|
||||||
|
"readWrite": "Odczyt i zapis",
|
||||||
|
"manager": "Menedżer",
|
||||||
|
"custom": "Niestandardowe"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "Catálogo de endereços renomeado",
|
"renamed": "Catálogo de endereços renomeado",
|
||||||
"rename_failed": "Falha ao renomear o catálogo de endereços",
|
"rename_failed": "Falha ao renomear o catálogo de endereços",
|
||||||
"default": "Padrão",
|
"default": "Padrão",
|
||||||
"manage": "Gerenciar catálogos de endereços"
|
"manage": "Gerenciar catálogos de endereços",
|
||||||
|
"share": "Compartilhar lista de contatos",
|
||||||
|
"new_contact_in_book": "Novo contato nesta lista",
|
||||||
|
"delete": "Excluir lista de contatos",
|
||||||
|
"confirm_delete": "Excluir \"{name}\"? Todos os contatos desta lista serão removidos.",
|
||||||
|
"deleted": "Lista de contatos excluída",
|
||||||
|
"delete_failed": "Falha ao excluir a lista de contatos"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Endereços de e-mail",
|
"emails": "Endereços de e-mail",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.",
|
"confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.",
|
||||||
"clear_events": "Limpar eventos",
|
"clear_events": "Limpar eventos",
|
||||||
"events_cleared": "{count} eventos removidos",
|
"events_cleared": "{count} eventos removidos",
|
||||||
"error_clear": "Falha ao limpar os eventos do calendário"
|
"error_clear": "Falha ao limpar os eventos do calendário",
|
||||||
|
"share": "Compartilhar calendário",
|
||||||
|
"new_event_in_calendar": "Novo evento neste calendário"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Assinatura iCal",
|
"title": "Assinatura iCal",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "A pesquisa não está disponível na vista unificada"
|
"search_unavailable": "A pesquisa não está disponível na vista unificada"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Compartilhar \"{name}\"",
|
||||||
|
"description": "Conceda acesso a outros usuários ou grupos neste servidor. As alterações têm efeito imediato.",
|
||||||
|
"no_shares": "Ainda não compartilhado.",
|
||||||
|
"add_person": "Adicionar pessoa ou grupo",
|
||||||
|
"search_placeholder": "Buscar por nome ou e-mail…",
|
||||||
|
"loading_principals": "Carregando usuários…",
|
||||||
|
"no_principals": "Nenhum outro usuário ou grupo encontrado.",
|
||||||
|
"no_match": "Sem resultados.",
|
||||||
|
"remove": "Remover acesso",
|
||||||
|
"group": "Grupo",
|
||||||
|
"share_added": "Acesso concedido",
|
||||||
|
"share_updated": "Acesso atualizado",
|
||||||
|
"share_removed": "Acesso removido",
|
||||||
|
"share_failed": "Falha ao atualizar o compartilhamento",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Apenas disponibilidade",
|
||||||
|
"read": "Somente leitura",
|
||||||
|
"readWrite": "Leitura e escrita",
|
||||||
|
"manager": "Gerente",
|
||||||
|
"custom": "Personalizado"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "Адресная книга переименована",
|
"renamed": "Адресная книга переименована",
|
||||||
"rename_failed": "Не удалось переименовать адресную книгу",
|
"rename_failed": "Не удалось переименовать адресную книгу",
|
||||||
"default": "По умолчанию",
|
"default": "По умолчанию",
|
||||||
"manage": "Управление адресными книгами"
|
"manage": "Управление адресными книгами",
|
||||||
|
"share": "Поделиться адресной книгой",
|
||||||
|
"new_contact_in_book": "Новый контакт в этой адресной книге",
|
||||||
|
"delete": "Удалить адресную книгу",
|
||||||
|
"confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.",
|
||||||
|
"deleted": "Адресная книга удалена",
|
||||||
|
"delete_failed": "Не удалось удалить адресную книгу"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Адреса электронной почты",
|
"emails": "Адреса электронной почты",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"error_delete": "Не удалось удалить календарь",
|
"error_delete": "Не удалось удалить календарь",
|
||||||
"caldav_url": "URL CalDAV",
|
"caldav_url": "URL CalDAV",
|
||||||
"copy_url": "Скопировать CalDAV URL",
|
"copy_url": "Скопировать CalDAV URL",
|
||||||
"url_copied": "CalDAV URL скопирован в буфер обмена"
|
"url_copied": "CalDAV URL скопирован в буфер обмена",
|
||||||
|
"share": "Поделиться календарём",
|
||||||
|
"new_event_in_calendar": "Новое событие в этом календаре"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Подписка iCal",
|
"title": "Подписка iCal",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "Поиск недоступен в объединённом представлении"
|
"search_unavailable": "Поиск недоступен в объединённом представлении"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Поделиться «{name}»",
|
||||||
|
"description": "Предоставьте доступ другим пользователям или группам на этом сервере. Изменения вступают в силу немедленно.",
|
||||||
|
"no_shares": "Пока никому не предоставлен доступ.",
|
||||||
|
"add_person": "Добавить пользователя или группу",
|
||||||
|
"search_placeholder": "Искать по имени или email…",
|
||||||
|
"loading_principals": "Загрузка пользователей…",
|
||||||
|
"no_principals": "Других пользователей или групп не найдено.",
|
||||||
|
"no_match": "Нет совпадений.",
|
||||||
|
"remove": "Отозвать доступ",
|
||||||
|
"group": "Группа",
|
||||||
|
"share_added": "Доступ предоставлен",
|
||||||
|
"share_updated": "Доступ обновлён",
|
||||||
|
"share_removed": "Доступ отозван",
|
||||||
|
"share_failed": "Не удалось обновить общий доступ",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Только занятость",
|
||||||
|
"read": "Только чтение",
|
||||||
|
"readWrite": "Чтение и запись",
|
||||||
|
"manager": "Управляющий",
|
||||||
|
"custom": "Пользовательский"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "Адресну книгу перейменовано",
|
"renamed": "Адресну книгу перейменовано",
|
||||||
"rename_failed": "Не вдалося перейменувати адресну книгу",
|
"rename_failed": "Не вдалося перейменувати адресну книгу",
|
||||||
"default": "За замовчуванням",
|
"default": "За замовчуванням",
|
||||||
"manage": "Керуйте адресними книгами"
|
"manage": "Керуйте адресними книгами",
|
||||||
|
"share": "Поділитися адресною книгою",
|
||||||
|
"new_contact_in_book": "Новий контакт у цій адресній книзі",
|
||||||
|
"delete": "Видалити адресну книгу",
|
||||||
|
"confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.",
|
||||||
|
"deleted": "Адресну книгу видалено",
|
||||||
|
"delete_failed": "Не вдалося видалити адресну книгу"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Адреси електронної пошти",
|
"emails": "Адреси електронної пошти",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"error_delete": "Не вдалося видалити календар",
|
"error_delete": "Не вдалося видалити календар",
|
||||||
"caldav_url": "URL-адреса CalDAV",
|
"caldav_url": "URL-адреса CalDAV",
|
||||||
"copy_url": "Скопіюйте URL-адресу CalDAV",
|
"copy_url": "Скопіюйте URL-адресу CalDAV",
|
||||||
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну"
|
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну",
|
||||||
|
"share": "Поділитися календарем",
|
||||||
|
"new_event_in_calendar": "Нова подія в цьому календарі"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Підписка iCal",
|
"title": "Підписка iCal",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "Пошук недоступний в об'єднаному перегляді"
|
"search_unavailable": "Пошук недоступний в об'єднаному перегляді"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Поділитися «{name}»",
|
||||||
|
"description": "Надайте доступ іншим користувачам або групам на цьому сервері. Зміни набувають чинності негайно.",
|
||||||
|
"no_shares": "Поки що ні з ким не поділено.",
|
||||||
|
"add_person": "Додати людину або групу",
|
||||||
|
"search_placeholder": "Шукати за іменем або email…",
|
||||||
|
"loading_principals": "Завантаження користувачів…",
|
||||||
|
"no_principals": "Інших користувачів або груп не знайдено.",
|
||||||
|
"no_match": "Збігів немає.",
|
||||||
|
"remove": "Видалити доступ",
|
||||||
|
"group": "Група",
|
||||||
|
"share_added": "Доступ надано",
|
||||||
|
"share_updated": "Доступ оновлено",
|
||||||
|
"share_removed": "Доступ видалено",
|
||||||
|
"share_failed": "Не вдалося оновити спільний доступ",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "Лише зайнятість",
|
||||||
|
"read": "Лише читання",
|
||||||
|
"readWrite": "Читання та запис",
|
||||||
|
"manager": "Керівник",
|
||||||
|
"custom": "Власне"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -1815,7 +1815,13 @@
|
|||||||
"renamed": "地址簿已重命名",
|
"renamed": "地址簿已重命名",
|
||||||
"rename_failed": "重命名地址簿失败",
|
"rename_failed": "重命名地址簿失败",
|
||||||
"default": "默认",
|
"default": "默认",
|
||||||
"manage": "管理地址簿"
|
"manage": "管理地址簿",
|
||||||
|
"share": "共享通讯录",
|
||||||
|
"new_contact_in_book": "在此通讯录中新建联系人",
|
||||||
|
"delete": "删除通讯录",
|
||||||
|
"confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。",
|
||||||
|
"deleted": "通讯录已删除",
|
||||||
|
"delete_failed": "删除通讯录失败"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "邮箱地址",
|
"emails": "邮箱地址",
|
||||||
@@ -2340,7 +2346,9 @@
|
|||||||
"error_delete": "删除日历失败",
|
"error_delete": "删除日历失败",
|
||||||
"caldav_url": "CalDAV URL",
|
"caldav_url": "CalDAV URL",
|
||||||
"copy_url": "复制 CalDAV URL",
|
"copy_url": "复制 CalDAV URL",
|
||||||
"url_copied": "CalDAV URL 已复制到剪贴板"
|
"url_copied": "CalDAV URL 已复制到剪贴板",
|
||||||
|
"share": "共享日历",
|
||||||
|
"new_event_in_calendar": "在此日历中新建事件"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "iCal 订阅",
|
"title": "iCal 订阅",
|
||||||
@@ -2706,5 +2714,28 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"search_unavailable": "统一视图中无法使用搜索"
|
"search_unavailable": "统一视图中无法使用搜索"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "共享「{name}」",
|
||||||
|
"description": "向此服务器上的其他用户或群组授予访问权限。更改会立即生效。",
|
||||||
|
"no_shares": "尚未共享。",
|
||||||
|
"add_person": "添加用户或群组",
|
||||||
|
"search_placeholder": "按姓名或邮箱搜索…",
|
||||||
|
"loading_principals": "正在加载用户…",
|
||||||
|
"no_principals": "未找到其他用户或群组。",
|
||||||
|
"no_match": "无匹配项。",
|
||||||
|
"remove": "取消访问",
|
||||||
|
"group": "群组",
|
||||||
|
"share_added": "已授予访问权限",
|
||||||
|
"share_updated": "已更新访问权限",
|
||||||
|
"share_removed": "已取消访问权限",
|
||||||
|
"share_failed": "更新共享失败",
|
||||||
|
"preset": {
|
||||||
|
"freeBusy": "仅显示忙/闲",
|
||||||
|
"read": "只读",
|
||||||
|
"readWrite": "读写",
|
||||||
|
"manager": "管理员",
|
||||||
|
"custom": "自定义"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from '@/lib/jmap/types';
|
||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||||
import { parseDuration } from '@/components/calendar/event-card';
|
import { parseDuration } from '@/components/calendar/event-card';
|
||||||
@@ -125,6 +125,7 @@ interface CalendarStore {
|
|||||||
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
|
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
|
||||||
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||||
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
|
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
|
||||||
|
shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
|
||||||
createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
|
createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
|
||||||
removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
|
removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
|
||||||
clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise<number>;
|
clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise<number>;
|
||||||
@@ -653,6 +654,29 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
shareCalendar: async (client, calendarId, principalId, rights) => {
|
||||||
|
set({ error: null });
|
||||||
|
try {
|
||||||
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
|
const realId = cal?.originalId || calendarId;
|
||||||
|
const targetAccountId = cal?.accountId;
|
||||||
|
await client.setCalendarShare(realId, principalId, rights, targetAccountId);
|
||||||
|
set((state) => ({
|
||||||
|
calendars: state.calendars.map(c => {
|
||||||
|
if (c.id !== calendarId) return c;
|
||||||
|
const next = { ...(c.shareWith ?? {}) };
|
||||||
|
if (rights === null) delete next[principalId];
|
||||||
|
else next[principalId] = rights;
|
||||||
|
return { ...c, shareWith: next };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to share calendar:', error);
|
||||||
|
set({ error: 'Failed to share calendar' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
createCalendar: async (client, calendar) => {
|
createCalendar: async (client, calendar) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
|
|||||||
+42
-1
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
import type { ContactCard, AddressBook, AddressBookRights, ContactName } from '@/lib/jmap/types';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { generateUUID } from '@/lib/utils';
|
import { generateUUID } from '@/lib/utils';
|
||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
@@ -101,6 +101,8 @@ interface ContactStore {
|
|||||||
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
||||||
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
||||||
renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise<void>;
|
renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise<void>;
|
||||||
|
removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise<void>;
|
||||||
|
shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise<void>;
|
||||||
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
|
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
|
||||||
|
|
||||||
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
||||||
@@ -655,6 +657,45 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
removeAddressBook: async (client, addressBook) => {
|
||||||
|
set({ error: null });
|
||||||
|
try {
|
||||||
|
const originalId = addressBook.originalId || addressBook.id;
|
||||||
|
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||||
|
await client.deleteAddressBook(originalId, accountId);
|
||||||
|
set((state) => ({
|
||||||
|
addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id),
|
||||||
|
contacts: state.contacts.filter(c => !c.addressBookIds?.[addressBook.id]),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Failed to delete address book';
|
||||||
|
set({ error: msg });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
shareAddressBook: async (client, addressBook, principalId, rights) => {
|
||||||
|
set({ error: null });
|
||||||
|
try {
|
||||||
|
const originalId = addressBook.originalId || addressBook.id;
|
||||||
|
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||||
|
await client.setAddressBookShare(originalId, principalId, rights, accountId);
|
||||||
|
set((state) => ({
|
||||||
|
addressBooks: state.addressBooks.map(b => {
|
||||||
|
if (b.id !== addressBook.id) return b;
|
||||||
|
const next = { ...(b.shareWith ?? {}) };
|
||||||
|
if (rights === null) delete next[principalId];
|
||||||
|
else next[principalId] = rights;
|
||||||
|
return { ...b, shareWith: next };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Failed to share address book';
|
||||||
|
set({ error: msg });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
renameKeyword: async (client, oldKeyword, newKeyword) => {
|
renameKeyword: async (client, oldKeyword, newKeyword) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
const oldKw = oldKeyword.trim();
|
const oldKw = oldKeyword.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user