Feature: per-viewer colors for shared calendars (#345)
This commit is contained in:
@@ -60,6 +60,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
|||||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
|
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
||||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||||
@@ -97,6 +98,9 @@ export default function CalendarPage() {
|
|||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
|
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||||
|
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
@@ -1030,10 +1034,47 @@ export default function CalendarPage() {
|
|||||||
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Apply each shared calendar's local color override (per-viewer recolor,
|
||||||
|
// #345). The override replaces the calendar's color and wins over per-event
|
||||||
|
// colors via the `colorIsLocalOverride` flag (see getEventColor). Personal
|
||||||
|
// calendars are passed through untouched.
|
||||||
|
const displayCalendars = useMemo(() => {
|
||||||
|
return calendars.map((cal) => {
|
||||||
|
if (!cal.isShared) return cal;
|
||||||
|
const override = sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||||
|
if (!override) return cal;
|
||||||
|
return { ...cal, color: override, colorIsLocalOverride: true };
|
||||||
|
});
|
||||||
|
}, [calendars, sharedCalendarColors]);
|
||||||
|
|
||||||
|
// Auto-assign a random, not-yet-used palette color to any freshly shared
|
||||||
|
// calendar so multiple shared calendars don't collide on one color. Runs
|
||||||
|
// once per calendar (guarded by the presence of an existing key), and the
|
||||||
|
// user can still overwrite it from the sidebar.
|
||||||
|
useEffect(() => {
|
||||||
|
const shared = calendars.filter((c) => c.isShared);
|
||||||
|
const missing = shared.filter((c) => !sharedCalendarColors[sharedCalendarColorKey(c)]);
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
// Seed "used" with personal calendar colors plus already-assigned shared
|
||||||
|
// overrides so the picks stay distinct from what's already on screen.
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const c of calendars) {
|
||||||
|
if (!c.isShared && c.color) used.add(c.color.toLowerCase());
|
||||||
|
}
|
||||||
|
for (const color of Object.values(sharedCalendarColors)) {
|
||||||
|
if (color) used.add(color.toLowerCase());
|
||||||
|
}
|
||||||
|
for (const cal of missing) {
|
||||||
|
const color = pickUnusedCalendarColor(used);
|
||||||
|
used.add(color.toLowerCase());
|
||||||
|
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||||
|
}
|
||||||
|
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
|
||||||
|
|
||||||
const allCalendars = useMemo(() => {
|
const allCalendars = useMemo(() => {
|
||||||
if (!showBirthdayCalendar) return calendars;
|
if (!showBirthdayCalendar) return displayCalendars;
|
||||||
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||||
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||||
|
|
||||||
const visibleEvents = useMemo(() => {
|
const visibleEvents = useMemo(() => {
|
||||||
const filtered = events.filter((e) => {
|
const filtered = events.filter((e) => {
|
||||||
@@ -1219,7 +1260,7 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
<TaskListView
|
<TaskListView
|
||||||
tasks={taskStore.tasks}
|
tasks={taskStore.tasks}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
filter={taskStore.filter}
|
filter={taskStore.filter}
|
||||||
showCompleted={taskStore.showCompleted}
|
showCompleted={taskStore.showCompleted}
|
||||||
@@ -1317,8 +1358,21 @@ export default function CalendarPage() {
|
|||||||
updateSetting('birthdayCalendarColor', color);
|
updateSetting('birthdayCalendarColor', color);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Shared calendars: recolor locally only (the viewer usually
|
||||||
|
// can't write the owner's calendar, and it'd recolor it for
|
||||||
|
// everyone). Personal calendars write through to the server.
|
||||||
|
const cal = allCalendars.find((c) => c.id === calendarId);
|
||||||
|
if (cal?.isShared) {
|
||||||
|
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
onResetColor={(cal) => {
|
||||||
|
// Drop the local override; the auto-assign effect picks a
|
||||||
|
// fresh unused color (so it never reverts to a collision).
|
||||||
|
removeSharedCalendarColor(sharedCalendarColorKey(cal));
|
||||||
|
}}
|
||||||
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||||
onCreateEvent={(cal: Calendar) => {
|
onCreateEvent={(cal: Calendar) => {
|
||||||
setDefaultCalendarIdForCreate(cal.id);
|
setDefaultCalendarIdForCreate(cal.id);
|
||||||
@@ -1389,7 +1443,7 @@ export default function CalendarPage() {
|
|||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
onToggleVisibility={toggleCalendarVisibility}
|
onToggleVisibility={toggleCalendarVisibility}
|
||||||
enableCalendarTasks={enableCalendarTasks}
|
enableCalendarTasks={enableCalendarTasks}
|
||||||
@@ -1419,7 +1473,7 @@ export default function CalendarPage() {
|
|||||||
<EventModal
|
<EventModal
|
||||||
key={editEvent?.id ?? 'new'}
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
defaultAllDay={defaultModalAllDay}
|
defaultAllDay={defaultModalAllDay}
|
||||||
@@ -1442,7 +1496,7 @@ export default function CalendarPage() {
|
|||||||
<TaskModal
|
<TaskModal
|
||||||
key={editTask?.id ?? 'new-task'}
|
key={editTask?.id ?? 'new-task'}
|
||||||
task={editTask}
|
task={editTask}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
onSave={handleSaveTask}
|
onSave={handleSaveTask}
|
||||||
onDelete={handleDeleteTask}
|
onDelete={handleDeleteTask}
|
||||||
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
||||||
@@ -1529,7 +1583,7 @@ export default function CalendarPage() {
|
|||||||
{detailEvent && detailAnchorRect && (
|
{detailEvent && detailAnchorRect && (
|
||||||
<EventDetailPopover
|
<EventDetailPopover
|
||||||
event={detailEvent}
|
event={detailEvent}
|
||||||
calendar={calendars.find(c => detailEvent.calendarIds[c.id])}
|
calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
|
||||||
anchorRect={detailAnchorRect}
|
anchorRect={detailAnchorRect}
|
||||||
onEdit={handleEditFromDetail}
|
onEdit={handleEditFromDetail}
|
||||||
onDelete={handleDeleteFromDetail}
|
onDelete={handleDeleteFromDetail}
|
||||||
@@ -1549,7 +1603,7 @@ export default function CalendarPage() {
|
|||||||
<EventModal
|
<EventModal
|
||||||
key={editEvent?.id ?? 'new'}
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
defaultAllDay={defaultModalAllDay}
|
defaultAllDay={defaultModalAllDay}
|
||||||
@@ -1566,7 +1620,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{showImportModal && client && (
|
{showImportModal && client && (
|
||||||
<ICalImportModal
|
<ICalImportModal
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
client={client}
|
client={client}
|
||||||
initialUrl={pendingSubscription?.url}
|
initialUrl={pendingSubscription?.url}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react";
|
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
@@ -11,6 +11,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
|
import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
@@ -50,6 +51,7 @@ interface CalendarSidebarPanelProps {
|
|||||||
selectedCalendarIds: string[];
|
selectedCalendarIds: string[];
|
||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
|
onResetColor?: (calendar: Calendar) => void;
|
||||||
onShareCalendar?: (calendar: Calendar) => void;
|
onShareCalendar?: (calendar: Calendar) => void;
|
||||||
onCreateEvent?: (calendar: Calendar) => void;
|
onCreateEvent?: (calendar: Calendar) => void;
|
||||||
onClearCalendar?: (calendar: Calendar) => void;
|
onClearCalendar?: (calendar: Calendar) => void;
|
||||||
@@ -71,6 +73,7 @@ export function CalendarSidebarPanel({
|
|||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
|
onResetColor,
|
||||||
onShareCalendar,
|
onShareCalendar,
|
||||||
onCreateEvent,
|
onCreateEvent,
|
||||||
onClearCalendar,
|
onClearCalendar,
|
||||||
@@ -94,6 +97,7 @@ export function CalendarSidebarPanel({
|
|||||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||||
const tasks = useTaskStore((s) => s.tasks);
|
const tasks = useTaskStore((s) => s.tasks);
|
||||||
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
||||||
@@ -303,9 +307,11 @@ export function CalendarSidebarPanel({
|
|||||||
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||||
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||||
const canChangeColor = !!onColorChange;
|
const canChangeColor = !!onColorChange;
|
||||||
|
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||||
|
const canResetColor = !!onResetColor && hasColorOverride;
|
||||||
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||||
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||||
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete);
|
||||||
const color = cal.color || "#3b82f6";
|
const color = cal.color || "#3b82f6";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -335,6 +341,13 @@ export function CalendarSidebarPanel({
|
|||||||
</div>
|
</div>
|
||||||
</ContextMenuSubMenu>
|
</ContextMenuSubMenu>
|
||||||
)}
|
)}
|
||||||
|
{canResetColor && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Shuffle}
|
||||||
|
label={tMgmt('random_color')}
|
||||||
|
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showSeparator && <ContextMenuSeparator />}
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
{canClear && (
|
{canClear && (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
|
function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
|
||||||
|
// A local color override on a shared calendar wins over per-event colors,
|
||||||
|
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
|
||||||
|
if (calendar?.colorIsLocalOverride && calendar.color) {
|
||||||
|
return sanitizeColor(calendar.color);
|
||||||
|
}
|
||||||
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
|
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,25 +15,7 @@ import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
|||||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
|
||||||
const CALENDAR_COLORS = [
|
|
||||||
"#3b82f6", // blue
|
|
||||||
"#ef4444", // red
|
|
||||||
"#22c55e", // green
|
|
||||||
"#f59e0b", // amber
|
|
||||||
"#8b5cf6", // violet
|
|
||||||
"#ec4899", // pink
|
|
||||||
"#14b8a6", // teal
|
|
||||||
"#f97316", // orange
|
|
||||||
"#06b6d4", // cyan
|
|
||||||
"#84cc16", // lime
|
|
||||||
"#6366f1", // indigo
|
|
||||||
"#a855f7", // purple
|
|
||||||
"#e11d48", // rose
|
|
||||||
"#0ea5e9", // sky
|
|
||||||
"#10b981", // emerald
|
|
||||||
"#d946ef", // fuchsia
|
|
||||||
];
|
|
||||||
|
|
||||||
function CalendarColorPicker({
|
function CalendarColorPicker({
|
||||||
value,
|
value,
|
||||||
@@ -182,6 +164,8 @@ export function CalendarManagementSettings() {
|
|||||||
const tImport = useTranslations('calendar.import');
|
const tImport = useTranslations('calendar.import');
|
||||||
const tSub = useTranslations('calendar.subscription');
|
const tSub = useTranslations('calendar.subscription');
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
|
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Load calendars if not yet loaded
|
// Load calendars if not yet loaded
|
||||||
@@ -329,6 +313,15 @@ export function CalendarManagementSettings() {
|
|||||||
|
|
||||||
const handleColorChange = async (calendarId: string, color: string) => {
|
const handleColorChange = async (calendarId: string, color: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
// Shared calendars: recolor locally only - the viewer usually can't write
|
||||||
|
// the owner's calendar and it'd recolor it for everyone (see #345).
|
||||||
|
const cal = calendars.find((c) => c.id === calendarId);
|
||||||
|
if (cal?.isShared) {
|
||||||
|
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||||
|
toast.success(t('color_updated'));
|
||||||
|
setColorPickerId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await updateCalendar(client, calendarId, { color });
|
await updateCalendar(client, calendarId, { color });
|
||||||
toast.success(t('color_updated'));
|
toast.success(t('color_updated'));
|
||||||
@@ -396,7 +389,7 @@ export function CalendarManagementSettings() {
|
|||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
|
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
|
||||||
const color = cal.color || '#3b82f6';
|
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
|
||||||
|
|
||||||
if (editingId === cal.id) {
|
if (editingId === cal.id) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { Calendar } from '@/lib/jmap/types';
|
||||||
|
import {
|
||||||
|
CALENDAR_COLORS,
|
||||||
|
sharedCalendarColorKey,
|
||||||
|
pickUnusedCalendarColor,
|
||||||
|
} from '../shared-calendar-colors';
|
||||||
|
|
||||||
|
function makeCal(overrides: Partial<Calendar>): Calendar {
|
||||||
|
return {
|
||||||
|
id: 'cal-1',
|
||||||
|
name: 'Cal',
|
||||||
|
description: null,
|
||||||
|
color: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
isSubscribed: true,
|
||||||
|
isVisible: true,
|
||||||
|
isDefault: false,
|
||||||
|
includeInAvailability: 'all',
|
||||||
|
defaultAlertsWithTime: null,
|
||||||
|
defaultAlertsWithoutTime: null,
|
||||||
|
timeZone: null,
|
||||||
|
shareWith: null,
|
||||||
|
myRights: {} as Calendar['myRights'],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sharedCalendarColorKey', () => {
|
||||||
|
it('is built from local account, JMAP account, and the original id', () => {
|
||||||
|
const cal = makeCal({
|
||||||
|
id: 'acct-9:cal-7',
|
||||||
|
originalId: 'cal-7',
|
||||||
|
accountId: 'acct-9',
|
||||||
|
localAccountId: 'slot-2',
|
||||||
|
});
|
||||||
|
expect(sharedCalendarColorKey(cal)).toBe('slot-2|acct-9|cal-7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stable regardless of the Pro-shell id prefix', () => {
|
||||||
|
// Same underlying calendar, shown once under the active account (bare id)
|
||||||
|
// and once cross-account (prefixed id) - both must map to one key.
|
||||||
|
const active = makeCal({ id: 'acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
|
||||||
|
const prefixed = makeCal({ id: 'slot-2::acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
|
||||||
|
expect(sharedCalendarColorKey(active)).toBe(sharedCalendarColorKey(prefixed));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the id when originalId is absent', () => {
|
||||||
|
const cal = makeCal({ id: 'cal-7', accountId: 'acct-9' });
|
||||||
|
expect(sharedCalendarColorKey(cal)).toBe('|acct-9|cal-7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickUnusedCalendarColor', () => {
|
||||||
|
it('returns a palette color not present in usedColors', () => {
|
||||||
|
const used = CALENDAR_COLORS.slice(0, CALENDAR_COLORS.length - 1);
|
||||||
|
const picked = pickUnusedCalendarColor(used);
|
||||||
|
expect(picked).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores case when comparing used colors', () => {
|
||||||
|
const used = CALENDAR_COLORS.slice(0, -1).map((c) => c.toUpperCase());
|
||||||
|
expect(pickUnusedCalendarColor(used)).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still returns a palette color once every color is taken', () => {
|
||||||
|
expect(CALENDAR_COLORS).toContain(pickUnusedCalendarColor(CALENDAR_COLORS));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always returns a valid palette color for a small used set', () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const picked = pickUnusedCalendarColor(['#3b82f6']);
|
||||||
|
expect(CALENDAR_COLORS).toContain(picked);
|
||||||
|
expect(picked).not.toBe('#3b82f6');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -488,6 +488,10 @@ export interface Calendar {
|
|||||||
// can route mutations to the right client. Distinct from `accountId`
|
// can route mutations to the right client. Distinct from `accountId`
|
||||||
// which is the JMAP server's own account UUID.
|
// which is the JMAP server's own account UUID.
|
||||||
localAccountId?: string;
|
localAccountId?: string;
|
||||||
|
// Set when `color` has been replaced by the viewer's local override for a
|
||||||
|
// shared calendar (see lib/shared-calendar-colors). When true, the override
|
||||||
|
// wins over per-event colors so the whole shared calendar paints uniformly.
|
||||||
|
colorIsLocalOverride?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarRights {
|
export interface CalendarRights {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Calendar } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Palette of calendar colors offered in the color picker. Defined here (rather
|
||||||
|
* than in the settings UI component) so non-React modules can reuse it without
|
||||||
|
* pulling in component code. The settings color picker re-exports this.
|
||||||
|
*/
|
||||||
|
export const CALENDAR_COLORS = [
|
||||||
|
"#3b82f6", // blue
|
||||||
|
"#ef4444", // red
|
||||||
|
"#22c55e", // green
|
||||||
|
"#f59e0b", // amber
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#14b8a6", // teal
|
||||||
|
"#f97316", // orange
|
||||||
|
"#06b6d4", // cyan
|
||||||
|
"#84cc16", // lime
|
||||||
|
"#6366f1", // indigo
|
||||||
|
"#a855f7", // purple
|
||||||
|
"#e11d48", // rose
|
||||||
|
"#0ea5e9", // sky
|
||||||
|
"#10b981", // emerald
|
||||||
|
"#d946ef", // fuchsia
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable key for a shared calendar's local color override. Independent of the
|
||||||
|
* Pro-shell id prefix (which changes with the active account), so the override
|
||||||
|
* survives shell-mode and account switches. Built from the owning JMAP account
|
||||||
|
* + the calendar's original server id.
|
||||||
|
*/
|
||||||
|
export function sharedCalendarColorKey(
|
||||||
|
cal: Pick<Calendar, 'id' | 'originalId' | 'accountId' | 'localAccountId'>,
|
||||||
|
): string {
|
||||||
|
const localAccount = cal.localAccountId ?? '';
|
||||||
|
const account = cal.accountId ?? '';
|
||||||
|
const id = cal.originalId ?? cal.id;
|
||||||
|
return `${localAccount}|${account}|${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a random palette color not present in `usedColors`. Once every palette
|
||||||
|
* entry is taken, fall back to a random palette color (collisions are
|
||||||
|
* unavoidable past CALENDAR_COLORS.length calendars).
|
||||||
|
*/
|
||||||
|
export function pickUnusedCalendarColor(usedColors: Iterable<string>): string {
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const c of usedColors) {
|
||||||
|
if (c) used.add(c.toLowerCase());
|
||||||
|
}
|
||||||
|
const available = CALENDAR_COLORS.filter((c) => !used.has(c.toLowerCase()));
|
||||||
|
const pool = available.length > 0 ? available : CALENDAR_COLORS;
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
}
|
||||||
@@ -2642,6 +2642,7 @@
|
|||||||
"name_placeholder": "Název kalendáře",
|
"name_placeholder": "Název kalendáře",
|
||||||
"color": "Barva",
|
"color": "Barva",
|
||||||
"change_color": "Změnit barvu",
|
"change_color": "Změnit barvu",
|
||||||
|
"random_color": "Nová náhodná barva",
|
||||||
"add_calendar": "Přidat kalendář",
|
"add_calendar": "Přidat kalendář",
|
||||||
"edit": "Upravit",
|
"edit": "Upravit",
|
||||||
"delete": "Odstranit",
|
"delete": "Odstranit",
|
||||||
|
|||||||
@@ -2656,6 +2656,7 @@
|
|||||||
"name_placeholder": "Kalendernavn",
|
"name_placeholder": "Kalendernavn",
|
||||||
"color": "Farve",
|
"color": "Farve",
|
||||||
"change_color": "Skift farve",
|
"change_color": "Skift farve",
|
||||||
|
"random_color": "Ny tilfældig farve",
|
||||||
"add_calendar": "Tilføj kalender",
|
"add_calendar": "Tilføj kalender",
|
||||||
"edit": "Redigér",
|
"edit": "Redigér",
|
||||||
"delete": "Slet",
|
"delete": "Slet",
|
||||||
|
|||||||
@@ -2642,6 +2642,7 @@
|
|||||||
"name_placeholder": "Kalendername",
|
"name_placeholder": "Kalendername",
|
||||||
"color": "Farbe",
|
"color": "Farbe",
|
||||||
"change_color": "Farbe ändern",
|
"change_color": "Farbe ändern",
|
||||||
|
"random_color": "Neue zufällige Farbe",
|
||||||
"add_calendar": "Kalender hinzufügen",
|
"add_calendar": "Kalender hinzufügen",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
|
|||||||
@@ -2657,6 +2657,7 @@
|
|||||||
"name_placeholder": "Calendar name",
|
"name_placeholder": "Calendar name",
|
||||||
"color": "Color",
|
"color": "Color",
|
||||||
"change_color": "Change color",
|
"change_color": "Change color",
|
||||||
|
"random_color": "New random color",
|
||||||
"add_calendar": "Add calendar",
|
"add_calendar": "Add calendar",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
|||||||
@@ -2642,6 +2642,7 @@
|
|||||||
"name_placeholder": "Nombre del calendario",
|
"name_placeholder": "Nombre del calendario",
|
||||||
"color": "Color",
|
"color": "Color",
|
||||||
"change_color": "Cambiar color",
|
"change_color": "Cambiar color",
|
||||||
|
"random_color": "Nuevo color aleatorio",
|
||||||
"add_calendar": "Añadir calendario",
|
"add_calendar": "Añadir calendario",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
|
|||||||
@@ -2656,6 +2656,7 @@
|
|||||||
"name_placeholder": "Nom du calendrier",
|
"name_placeholder": "Nom du calendrier",
|
||||||
"color": "Couleur",
|
"color": "Couleur",
|
||||||
"change_color": "Changer la couleur",
|
"change_color": "Changer la couleur",
|
||||||
|
"random_color": "Nouvelle couleur aléatoire",
|
||||||
"add_calendar": "Ajouter un calendrier",
|
"add_calendar": "Ajouter un calendrier",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
|
|||||||
@@ -2642,6 +2642,7 @@
|
|||||||
"name_placeholder": "Nome del calendario",
|
"name_placeholder": "Nome del calendario",
|
||||||
"color": "Colore",
|
"color": "Colore",
|
||||||
"change_color": "Cambia colore",
|
"change_color": "Cambia colore",
|
||||||
|
"random_color": "Nuovo colore casuale",
|
||||||
"add_calendar": "Aggiungi calendario",
|
"add_calendar": "Aggiungi calendario",
|
||||||
"edit": "Modifica",
|
"edit": "Modifica",
|
||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
|
|||||||
@@ -2642,6 +2642,7 @@
|
|||||||
"name_placeholder": "カレンダー名",
|
"name_placeholder": "カレンダー名",
|
||||||
"color": "色",
|
"color": "色",
|
||||||
"change_color": "色を変更",
|
"change_color": "色を変更",
|
||||||
|
"random_color": "新しいランダムな色",
|
||||||
"add_calendar": "カレンダーを追加",
|
"add_calendar": "カレンダーを追加",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
|
|||||||
@@ -2642,6 +2642,7 @@
|
|||||||
"name_placeholder": "캘린더 이름",
|
"name_placeholder": "캘린더 이름",
|
||||||
"color": "색상",
|
"color": "색상",
|
||||||
"change_color": "색상 변경",
|
"change_color": "색상 변경",
|
||||||
|
"random_color": "새 무작위 색상",
|
||||||
"add_calendar": "캘린더 추가",
|
"add_calendar": "캘린더 추가",
|
||||||
"edit": "수정",
|
"edit": "수정",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
|
|||||||
@@ -2641,6 +2641,7 @@
|
|||||||
"name_placeholder": "Kalendāra nosaukums",
|
"name_placeholder": "Kalendāra nosaukums",
|
||||||
"color": "Krāsa",
|
"color": "Krāsa",
|
||||||
"change_color": "Mainīt krāsu",
|
"change_color": "Mainīt krāsu",
|
||||||
|
"random_color": "Jauna nejauša krāsa",
|
||||||
"add_calendar": "Pievienot kalendāru",
|
"add_calendar": "Pievienot kalendāru",
|
||||||
"edit": "Rediģēt",
|
"edit": "Rediģēt",
|
||||||
"delete": "Dzēst",
|
"delete": "Dzēst",
|
||||||
|
|||||||
@@ -2642,6 +2642,7 @@
|
|||||||
"name_placeholder": "Agendanaam",
|
"name_placeholder": "Agendanaam",
|
||||||
"color": "Kleur",
|
"color": "Kleur",
|
||||||
"change_color": "Kleur wijzigen",
|
"change_color": "Kleur wijzigen",
|
||||||
|
"random_color": "Nieuwe willekeurige kleur",
|
||||||
"add_calendar": "Agenda toevoegen",
|
"add_calendar": "Agenda toevoegen",
|
||||||
"edit": "Bewerken",
|
"edit": "Bewerken",
|
||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
|
|||||||
@@ -2642,6 +2642,7 @@
|
|||||||
"name_placeholder": "Nazwa kalendarza",
|
"name_placeholder": "Nazwa kalendarza",
|
||||||
"color": "Kolor",
|
"color": "Kolor",
|
||||||
"change_color": "Zmień kolor",
|
"change_color": "Zmień kolor",
|
||||||
|
"random_color": "Nowy losowy kolor",
|
||||||
"add_calendar": "Dodaj kalendarz",
|
"add_calendar": "Dodaj kalendarz",
|
||||||
"edit": "Edytuj",
|
"edit": "Edytuj",
|
||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
|
|||||||
@@ -2656,6 +2656,7 @@
|
|||||||
"name_placeholder": "Nome do calendário",
|
"name_placeholder": "Nome do calendário",
|
||||||
"color": "Cor",
|
"color": "Cor",
|
||||||
"change_color": "Alterar cor",
|
"change_color": "Alterar cor",
|
||||||
|
"random_color": "Nova cor aleatória",
|
||||||
"add_calendar": "Adicionar calendário",
|
"add_calendar": "Adicionar calendário",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
|
|||||||
@@ -2642,6 +2642,7 @@
|
|||||||
"name_placeholder": "Название календаря",
|
"name_placeholder": "Название календаря",
|
||||||
"color": "Цвет",
|
"color": "Цвет",
|
||||||
"change_color": "Изменить цвет",
|
"change_color": "Изменить цвет",
|
||||||
|
"random_color": "Новый случайный цвет",
|
||||||
"add_calendar": "Добавить календарь",
|
"add_calendar": "Добавить календарь",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
|
|||||||
@@ -2656,6 +2656,7 @@
|
|||||||
"name_placeholder": "Takvim adı",
|
"name_placeholder": "Takvim adı",
|
||||||
"color": "Renk",
|
"color": "Renk",
|
||||||
"change_color": "Rengi değiştir",
|
"change_color": "Rengi değiştir",
|
||||||
|
"random_color": "Yeni rastgele renk",
|
||||||
"add_calendar": "Takvim ekle",
|
"add_calendar": "Takvim ekle",
|
||||||
"edit": "Düzenle",
|
"edit": "Düzenle",
|
||||||
"delete": "Sil",
|
"delete": "Sil",
|
||||||
|
|||||||
@@ -2642,6 +2642,7 @@
|
|||||||
"name_placeholder": "Назва календаря",
|
"name_placeholder": "Назва календаря",
|
||||||
"color": "колір",
|
"color": "колір",
|
||||||
"change_color": "Змінити колір",
|
"change_color": "Змінити колір",
|
||||||
|
"random_color": "Новий випадковий колір",
|
||||||
"add_calendar": "Додати календар",
|
"add_calendar": "Додати календар",
|
||||||
"edit": "Редагувати",
|
"edit": "Редагувати",
|
||||||
"delete": "Видалити",
|
"delete": "Видалити",
|
||||||
|
|||||||
@@ -2642,6 +2642,7 @@
|
|||||||
"name_placeholder": "日历名称",
|
"name_placeholder": "日历名称",
|
||||||
"color": "颜色",
|
"color": "颜色",
|
||||||
"change_color": "更改颜色",
|
"change_color": "更改颜色",
|
||||||
|
"random_color": "随机新颜色",
|
||||||
"add_calendar": "添加日历",
|
"add_calendar": "添加日历",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
|
|||||||
@@ -183,6 +183,11 @@ interface SettingsState {
|
|||||||
showBirthdayCalendar: boolean;
|
showBirthdayCalendar: boolean;
|
||||||
birthdayCalendarColor: string;
|
birthdayCalendarColor: string;
|
||||||
|
|
||||||
|
// Per-viewer color overrides for shared calendars, keyed by
|
||||||
|
// sharedCalendarColorKey(). Lets each recipient recolor calendars shared
|
||||||
|
// with them without changing the owner's color (see #345).
|
||||||
|
sharedCalendarColors: Record<string, string>;
|
||||||
|
|
||||||
// Contacts Display
|
// Contacts Display
|
||||||
groupContactsByLetter: boolean;
|
groupContactsByLetter: boolean;
|
||||||
|
|
||||||
@@ -274,6 +279,10 @@ interface SettingsState {
|
|||||||
setFolderIcon: (mailboxId: string, icon: string) => void;
|
setFolderIcon: (mailboxId: string, icon: string) => void;
|
||||||
removeFolderIcon: (mailboxId: string) => void;
|
removeFolderIcon: (mailboxId: string) => void;
|
||||||
|
|
||||||
|
// Shared-calendar color overrides
|
||||||
|
setSharedCalendarColor: (key: string, color: string) => void;
|
||||||
|
removeSharedCalendarColor: (key: string) => void;
|
||||||
|
|
||||||
// Trusted senders
|
// Trusted senders
|
||||||
addTrustedSender: (email: string) => void;
|
addTrustedSender: (email: string) => void;
|
||||||
removeTrustedSender: (email: string) => void;
|
removeTrustedSender: (email: string) => void;
|
||||||
@@ -360,6 +369,8 @@ const DEFAULT_SETTINGS = {
|
|||||||
showBirthdayCalendar: false,
|
showBirthdayCalendar: false,
|
||||||
birthdayCalendarColor: '#eab308',
|
birthdayCalendarColor: '#eab308',
|
||||||
|
|
||||||
|
sharedCalendarColors: {} as Record<string, string>,
|
||||||
|
|
||||||
// Contacts Display
|
// Contacts Display
|
||||||
groupContactsByLetter: true,
|
groupContactsByLetter: true,
|
||||||
|
|
||||||
@@ -546,6 +557,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
showTasksOnCalendar: state.showTasksOnCalendar,
|
showTasksOnCalendar: state.showTasksOnCalendar,
|
||||||
showBirthdayCalendar: state.showBirthdayCalendar,
|
showBirthdayCalendar: state.showBirthdayCalendar,
|
||||||
birthdayCalendarColor: state.birthdayCalendarColor,
|
birthdayCalendarColor: state.birthdayCalendarColor,
|
||||||
|
sharedCalendarColors: state.sharedCalendarColors,
|
||||||
groupContactsByLetter: state.groupContactsByLetter,
|
groupContactsByLetter: state.groupContactsByLetter,
|
||||||
expandedFilterView: state.expandedFilterView,
|
expandedFilterView: state.expandedFilterView,
|
||||||
showTimeInMonthView: state.showTimeInMonthView,
|
showTimeInMonthView: state.showTimeInMonthView,
|
||||||
@@ -642,6 +654,16 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
set({ folderIcons: rest });
|
set({ folderIcons: rest });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Shared-calendar color override methods
|
||||||
|
setSharedCalendarColor: (key: string, color: string) => {
|
||||||
|
set({ sharedCalendarColors: { ...get().sharedCalendarColors, [key]: color } });
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSharedCalendarColor: (key: string) => {
|
||||||
|
const { [key]: _, ...rest } = get().sharedCalendarColors;
|
||||||
|
set({ sharedCalendarColors: rest });
|
||||||
|
},
|
||||||
|
|
||||||
// Trusted senders methods
|
// Trusted senders methods
|
||||||
addTrustedSender: (email: string) => {
|
addTrustedSender: (email: string) => {
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
const normalizedEmail = email.toLowerCase().trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user