Phase 3: Frontend core for shared calendars

Types: CalendarPermission, SharedCalendarMembership, CalendarMemberInfo,
CalendarInvite, EventLockInfo. Calendar type extended with is_shared.

Hooks: useCalendars extended with shared calendar polling (5s).
useSharedCalendars for member CRUD, invite responses, color updates.
useConnections cascade invalidation on disconnect.

New components: PermissionBadge, CalendarMemberSearch,
CalendarMemberRow, CalendarMemberList, SharedCalendarSection,
SharedCalendarSettings (non-owner dialog with color, members, leave).

Modified: CalendarForm (sharing toggle, member management for owners),
CalendarSidebar (shared calendars section with localStorage visibility),
CalendarPage (shared calendar ID integration in event filtering),
NotificationToaster (calendar_invite toast with accept/decline),
NotificationsPage (calendar_invite inline actions + type icons).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-06 04:59:13 +08:00
parent e6e81c59e7
commit 4e3fd35040
15 changed files with 1064 additions and 23 deletions

View File

@ -1,8 +1,8 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Calendar } from '@/types'; import type { Calendar, CalendarMemberInfo, CalendarPermission, Connection } from '@/types';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -14,6 +14,11 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { useConnections } from '@/hooks/useConnections';
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
import CalendarMemberSearch from './CalendarMemberSearch';
import CalendarMemberList from './CalendarMemberList';
interface CalendarFormProps { interface CalendarFormProps {
calendar: Calendar | null; calendar: Calendar | null;
@ -21,20 +26,30 @@ interface CalendarFormProps {
} }
const colorSwatches = [ const colorSwatches = [
'#3b82f6', // blue '#3b82f6', '#ef4444', '#f97316', '#eab308',
'#ef4444', // red '#22c55e', '#8b5cf6', '#ec4899', '#06b6d4',
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#8b5cf6', // purple
'#ec4899', // pink
'#06b6d4', // cyan
]; ];
export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [name, setName] = useState(calendar?.name || ''); const [name, setName] = useState(calendar?.name || '');
const [color, setColor] = useState(calendar?.color || '#3b82f6'); const [color, setColor] = useState(calendar?.color || '#3b82f6');
const [isShared, setIsShared] = useState(calendar?.is_shared ?? false);
const { connections } = useConnections();
const { invite, isInviting, updateMember, removeMember } = useSharedCalendars();
const membersQuery = useQuery({
queryKey: ['calendar-members', calendar?.id],
queryFn: async () => {
const { data } = await api.get<CalendarMemberInfo[]>(
`/shared-calendars/${calendar!.id}/members`
);
return data;
},
enabled: !!calendar?.is_shared,
});
const members = membersQuery.data ?? [];
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -78,11 +93,41 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
mutation.mutate(); mutation.mutate();
}; };
const handleInvite = async (conn: Connection) => {
if (!calendar) return;
await invite({
calendarId: calendar.id,
connectionId: conn.id,
permission: 'read_only',
canAddOthers: false,
});
membersQuery.refetch();
};
const handleUpdatePermission = async (memberId: number, permission: CalendarPermission) => {
if (!calendar) return;
await updateMember({ calendarId: calendar.id, memberId, permission });
membersQuery.refetch();
};
const handleUpdateCanAddOthers = async (memberId: number, canAddOthers: boolean) => {
if (!calendar) return;
await updateMember({ calendarId: calendar.id, memberId, canAddOthers });
membersQuery.refetch();
};
const handleRemoveMember = async (memberId: number) => {
if (!calendar) return;
await removeMember({ calendarId: calendar.id, memberId });
membersQuery.refetch();
};
const canDelete = calendar && !calendar.is_default && !calendar.is_system; const canDelete = calendar && !calendar.is_default && !calendar.is_system;
const showSharing = calendar && !calendar.is_system;
return ( return (
<Dialog open={true} onOpenChange={onClose}> <Dialog open={true} onOpenChange={onClose}>
<DialogContent> <DialogContent className={isShared && showSharing ? 'sm:max-w-lg' : undefined}>
<DialogClose onClick={onClose} /> <DialogClose onClick={onClose} />
<DialogHeader> <DialogHeader>
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle> <DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
@ -119,6 +164,45 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
</div> </div>
</div> </div>
{showSharing && (
<>
<div className="flex items-center justify-between py-3 border-t border-border">
<Label className="mb-0">Share this calendar</Label>
<Switch
checked={isShared}
onCheckedChange={setIsShared}
/>
</div>
{isShared && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="mb-0">Members</Label>
<span className="text-[11px] text-muted-foreground">
You (Owner)
</span>
</div>
<CalendarMemberSearch
connections={connections}
existingMembers={members}
onSelect={handleInvite}
isLoading={isInviting}
/>
<CalendarMemberList
members={members}
isLoading={membersQuery.isLoading}
isOwner={true}
onUpdatePermission={handleUpdatePermission}
onUpdateCanAddOthers={handleUpdateCanAddOthers}
onRemove={handleRemoveMember}
/>
</div>
)}
</>
)}
<DialogFooter> <DialogFooter>
{canDelete && ( {canDelete && (
<Button <Button

View File

@ -0,0 +1,55 @@
import { Loader2 } from 'lucide-react';
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
import CalendarMemberRow from './CalendarMemberRow';
interface CalendarMemberListProps {
members: CalendarMemberInfo[];
isLoading?: boolean;
isOwner: boolean;
readOnly?: boolean;
onUpdatePermission?: (memberId: number, permission: CalendarPermission) => void;
onUpdateCanAddOthers?: (memberId: number, canAddOthers: boolean) => void;
onRemove?: (memberId: number) => void;
}
export default function CalendarMemberList({
members,
isLoading = false,
isOwner,
readOnly = false,
onUpdatePermission,
onUpdateCanAddOthers,
onRemove,
}: CalendarMemberListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (members.length === 0) {
return (
<p className="text-xs text-muted-foreground py-2">
Search your connections to add members
</p>
);
}
return (
<div className="space-y-2 max-h-48 overflow-y-auto">
{members.map((member) => (
<CalendarMemberRow
key={member.id}
member={member}
isOwner={isOwner}
readOnly={readOnly}
onUpdatePermission={onUpdatePermission}
onUpdateCanAddOthers={onUpdateCanAddOthers}
onRemove={onRemove}
/>
))}
</div>
);
}

View File

@ -0,0 +1,98 @@
import { X, UserPlus } from 'lucide-react';
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
import { Select } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import PermissionBadge from './PermissionBadge';
interface CalendarMemberRowProps {
member: CalendarMemberInfo;
isOwner: boolean;
readOnly?: boolean;
onUpdatePermission?: (memberId: number, permission: CalendarPermission) => void;
onUpdateCanAddOthers?: (memberId: number, canAddOthers: boolean) => void;
onRemove?: (memberId: number) => void;
}
export default function CalendarMemberRow({
member,
isOwner,
readOnly = false,
onUpdatePermission,
onUpdateCanAddOthers,
onRemove,
}: CalendarMemberRowProps) {
const { confirming, handleClick: handleRemoveClick } = useConfirmAction(
() => onRemove?.(member.id)
);
const displayName = member.preferred_name || member.umbral_name;
const initial = displayName.charAt(0).toUpperCase();
return (
<div className="flex items-center gap-2.5 rounded-lg border border-border p-3 transition-all duration-300">
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-sm text-violet-400 font-medium">{initial}</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{displayName}</span>
{member.status === 'pending' && (
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-400 font-medium">
Pending
</span>
)}
</div>
{member.preferred_name && (
<span className="text-xs text-muted-foreground">{member.umbral_name}</span>
)}
</div>
{readOnly ? (
<PermissionBadge permission={member.permission} />
) : isOwner ? (
<div className="flex items-center gap-2 shrink-0">
<Select
value={member.permission}
onChange={(e) =>
onUpdatePermission?.(member.id, e.target.value as CalendarPermission)
}
className="h-7 w-auto text-xs py-0 pr-7 pl-2"
>
<option value="read_only">Read Only</option>
<option value="create_modify">Create/Modify</option>
<option value="full_access">Full Access</option>
</Select>
{(member.permission === 'create_modify' || member.permission === 'full_access') && (
<label className="flex items-center gap-1.5 cursor-pointer" title="Can add others">
<Checkbox
checked={member.can_add_others}
onChange={() =>
onUpdateCanAddOthers?.(member.id, !member.can_add_others)
}
className="h-3.5 w-3.5"
/>
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
</label>
)}
<button
onClick={handleRemoveClick}
className="text-muted-foreground hover:text-destructive transition-colors"
title={confirming ? 'Click again to confirm' : 'Remove member'}
>
{confirming ? (
<span className="text-[10px] text-destructive font-medium">Sure?</span>
) : (
<X className="h-4 w-4" />
)}
</button>
</div>
) : (
<PermissionBadge permission={member.permission} />
)}
</div>
);
}

View File

@ -0,0 +1,103 @@
import { useState, useRef, useEffect } from 'react';
import { Search, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import type { Connection, CalendarMemberInfo } from '@/types';
interface CalendarMemberSearchProps {
connections: Connection[];
existingMembers: CalendarMemberInfo[];
onSelect: (connection: Connection) => void;
isLoading?: boolean;
}
export default function CalendarMemberSearch({
connections,
existingMembers,
onSelect,
isLoading = false,
}: CalendarMemberSearchProps) {
const [query, setQuery] = useState('');
const [focused, setFocused] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setFocused(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const existingUserIds = new Set(existingMembers.map((m) => m.user_id));
const filtered = connections.filter((c) => {
if (existingUserIds.has(c.connected_user_id)) return false;
if (!query.trim()) return true;
const q = query.toLowerCase();
return (
c.connected_umbral_name.toLowerCase().includes(q) ||
(c.connected_preferred_name?.toLowerCase().includes(q) ?? false)
);
});
const handleSelect = (connection: Connection) => {
onSelect(connection);
setQuery('');
setFocused(false);
};
return (
<div ref={containerRef} className="relative">
<div className="relative">
{isLoading ? (
<Loader2 className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground animate-spin" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder="Search connections to invite..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
className="pl-8 h-9 text-sm"
/>
</div>
{focused && filtered.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden max-h-40 overflow-y-auto">
{filtered.map((conn) => {
const displayName = conn.connected_preferred_name || conn.connected_umbral_name;
const initial = displayName.charAt(0).toUpperCase();
return (
<button
key={conn.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(conn)}
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
>
<div className="h-6 w-6 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-xs text-violet-400 font-medium">{initial}</span>
</div>
<div className="min-w-0 flex-1">
<span className="font-medium truncate block">{displayName}</span>
{conn.connected_preferred_name && (
<span className="text-xs text-muted-foreground">{conn.connected_umbral_name}</span>
)}
</div>
</button>
);
})}
</div>
)}
{focused && query.trim() && filtered.length === 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg p-3">
<p className="text-xs text-muted-foreground text-center">No matching connections</p>
</div>
)}
</div>
);
}

View File

@ -43,6 +43,7 @@ export default function CalendarPage() {
const { settings } = useSettings(); const { settings } = useSettings();
const { data: calendars = [] } = useCalendars(); const { data: calendars = [] } = useCalendars();
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
const calendarContainerRef = useRef<HTMLDivElement>(null); const calendarContainerRef = useRef<HTMLDivElement>(null);
// Location data for event panel // Location data for event panel
@ -149,8 +150,11 @@ export default function CalendarPage() {
}, [panelOpen]); }, [panelOpen]);
const visibleCalendarIds = useMemo( const visibleCalendarIds = useMemo(
() => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)), () => {
[calendars], const owned = calendars.filter((c) => c.is_visible).map((c) => c.id);
return new Set([...owned, ...visibleSharedIds]);
},
[calendars, visibleSharedIds],
); );
const toLocalDatetime = (d: Date): string => { const toLocalDatetime = (d: Date): string => {
@ -364,7 +368,7 @@ export default function CalendarPage() {
return ( return (
<div className="flex h-full overflow-hidden animate-fade-in"> <div className="flex h-full overflow-hidden animate-fade-in">
<CalendarSidebar onUseTemplate={handleUseTemplate} /> <CalendarSidebar onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} />
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden"> <div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar */} {/* Custom toolbar */}

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, FileText } from 'lucide-react'; import { Plus, Pencil, Trash2, FileText } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -9,19 +9,41 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import CalendarForm from './CalendarForm'; import CalendarForm from './CalendarForm';
import TemplateForm from './TemplateForm'; import TemplateForm from './TemplateForm';
import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedCalendarSection';
interface CalendarSidebarProps { interface CalendarSidebarProps {
onUseTemplate?: (template: EventTemplate) => void; onUseTemplate?: (template: EventTemplate) => void;
onSharedVisibilityChange?: (visibleIds: Set<number>) => void;
} }
export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps) { export default function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange }: CalendarSidebarProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars(); const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingCalendar, setEditingCalendar] = useState<Calendar | null>(null); const [editingCalendar, setEditingCalendar] = useState<Calendar | null>(null);
const [showTemplateForm, setShowTemplateForm] = useState(false); const [showTemplateForm, setShowTemplateForm] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<EventTemplate | null>(null); const [editingTemplate, setEditingTemplate] = useState<EventTemplate | null>(null);
const [sharedVisibility, setSharedVisibility] = useState<Record<number, boolean>>(() => loadVisibility());
const visibleSharedIds = new Set(
sharedCalendars
.filter((m) => sharedVisibility[m.calendar_id] !== false)
.map((m) => m.calendar_id)
);
useEffect(() => {
onSharedVisibilityChange?.(visibleSharedIds);
}, [sharedCalendars, sharedVisibility]);
const handleSharedVisibilityChange = useCallback((calendarId: number, visible: boolean) => {
setSharedVisibility((prev) => {
const next = { ...prev, [calendarId]: visible };
saveVisibility(next);
return next;
});
}, []);
const { data: templates = [] } = useQuery({ const { data: templates = [] } = useQuery({
queryKey: ['event-templates'], queryKey: ['event-templates'],
queryFn: async () => { queryFn: async () => {
@ -84,7 +106,7 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-3 space-y-4"> <div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Calendars list */} {/* Owned calendars list */}
<div className="space-y-0.5"> <div className="space-y-0.5">
{calendars.map((cal) => ( {calendars.map((cal) => (
<div <div
@ -116,6 +138,13 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
))} ))}
</div> </div>
{/* Shared calendars section */}
<SharedCalendarSection
memberships={sharedCalendars}
visibleSharedIds={visibleSharedIds}
onVisibilityChange={handleSharedVisibilityChange}
/>
{/* Templates section */} {/* Templates section */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">

View File

@ -0,0 +1,24 @@
import { Eye, Pencil, Shield } from 'lucide-react';
import type { CalendarPermission } from '@/types';
const config: Record<CalendarPermission, { label: string; icon: typeof Eye; bg: string; text: string }> = {
read_only: { label: 'Read Only', icon: Eye, bg: 'bg-blue-500/10', text: 'text-blue-400' },
create_modify: { label: 'Create/Modify', icon: Pencil, bg: 'bg-amber-500/10', text: 'text-amber-400' },
full_access: { label: 'Full Access', icon: Shield, bg: 'bg-green-500/10', text: 'text-green-400' },
};
interface PermissionBadgeProps {
permission: CalendarPermission;
showIcon?: boolean;
}
export default function PermissionBadge({ permission, showIcon = true }: PermissionBadgeProps) {
const c = config[permission];
const Icon = c.icon;
return (
<span className={`inline-flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded-full font-medium ${c.bg} ${c.text}`}>
{showIcon && <Icon className="h-3 w-3" />}
{c.label}
</span>
);
}

View File

@ -0,0 +1,90 @@
import { useState } from 'react';
import { Pencil } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import type { SharedCalendarMembership } from '@/types';
import SharedCalendarSettings from './SharedCalendarSettings';
const STORAGE_KEY = 'umbra_shared_cal_visibility';
function loadVisibility(): Record<number, boolean> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
} catch {
return {};
}
}
function saveVisibility(v: Record<number, boolean>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(v));
}
interface SharedCalendarSectionProps {
memberships: SharedCalendarMembership[];
visibleSharedIds: Set<number>;
onVisibilityChange: (calendarId: number, visible: boolean) => void;
}
export default function SharedCalendarSection({
memberships,
visibleSharedIds,
onVisibilityChange,
}: SharedCalendarSectionProps) {
const [settingsFor, setSettingsFor] = useState<SharedCalendarMembership | null>(null);
if (memberships.length === 0) return null;
return (
<>
<div className="space-y-1.5">
<div className="px-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Shared Calendars
</span>
</div>
<div className="space-y-0.5">
{memberships.map((m) => {
const color = m.local_color || m.calendar_color;
const isVisible = visibleSharedIds.has(m.calendar_id);
return (
<div
key={m.id}
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
>
<Checkbox
checked={isVisible}
onChange={() => onVisibilityChange(m.calendar_id, !isVisible)}
className="shrink-0"
style={{
accentColor: color,
borderColor: isVisible ? color : undefined,
backgroundColor: isVisible ? color : undefined,
}}
/>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-sm text-foreground truncate flex-1">{m.calendar_name}</span>
<button
onClick={() => setSettingsFor(m)}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
</div>
{settingsFor && (
<SharedCalendarSettings
membership={settingsFor}
onClose={() => setSettingsFor(null)}
/>
)}
</>
);
}
export { STORAGE_KEY, loadVisibility, saveVisibility };

View File

@ -0,0 +1,145 @@
import { useState } from 'react';
import { LogOut } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { SharedCalendarMembership, CalendarMemberInfo, Connection } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
import { useConnections } from '@/hooks/useConnections';
import PermissionBadge from './PermissionBadge';
import CalendarMemberList from './CalendarMemberList';
import CalendarMemberSearch from './CalendarMemberSearch';
const colorSwatches = [
'#3b82f6', '#ef4444', '#f97316', '#eab308',
'#22c55e', '#8b5cf6', '#ec4899', '#06b6d4',
];
interface SharedCalendarSettingsProps {
membership: SharedCalendarMembership;
onClose: () => void;
}
export default function SharedCalendarSettings({ membership, onClose }: SharedCalendarSettingsProps) {
const [localColor, setLocalColor] = useState(membership.local_color || membership.calendar_color);
const { updateColor, leaveCalendar, invite, isInviting } = useSharedCalendars();
const { connections } = useConnections();
const membersQuery = useQuery({
queryKey: ['calendar-members', membership.calendar_id],
queryFn: async () => {
const { data } = await api.get<CalendarMemberInfo[]>(
`/shared-calendars/${membership.calendar_id}/members`
);
return data;
},
});
const members = membersQuery.data ?? [];
const { confirming: leaveConfirming, handleClick: handleLeaveClick } = useConfirmAction(
async () => {
await leaveCalendar({ calendarId: membership.calendar_id, memberId: membership.id });
onClose();
}
);
const handleColorSelect = async (color: string) => {
setLocalColor(color);
await updateColor({ calendarId: membership.calendar_id, localColor: color });
};
const handleInvite = async (conn: Connection) => {
await invite({
calendarId: membership.calendar_id,
connectionId: conn.id,
permission: 'read_only',
canAddOthers: false,
});
membersQuery.refetch();
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>Shared Calendar Settings</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">{membership.calendar_name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">Your permission:</span>
<PermissionBadge permission={membership.permission} />
</div>
</div>
<div className="space-y-2">
<Label>Your Color</Label>
<div className="flex gap-2">
{colorSwatches.map((c) => (
<button
key={c}
type="button"
onClick={() => handleColorSelect(c)}
className="h-8 w-8 rounded-full border-2 transition-all duration-150 hover:scale-110"
style={{
backgroundColor: c,
borderColor: localColor === c ? 'hsl(0 0% 98%)' : 'transparent',
boxShadow: localColor === c ? `0 0 0 2px ${c}40` : 'none',
}}
/>
))}
</div>
</div>
<div className="space-y-2">
<Label>Members ({members.length})</Label>
<CalendarMemberList
members={members}
isLoading={membersQuery.isLoading}
isOwner={false}
readOnly={true}
/>
</div>
{membership.can_add_others && (
<div className="space-y-2">
<Label>Add Members</Label>
<CalendarMemberSearch
connections={connections}
existingMembers={members}
onSelect={handleInvite}
isLoading={isInviting}
/>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-border">
<Button
variant="destructive"
size="sm"
onClick={handleLeaveClick}
>
<LogOut className="mr-2 h-4 w-4" />
{leaveConfirming ? 'Sure?' : 'Leave Calendar'}
</Button>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,9 +1,10 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Check, X, Bell, UserPlus } from 'lucide-react'; import { Check, X, Bell, UserPlus, Calendar } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections'; import { useConnections } from '@/hooks/useConnections';
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
import axios from 'axios'; import axios from 'axios';
import { getErrorMessage } from '@/lib/api'; import { getErrorMessage } from '@/lib/api';
import type { AppNotification } from '@/types'; import type { AppNotification } from '@/types';
@ -11,6 +12,7 @@ import type { AppNotification } from '@/types';
export default function NotificationToaster() { export default function NotificationToaster() {
const { notifications, unreadCount, markRead } = useNotifications(); const { notifications, unreadCount, markRead } = useNotifications();
const { respond } = useConnections(); const { respond } = useConnections();
const { respondInvite } = useSharedCalendars();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const maxSeenIdRef = useRef(0); const maxSeenIdRef = useRef(0);
const initializedRef = useRef(false); const initializedRef = useRef(false);
@ -18,7 +20,9 @@ export default function NotificationToaster() {
// Track in-flight request IDs so repeated clicks are blocked // Track in-flight request IDs so repeated clicks are blocked
const respondingRef = useRef<Set<number>>(new Set()); const respondingRef = useRef<Set<number>>(new Set());
// Always call the latest respond — Sonner toasts capture closures at creation time // Always call the latest respond — Sonner toasts capture closures at creation time
const respondInviteRef = useRef(respondInvite);
const respondRef = useRef(respond); const respondRef = useRef(respond);
respondInviteRef.current = respondInvite;
respondRef.current = respond; respondRef.current = respond;
const markReadRef = useRef(markRead); const markReadRef = useRef(markRead);
markReadRef.current = markRead; markReadRef.current = markRead;
@ -56,6 +60,34 @@ export default function NotificationToaster() {
[], [],
); );
const handleCalendarInviteRespond = useCallback(
async (inviteId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
if (respondingRef.current.has(inviteId + 100000)) return;
respondingRef.current.add(inviteId + 100000);
toast.dismiss(toastId);
const loadingId = toast.loading(
action === 'accept' ? 'Accepting calendar invite\u2026' : 'Declining invite\u2026',
);
try {
await respondInviteRef.current({ inviteId, action });
toast.dismiss(loadingId);
markReadRef.current([notificationId]).catch(() => {});
} catch (err) {
toast.dismiss(loadingId);
if (axios.isAxiosError(err) && err.response?.status === 409) {
markReadRef.current([notificationId]).catch(() => {});
} else {
toast.error(getErrorMessage(err, 'Failed to respond to invite'));
}
} finally {
respondingRef.current.delete(inviteId + 100000);
}
},
[],
);
// Track unread count changes to force-refetch the list // Track unread count changes to force-refetch the list
useEffect(() => { useEffect(() => {
if (unreadCount > prevUnreadRef.current && initializedRef.current) { if (unreadCount > prevUnreadRef.current && initializedRef.current) {
@ -91,11 +123,16 @@ export default function NotificationToaster() {
if (newNotifications.some((n) => n.type === 'connection_request')) { if (newNotifications.some((n) => n.type === 'connection_request')) {
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] }); queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
} }
if (newNotifications.some((n) => n.type === 'calendar_invite')) {
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
}
// Show toasts // Show toasts
newNotifications.forEach((notification) => { newNotifications.forEach((notification) => {
if (notification.type === 'connection_request' && notification.source_id) { if (notification.type === 'connection_request' && notification.source_id) {
showConnectionRequestToast(notification); showConnectionRequestToast(notification);
} else if (notification.type === 'calendar_invite' && notification.source_id) {
showCalendarInviteToast(notification);
} else { } else {
toast(notification.title || 'New Notification', { toast(notification.title || 'New Notification', {
description: notification.message || undefined, description: notification.message || undefined,
@ -104,7 +141,7 @@ export default function NotificationToaster() {
}); });
} }
}); });
}, [notifications, handleConnectionRespond]); }, [notifications, handleConnectionRespond, handleCalendarInviteRespond]);
const showConnectionRequestToast = (notification: AppNotification) => { const showConnectionRequestToast = (notification: AppNotification) => {
const requestId = notification.source_id!; const requestId = notification.source_id!;
@ -145,5 +182,45 @@ export default function NotificationToaster() {
); );
}; };
const showCalendarInviteToast = (notification: AppNotification) => {
const inviteId = notification.source_id!;
const calendarName = (notification.data as Record<string, string>)?.calendar_name || 'a calendar';
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-purple-500/15 flex items-center justify-center shrink-0">
<Calendar className="h-4 w-4 text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Calendar Invite</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || `You've been invited to ${calendarName}`}
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => handleCalendarInviteRespond(inviteId, 'accept', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
<Check className="h-3.5 w-3.5" />
Accept
</button>
<button
onClick={() => handleCalendarInviteRespond(inviteId, 'reject', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
<X className="h-3.5 w-3.5" />
Decline
</button>
</div>
</div>
</div>
</div>
),
{ id: `calendar-invite-${inviteId}`, duration: 30000 },
);
};
return null; return null;
} }

View File

@ -1,11 +1,12 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2 } from 'lucide-react'; import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections'; import { useConnections } from '@/hooks/useConnections';
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import axios from 'axios'; import axios from 'axios';
@ -16,6 +17,9 @@ import type { AppNotification } from '@/types';
const typeIcons: Record<string, { icon: typeof Bell; color: string }> = { const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
connection_request: { icon: UserPlus, color: 'text-violet-400' }, connection_request: { icon: UserPlus, color: 'text-violet-400' },
connection_accepted: { icon: UserPlus, color: 'text-green-400' }, connection_accepted: { icon: UserPlus, color: 'text-green-400' },
calendar_invite: { icon: Calendar, color: 'text-purple-400' },
calendar_invite_accepted: { icon: Calendar, color: 'text-green-400' },
calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' },
info: { icon: Info, color: 'text-blue-400' }, info: { icon: Info, color: 'text-blue-400' },
warning: { icon: AlertCircle, color: 'text-amber-400' }, warning: { icon: AlertCircle, color: 'text-amber-400' },
}; };
@ -33,11 +37,17 @@ export default function NotificationsPage() {
} = useNotifications(); } = useNotifications();
const { incomingRequests, respond, isResponding } = useConnections(); const { incomingRequests, respond, isResponding } = useConnections();
const { incomingInvites, respondInvite, isResponding: isRespondingInvite } = useSharedCalendars();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [filter, setFilter] = useState<Filter>('all'); const [filter, setFilter] = useState<Filter>('all');
// Build a set of pending connection request IDs for quick lookup // Build a set of pending connection request IDs for quick lookup
const pendingInviteIds = useMemo(
() => new Set(incomingInvites.map((inv) => inv.id)),
[incomingInvites],
);
const pendingRequestIds = useMemo( const pendingRequestIds = useMemo(
() => new Set(incomingRequests.map((r) => r.id)), () => new Set(incomingRequests.map((r) => r.id)),
[incomingRequests], [incomingRequests],
@ -46,6 +56,10 @@ export default function NotificationsPage() {
// Eagerly fetch incoming requests when notifications contain connection_request // Eagerly fetch incoming requests when notifications contain connection_request
// entries whose source_id isn't in pendingRequestIds yet (stale connections data) // entries whose source_id isn't in pendingRequestIds yet (stale connections data)
useEffect(() => { useEffect(() => {
// Also refresh calendar invites
if (notifications.some((n) => n.type === 'calendar_invite' && !n.is_read)) {
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
}
const hasMissing = notifications.some( const hasMissing = notifications.some(
(n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id), (n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id),
); );
@ -106,6 +120,27 @@ export default function NotificationsPage() {
} }
}; };
const handleCalendarInviteRespond = async (
notification: AppNotification,
action: 'accept' | 'reject',
) => {
if (!notification.source_id) return;
try {
await respondInvite({ inviteId: notification.source_id, action });
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 409) {
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
} else {
toast.error(getErrorMessage(err, 'Failed to respond'));
}
}
};
const handleNotificationClick = async (notification: AppNotification) => { const handleNotificationClick = async (notification: AppNotification) => {
// Don't navigate for pending connection requests — let user act inline // Don't navigate for pending connection requests — let user act inline
if ( if (
@ -250,6 +285,32 @@ export default function NotificationsPage() {
</div> </div>
)} )}
{/* Calendar invite actions (inline) */}
{notification.type === 'calendar_invite' &&
notification.source_id &&
pendingInviteIds.has(notification.source_id) && (
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="sm"
onClick={(e) => { e.stopPropagation(); handleCalendarInviteRespond(notification, 'accept'); }}
disabled={isRespondingInvite}
className="gap-1 h-7 text-xs"
>
{isRespondingInvite ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
Accept
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleCalendarInviteRespond(notification, 'reject'); }}
disabled={isRespondingInvite}
className="h-7 text-xs"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* Timestamp + actions */} {/* Timestamp + actions */}
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">

View File

@ -1,13 +1,38 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Calendar } from '@/types'; import type { Calendar, SharedCalendarMembership } from '@/types';
export function useCalendars() { export function useCalendars() {
return useQuery({ const ownedQuery = useQuery({
queryKey: ['calendars'], queryKey: ['calendars'],
queryFn: async () => { queryFn: async () => {
const { data } = await api.get<Calendar[]>('/calendars'); const { data } = await api.get<Calendar[]>('/calendars');
return data; return data;
}, },
}); });
const sharedQuery = useQuery({
queryKey: ['calendars', 'shared'],
queryFn: async () => {
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
return data;
},
refetchInterval: 5_000,
staleTime: 3_000,
});
const allCalendarIds = useMemo(() => {
const owned = (ownedQuery.data ?? []).map((c) => c.id);
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);
return new Set([...owned, ...shared]);
}, [ownedQuery.data, sharedQuery.data]);
return {
data: ownedQuery.data ?? [],
sharedData: sharedQuery.data ?? [],
allCalendarIds,
isLoading: ownedQuery.isLoading,
isLoadingShared: sharedQuery.isLoading,
};
} }

View File

@ -88,6 +88,8 @@ export function useConnections() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['connections'] }); queryClient.invalidateQueries({ queryKey: ['connections'] });
queryClient.invalidateQueries({ queryKey: ['people'] }); queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
}, },
}); });

View File

@ -0,0 +1,196 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import axios from 'axios';
import type { CalendarMemberInfo, CalendarInvite, CalendarPermission } from '@/types';
export function useSharedCalendars() {
const queryClient = useQueryClient();
const incomingInvitesQuery = useQuery({
queryKey: ['calendar-invites', 'incoming'],
queryFn: async () => {
const { data } = await api.get<{ invites: CalendarInvite[]; total: number }>(
'/shared-calendars/invites/incoming'
);
return data.invites;
},
refetchOnMount: 'always' as const,
});
const fetchMembers = async (calendarId: number) => {
const { data } = await api.get<CalendarMemberInfo[]>(
`/shared-calendars/${calendarId}/members`
);
return data;
};
const useMembersQuery = (calendarId: number | null) =>
useQuery({
queryKey: ['calendar-members', calendarId],
queryFn: () => fetchMembers(calendarId!),
enabled: calendarId != null,
});
const inviteMutation = useMutation({
mutationFn: async ({
calendarId,
connectionId,
permission,
canAddOthers,
}: {
calendarId: number;
connectionId: number;
permission: CalendarPermission;
canAddOthers: boolean;
}) => {
const { data } = await api.post(`/shared-calendars/${calendarId}/invite`, {
connection_id: connectionId,
permission,
can_add_others: canAddOthers,
});
return data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] });
queryClient.invalidateQueries({ queryKey: ['calendars'] });
toast.success('Invite sent');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to send invite'));
},
});
const updateMemberMutation = useMutation({
mutationFn: async ({
calendarId,
memberId,
permission,
canAddOthers,
}: {
calendarId: number;
memberId: number;
permission?: CalendarPermission;
canAddOthers?: boolean;
}) => {
const body: Record<string, unknown> = {};
if (permission !== undefined) body.permission = permission;
if (canAddOthers !== undefined) body.can_add_others = canAddOthers;
const { data } = await api.put(
`/shared-calendars/${calendarId}/members/${memberId}`,
body
);
return data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] });
toast.success('Member updated');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update member'));
},
});
const removeMemberMutation = useMutation({
mutationFn: async ({
calendarId,
memberId,
}: {
calendarId: number;
memberId: number;
}) => {
await api.delete(`/shared-calendars/${calendarId}/members/${memberId}`);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] });
queryClient.invalidateQueries({ queryKey: ['calendars'] });
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
toast.success('Member removed');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to remove member'));
},
});
const respondInviteMutation = useMutation({
mutationFn: async ({
inviteId,
action,
}: {
inviteId: number;
action: 'accept' | 'reject';
}) => {
const { data } = await api.put(`/shared-calendars/invites/${inviteId}/respond`, {
action,
});
return data;
},
onSuccess: (_, variables) => {
toast.dismiss(`calendar-invite-${variables.inviteId}`);
queryClient.invalidateQueries({ queryKey: ['calendar-invites'] });
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success(variables.action === 'accept' ? 'Calendar added' : 'Invite declined');
},
onError: (error, variables) => {
if (axios.isAxiosError(error) && error.response?.status === 409) {
toast.dismiss(`calendar-invite-${variables.inviteId}`);
queryClient.invalidateQueries({ queryKey: ['calendar-invites'] });
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
return;
}
toast.error(getErrorMessage(error, 'Failed to respond to invite'));
},
});
const updateColorMutation = useMutation({
mutationFn: async ({
calendarId,
localColor,
}: {
calendarId: number;
localColor: string | null;
}) => {
await api.put(`/shared-calendars/${calendarId}/members/me/color`, {
local_color: localColor,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update color'));
},
});
const leaveCalendarMutation = useMutation({
mutationFn: async ({ calendarId, memberId }: { calendarId: number; memberId: number }) => {
await api.delete(`/shared-calendars/${calendarId}/members/${memberId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success('Left calendar');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to leave calendar'));
},
});
return {
incomingInvites: incomingInvitesQuery.data ?? [],
isLoadingInvites: incomingInvitesQuery.isLoading,
useMembersQuery,
invite: inviteMutation.mutateAsync,
isInviting: inviteMutation.isPending,
updateMember: updateMemberMutation.mutateAsync,
removeMember: removeMemberMutation.mutateAsync,
respondInvite: respondInviteMutation.mutateAsync,
isResponding: respondInviteMutation.isPending,
updateColor: updateColorMutation.mutateAsync,
leaveCalendar: leaveCalendarMutation.mutateAsync,
};
}

View File

@ -81,6 +81,7 @@ export interface Calendar {
is_default: boolean; is_default: boolean;
is_system: boolean; is_system: boolean;
is_visible: boolean; is_visible: boolean;
is_shared: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -439,3 +440,50 @@ export interface Connection {
export interface UmbralSearchResponse { export interface UmbralSearchResponse {
found: boolean; found: boolean;
} }
// ── Shared Calendars ──────────────────────────────────────────────
export type CalendarPermission = 'read_only' | 'create_modify' | 'full_access';
export interface SharedCalendarMembership {
id: number;
calendar_id: number;
calendar_name: string;
calendar_color: string;
local_color: string | null;
permission: CalendarPermission;
can_add_others: boolean;
is_owner: false;
}
export interface CalendarMemberInfo {
id: number;
calendar_id: number;
user_id: number;
umbral_name: string;
preferred_name: string | null;
permission: CalendarPermission;
can_add_others: boolean;
local_color: string | null;
status: 'pending' | 'accepted';
invited_at: string;
accepted_at: string | null;
}
export interface CalendarInvite {
id: number;
calendar_id: number;
calendar_name: string;
calendar_color: string;
owner_umbral_name: string;
inviter_umbral_name: string;
permission: CalendarPermission;
invited_at: string;
}
export interface EventLockInfo {
locked: boolean;
locked_by_name: string | null;
expires_at: string | null;
is_permanent: boolean;
}