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 { useContactStore } from "@/stores/contact-store";
|
||||
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 { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -72,7 +76,8 @@ export default function CalendarPage() {
|
||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||
removeCalendar, clearCalendarEvents,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
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 [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
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 [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||
@@ -1102,6 +1112,42 @@ export default function CalendarPage() {
|
||||
}
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : 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)}
|
||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||
client={client}
|
||||
@@ -1165,11 +1211,12 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||
onPreviewChange={setPendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={false}
|
||||
@@ -1261,11 +1308,12 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={true}
|
||||
/>
|
||||
@@ -1305,6 +1353,33 @@ export default function CalendarPage() {
|
||||
onSelect={handleScopeSelect}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
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 =
|
||||
| "list"
|
||||
@@ -75,6 +76,8 @@ export default function ContactsPage() {
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
renameAddressBook,
|
||||
removeAddressBook,
|
||||
shareAddressBook,
|
||||
renameKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
@@ -83,6 +86,8 @@ export default function ContactsPage() {
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
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 [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
@@ -329,6 +334,7 @@ export default function ContactsPage() {
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
setView("list");
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
|
||||
@@ -346,6 +352,7 @@ export default function ContactsPage() {
|
||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
if (view === "group-create" || view === "group-edit") {
|
||||
setView(selectedGroup ? "group-detail" : "list");
|
||||
} else if (view === "bulk-add-to-group") {
|
||||
@@ -517,7 +524,7 @@ export default function ContactsPage() {
|
||||
const renderRightPanel = () => {
|
||||
switch (view) {
|
||||
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":
|
||||
if (!selectedContact) return null;
|
||||
@@ -690,6 +697,26 @@ export default function ContactsPage() {
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
@@ -838,6 +865,23 @@ export default function ContactsPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
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 type { Calendar } from "@/lib/jmap/types";
|
||||
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 { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
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';
|
||||
|
||||
interface CalendarSidebarPanelProps {
|
||||
@@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps {
|
||||
selectedCalendarIds: string[];
|
||||
onToggleVisibility: (id: 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;
|
||||
onEditSubscription?: (subscriptionId: string) => void;
|
||||
client?: IJMAPClient | null;
|
||||
@@ -28,12 +35,18 @@ export function CalendarSidebarPanel({
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
onColorChange,
|
||||
onShareCalendar,
|
||||
onCreateEvent,
|
||||
onClearCalendar,
|
||||
onDeleteCalendar,
|
||||
onCreateCalendar,
|
||||
onSubscribe,
|
||||
onEditSubscription,
|
||||
client,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tSub = useTranslations("calendar.subscription");
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||
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;
|
||||
}, [tasks]);
|
||||
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||
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 sharedAccountGroups = useMemo(() => {
|
||||
@@ -69,30 +79,6 @@ export function CalendarSidebarPanel({
|
||||
return Array.from(groups.values());
|
||||
}, [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) => {
|
||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||
};
|
||||
@@ -100,7 +86,6 @@ export function CalendarSidebarPanel({
|
||||
const handleRefreshSubscription = async (subId: string) => {
|
||||
if (!client) return;
|
||||
setRefreshingSubId(subId);
|
||||
setContextMenuCalId(null);
|
||||
try {
|
||||
await refreshICalSubscription(client, subId);
|
||||
toast.success(tSub('refresh_success'));
|
||||
@@ -113,7 +98,6 @@ export function CalendarSidebarPanel({
|
||||
|
||||
const handleUnsubscribe = async (subId: string) => {
|
||||
if (!client) return;
|
||||
setContextMenuCalId(null);
|
||||
try {
|
||||
await removeICalSubscription(client, subId);
|
||||
toast.success(tSub('deleted'));
|
||||
@@ -127,21 +111,13 @@ export function CalendarSidebarPanel({
|
||||
const renderCalendarItem = (cal: Calendar) => {
|
||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||
const color = cal.color || "#3b82f6";
|
||||
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||
|
||||
return (
|
||||
<div key={cal.id} className="relative">
|
||||
<button
|
||||
onClick={() => onToggleVisibility(cal.id)}
|
||||
onContextMenu={(e) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||
"hover:bg-muted"
|
||||
@@ -169,70 +145,101 @@ export function CalendarSidebarPanel({
|
||||
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="mt-4">
|
||||
{enableCalendarTasks && (
|
||||
@@ -250,9 +257,22 @@ export function CalendarSidebarPanel({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||
{t("my_calendars")}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||
{onCreateCalendar ? (
|
||||
<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">
|
||||
{personalCalendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{renderCalendarMenu()}
|
||||
</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[];
|
||||
defaultDate?: Date;
|
||||
defaultEndDate?: Date;
|
||||
defaultCalendarId?: string;
|
||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||
@@ -113,6 +114,7 @@ export function EventModal({
|
||||
calendars,
|
||||
defaultDate,
|
||||
defaultEndDate,
|
||||
defaultCalendarId,
|
||||
onSave,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
@@ -200,6 +202,7 @@ export function EventModal({
|
||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||
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);
|
||||
return defaultCal?.id || calendars[0]?.id || "";
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ interface ContactFormProps {
|
||||
contact?: ContactCard | null;
|
||||
addressBooks?: AddressBook[];
|
||||
allKeywords?: string[];
|
||||
defaultAddressBookId?: string;
|
||||
onSave: (data: Partial<ContactCard>) => Promise<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 isEditing = !!contact;
|
||||
|
||||
@@ -330,8 +331,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
return ids[0];
|
||||
}
|
||||
}
|
||||
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
|
||||
return defaultAddressBookId;
|
||||
}
|
||||
return "";
|
||||
}, [contact]);
|
||||
}, [contact, defaultAddressBookId, addressBooks]);
|
||||
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||
|
||||
const initialPhotoEntry = useMemo(() => {
|
||||
|
||||
@@ -27,6 +27,9 @@ interface ContactsSidebarProps {
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||
onShareAddressBook?: (addressBook: AddressBook) => void;
|
||||
onCreateContactInBook?: (addressBook: AddressBook) => void;
|
||||
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||
onRenameKeyword?: (keyword: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -62,6 +65,9 @@ export function ContactsSidebar({
|
||||
onDropContacts,
|
||||
onDropContactsToCategory,
|
||||
onRenameAddressBook,
|
||||
onShareAddressBook,
|
||||
onCreateContactInBook,
|
||||
onDeleteAddressBook,
|
||||
onRenameKeyword,
|
||||
className,
|
||||
}: ContactsSidebarProps) {
|
||||
@@ -288,7 +294,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -430,7 +436,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -438,24 +444,65 @@ export function ContactsSidebar({
|
||||
</div>
|
||||
|
||||
{/* Address book context menu */}
|
||||
{bookContextMenu.data && onRenameAddressBook && (
|
||||
<ContextMenu
|
||||
ref={bookMenuRef}
|
||||
isOpen={bookContextMenu.isOpen}
|
||||
position={bookContextMenu.position}
|
||||
onClose={closeBookContextMenu}
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("address_books.rename")}
|
||||
onClick={() => {
|
||||
const book = bookContextMenu.data!;
|
||||
closeBookContextMenu();
|
||||
onRenameAddressBook(book);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
|
||||
const book = bookContextMenu.data;
|
||||
const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
|
||||
const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
|
||||
const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
|
||||
const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
|
||||
const showSeparator = (canCreate || canRename || canShare) && canDelete;
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={bookMenuRef}
|
||||
isOpen={bookContextMenu.isOpen}
|
||||
position={bookContextMenu.position}
|
||||
onClose={closeBookContextMenu}
|
||||
>
|
||||
{canCreate && (
|
||||
<ContextMenuItem
|
||||
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 */}
|
||||
{keywordContextMenu.data && onRenameKeyword && (
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
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 { useAuthStore } from "@/stores/auth-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
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({
|
||||
initial,
|
||||
@@ -70,9 +71,10 @@ export function AddressBookManagementSettings() {
|
||||
const tContacts = useTranslations("contacts");
|
||||
const tSettings = useTranslations("settings.contacts");
|
||||
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 [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,6 +150,16 @@ export function AddressBookManagementSettings() {
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</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>
|
||||
);
|
||||
@@ -242,6 +254,24 @@ export function AddressBookManagementSettings() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</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 { toast } from '@/stores/toast-store';
|
||||
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 { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||
@@ -83,7 +85,7 @@ function CalendarColorPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEditForm({
|
||||
export function CalendarEditForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
@@ -153,7 +155,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
|
||||
export function CalendarManagementSettings() {
|
||||
const t = useTranslations('calendar.management');
|
||||
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 [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
|
||||
@@ -164,6 +166,7 @@ export function CalendarManagementSettings() {
|
||||
const [clearingId, setClearingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
||||
@@ -522,6 +525,16 @@ export function CalendarManagementSettings() {
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => setClearingId(cal.id)}
|
||||
@@ -689,6 +702,24 @@ export function CalendarManagementSettings() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayAdmin: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
...overrides,
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createBirthdayCalendar(name?: string, color?: string): Calendar
|
||||
mayWriteOwn: false,
|
||||
mayUpdatePrivate: false,
|
||||
mayRSVP: false,
|
||||
mayAdmin: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
};
|
||||
|
||||
+10
-1
@@ -78,6 +78,10 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
supportsCalendars(): boolean { return true; }
|
||||
supportsSieve(): 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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -552,6 +556,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
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[]> {
|
||||
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||
return [...this.data.contacts];
|
||||
@@ -609,7 +618,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
includeInAvailability: 'all',
|
||||
defaultAlertsWithTime: null, defaultAlertsWithoutTime: 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,
|
||||
} as Calendar;
|
||||
this.data.calendars.push(full);
|
||||
|
||||
@@ -17,7 +17,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: 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',
|
||||
@@ -33,7 +33,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: 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 },
|
||||
},
|
||||
{
|
||||
id: 'demo-calendar-birthdays',
|
||||
@@ -49,7 +49,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: 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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
@@ -190,6 +190,7 @@ export interface IJMAPClient {
|
||||
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||
createAddressBook(name: string): Promise<AddressBook>;
|
||||
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
||||
deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void>;
|
||||
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||
getAllContacts(): Promise<ContactCard[]>;
|
||||
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>;
|
||||
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 ──────────────────────────────────────────
|
||||
getSieveAccountId(): string;
|
||||
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 { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -2826,6 +2826,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
return this.hasCapability("urn:ietf:params:jmap:sieve");
|
||||
}
|
||||
|
||||
supportsPrincipals(): boolean {
|
||||
return this.hasCapability("urn:ietf:params:jmap:principals");
|
||||
}
|
||||
|
||||
getSieveAccountId(): string {
|
||||
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
|
||||
return sieveAccount || this.accountId;
|
||||
@@ -3198,6 +3202,102 @@ export class JMAPClient implements IJMAPClient {
|
||||
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(
|
||||
accountId: string,
|
||||
filter?: Record<string, unknown>,
|
||||
|
||||
+15
-2
@@ -368,6 +368,7 @@ export interface AddressBook {
|
||||
isDefault?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
myRights?: AddressBookRights;
|
||||
shareWith?: Record<string, AddressBookRights> | null;
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
@@ -376,10 +377,22 @@ export interface AddressBook {
|
||||
export interface AddressBookRights {
|
||||
mayRead: boolean;
|
||||
mayWrite: boolean;
|
||||
mayShare: boolean;
|
||||
mayShare?: 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 {
|
||||
id: string;
|
||||
isEnabled: boolean;
|
||||
@@ -442,7 +455,7 @@ export interface CalendarRights {
|
||||
mayWriteOwn: boolean;
|
||||
mayUpdatePrivate: boolean;
|
||||
mayRSVP: boolean;
|
||||
mayAdmin: boolean;
|
||||
mayShare: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
|
||||
+44
-13
@@ -426,7 +426,7 @@
|
||||
"event_updated": "Aktualizace #{sequence}",
|
||||
"event_status_tentative": "Nezávazně",
|
||||
"event_status_cancelled": "Zrušeno",
|
||||
"expand": "Zobrazit detaily",
|
||||
"expand": "Zobrazit detaily",
|
||||
"collapse": "Skrýt detaily"
|
||||
},
|
||||
"send": "Odeslat",
|
||||
@@ -675,7 +675,7 @@
|
||||
"encryption": "Šifrování",
|
||||
"sidebar_apps": "Aplikace postranního panelu",
|
||||
"notifications": "Oznámení",
|
||||
"layout": "Vzhled",
|
||||
"layout": "Vzhled",
|
||||
"reading": "Čtení",
|
||||
"composing": "Psaní",
|
||||
"content_senders": "Obsah a odesílatelé",
|
||||
@@ -688,7 +688,7 @@
|
||||
"organization": "Organizace pošty",
|
||||
"apps": "Aplikace",
|
||||
"system": "Systém",
|
||||
"appearance": "Vzhled",
|
||||
"appearance": "Vzhled",
|
||||
"mail": "Pošta",
|
||||
"privacy": "Soukromí a zabezpečení",
|
||||
"advanced": "Pokročilé"
|
||||
@@ -1094,7 +1094,7 @@
|
||||
"enable_error": "Nepodařilo se zapnout 2FA",
|
||||
"disable_error": "Nepodařilo se vypnout 2FA",
|
||||
"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",
|
||||
"disable": "Vypnout",
|
||||
"disable_confirm_prompt": "Pro vypnutí dvoufázového ověření zadejte heslo.",
|
||||
@@ -1327,7 +1327,7 @@
|
||||
"contacts": {
|
||||
"title": "Kontakty",
|
||||
"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ů",
|
||||
"import_label": "Importovat kontakty",
|
||||
"import_description": "Importovat kontakty ze souboru vCard (.vcf)",
|
||||
@@ -1595,7 +1595,7 @@
|
||||
"items_selected": "{count} vybraných zpráv",
|
||||
"edit_draft": "Upravit koncept"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mailbox_context_menu": {
|
||||
"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_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_folder": "Zadejte název nové 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",
|
||||
"rename_confirm": "Přejmenovat",
|
||||
"toast_marked_read": "Složka označena jako přečtená",
|
||||
"rename_confirm": "Přejmenovat",
|
||||
"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_already_read": "Žádné nepřečtené zprávy",
|
||||
"toast_marked_all_read": "Všechny složky označeny jako přečtené",
|
||||
@@ -1820,7 +1820,13 @@
|
||||
"renamed": "Adresář byl přejmenován",
|
||||
"rename_failed": "Přejmenování adresáře selhalo",
|
||||
"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": {
|
||||
"emails": "E-mailové adresy",
|
||||
@@ -1995,7 +2001,7 @@
|
||||
"email_error_inline": "Neplatný formát e-mailové adresy",
|
||||
"save_failed": "Uložení kontaktu selhalo",
|
||||
"delete": "Odstranit",
|
||||
"upload_photo": "Nahrát fotku",
|
||||
"upload_photo": "Nahrát fotku",
|
||||
"remove_photo": "Odebrat fotku",
|
||||
"photo_hint": "JPG nebo PNG, max. 10 MB. Velikost se upraví.",
|
||||
"photo_too_large": "Obrázek je moc velký (max. 10 MB)",
|
||||
@@ -2091,7 +2097,7 @@
|
||||
"has_email": "Má e-mail",
|
||||
"has_phone": "Má telefon",
|
||||
"has_photo": "Má fotku"
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalendář",
|
||||
@@ -2345,7 +2351,9 @@
|
||||
"error_delete": "Odstranění kalendáře selhalo",
|
||||
"caldav_url": "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": {
|
||||
"title": "Odběr iCal",
|
||||
@@ -2711,5 +2719,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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",
|
||||
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
|
||||
"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": {
|
||||
"emails": "E-Mail-Adressen",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"clear_events": "Ereignisse löschen",
|
||||
"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": {
|
||||
"title": "iCal-Abonnement",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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",
|
||||
"rename_failed": "Failed to rename address book",
|
||||
"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": {
|
||||
"emails": "Email Addresses",
|
||||
@@ -2344,7 +2350,9 @@
|
||||
"error_delete": "Failed to delete calendar",
|
||||
"caldav_url": "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": {
|
||||
"title": "iCal Subscription",
|
||||
@@ -2426,6 +2434,29 @@
|
||||
"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": {
|
||||
"title": "Advanced Search",
|
||||
"from": "From",
|
||||
|
||||
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "Libreta de direcciones renombrada",
|
||||
"rename_failed": "Error al renombrar la libreta de direcciones",
|
||||
"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": {
|
||||
"emails": "Direcciones de correo",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.",
|
||||
"clear_events": "Borrar eventos",
|
||||
"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": {
|
||||
"title": "Suscripción iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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é",
|
||||
"rename_failed": "Échec du renommage du carnet d'adresses",
|
||||
"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": {
|
||||
"emails": "Adresses e-mail",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.",
|
||||
"clear_events": "Supprimer les événements",
|
||||
"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": {
|
||||
"title": "Abonnement iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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",
|
||||
"rename_failed": "Impossibile rinominare la rubrica",
|
||||
"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": {
|
||||
"emails": "Indirizzi email",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.",
|
||||
"clear_events": "Cancella eventi",
|
||||
"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": {
|
||||
"title": "Abbonamento iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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": "アドレス帳の名前を変更しました",
|
||||
"rename_failed": "アドレス帳の名前変更に失敗しました",
|
||||
"default": "デフォルト",
|
||||
"manage": "アドレス帳を管理"
|
||||
"manage": "アドレス帳を管理",
|
||||
"share": "アドレス帳を共有",
|
||||
"new_contact_in_book": "このアドレス帳に新規連絡先",
|
||||
"delete": "アドレス帳を削除",
|
||||
"confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。",
|
||||
"deleted": "アドレス帳を削除しました",
|
||||
"delete_failed": "アドレス帳の削除に失敗しました"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "メールアドレス",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。",
|
||||
"clear_events": "イベントを削除",
|
||||
"events_cleared": "{count}件のイベントを削除しました",
|
||||
"error_clear": "カレンダーイベントの削除に失敗しました"
|
||||
"error_clear": "カレンダーイベントの削除に失敗しました",
|
||||
"share": "カレンダーを共有",
|
||||
"new_event_in_calendar": "このカレンダーに新規イベント"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal購読",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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": "주소록 이름이 변경되었습니다",
|
||||
"rename_failed": "주소록 이름 변경 실패",
|
||||
"default": "기본",
|
||||
"manage": "주소록 관리"
|
||||
"manage": "주소록 관리",
|
||||
"share": "주소록 공유",
|
||||
"new_contact_in_book": "이 주소록에 새 연락처",
|
||||
"delete": "주소록 삭제",
|
||||
"confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.",
|
||||
"deleted": "주소록이 삭제되었습니다",
|
||||
"delete_failed": "주소록 삭제에 실패했습니다"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "이메일",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"error_delete": "캘린더를 삭제하지 못했어요",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "CalDAV URL 복사",
|
||||
"url_copied": "CalDAV URL이 클립보드에 복사되었어요"
|
||||
"url_copied": "CalDAV URL이 클립보드에 복사되었어요",
|
||||
"share": "캘린더 공유",
|
||||
"new_event_in_calendar": "이 캘린더에 새 일정"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal 구독",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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",
|
||||
"rename_failed": "Neizdevās pārdēvēt adrešu grāmatu",
|
||||
"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": {
|
||||
"emails": "E-pasta adreses",
|
||||
@@ -2339,7 +2345,9 @@
|
||||
"error_delete": "Neizdevās izdzēst kalendāru",
|
||||
"caldav_url": "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": {
|
||||
"title": "iCal abonements",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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",
|
||||
"rename_failed": "Adresboek hernoemen mislukt",
|
||||
"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": {
|
||||
"emails": "E-mailadressen",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"clear_events": "Afspraken verwijderen",
|
||||
"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": {
|
||||
"title": "iCal-abonnement",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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",
|
||||
"rename_failed": "Nie udało się zmienić nazwy książki adresowej",
|
||||
"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": {
|
||||
"emails": "Adresy e-mail",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"error_delete": "Nie udało się usunąć kalendarza",
|
||||
"caldav_url": "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": {
|
||||
"title": "Subskrypcja iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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",
|
||||
"rename_failed": "Falha ao renomear o catálogo de endereços",
|
||||
"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": {
|
||||
"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.",
|
||||
"clear_events": "Limpar eventos",
|
||||
"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": {
|
||||
"title": "Assinatura iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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": "Адресная книга переименована",
|
||||
"rename_failed": "Не удалось переименовать адресную книгу",
|
||||
"default": "По умолчанию",
|
||||
"manage": "Управление адресными книгами"
|
||||
"manage": "Управление адресными книгами",
|
||||
"share": "Поделиться адресной книгой",
|
||||
"new_contact_in_book": "Новый контакт в этой адресной книге",
|
||||
"delete": "Удалить адресную книгу",
|
||||
"confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.",
|
||||
"deleted": "Адресная книга удалена",
|
||||
"delete_failed": "Не удалось удалить адресную книгу"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреса электронной почты",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"error_delete": "Не удалось удалить календарь",
|
||||
"caldav_url": "URL CalDAV",
|
||||
"copy_url": "Скопировать CalDAV URL",
|
||||
"url_copied": "CalDAV URL скопирован в буфер обмена"
|
||||
"url_copied": "CalDAV URL скопирован в буфер обмена",
|
||||
"share": "Поделиться календарём",
|
||||
"new_event_in_calendar": "Новое событие в этом календаре"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Подписка iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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": "Адресну книгу перейменовано",
|
||||
"rename_failed": "Не вдалося перейменувати адресну книгу",
|
||||
"default": "За замовчуванням",
|
||||
"manage": "Керуйте адресними книгами"
|
||||
"manage": "Керуйте адресними книгами",
|
||||
"share": "Поділитися адресною книгою",
|
||||
"new_contact_in_book": "Новий контакт у цій адресній книзі",
|
||||
"delete": "Видалити адресну книгу",
|
||||
"confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.",
|
||||
"deleted": "Адресну книгу видалено",
|
||||
"delete_failed": "Не вдалося видалити адресну книгу"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреси електронної пошти",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"error_delete": "Не вдалося видалити календар",
|
||||
"caldav_url": "URL-адреса CalDAV",
|
||||
"copy_url": "Скопіюйте URL-адресу CalDAV",
|
||||
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну"
|
||||
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну",
|
||||
"share": "Поділитися календарем",
|
||||
"new_event_in_calendar": "Нова подія в цьому календарі"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Підписка iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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": "地址簿已重命名",
|
||||
"rename_failed": "重命名地址簿失败",
|
||||
"default": "默认",
|
||||
"manage": "管理地址簿"
|
||||
"manage": "管理地址簿",
|
||||
"share": "共享通讯录",
|
||||
"new_contact_in_book": "在此通讯录中新建联系人",
|
||||
"delete": "删除通讯录",
|
||||
"confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。",
|
||||
"deleted": "通讯录已删除",
|
||||
"delete_failed": "删除通讯录失败"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "邮箱地址",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"error_delete": "删除日历失败",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "复制 CalDAV URL",
|
||||
"url_copied": "CalDAV URL 已复制到剪贴板"
|
||||
"url_copied": "CalDAV URL 已复制到剪贴板",
|
||||
"share": "共享日历",
|
||||
"new_event_in_calendar": "在此日历中新建事件"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal 订阅",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"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 { persist } from 'zustand/middleware';
|
||||
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 { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||
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>;
|
||||
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
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>;
|
||||
removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
|
||||
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) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
|
||||
+42
-1
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
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 { generateUUID } from '@/lib/utils';
|
||||
import { debug } from '@/lib/debug';
|
||||
@@ -101,6 +101,8 @@ interface ContactStore {
|
||||
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
||||
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => 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>;
|
||||
|
||||
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) => {
|
||||
set({ error: null });
|
||||
const oldKw = oldKeyword.trim();
|
||||
|
||||
Reference in New Issue
Block a user