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:
parent
e6e81c59e7
commit
4e3fd35040
@ -1,8 +1,8 @@
|
||||
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 api, { getErrorMessage } from '@/lib/api';
|
||||
import type { Calendar } from '@/types';
|
||||
import type { Calendar, CalendarMemberInfo, CalendarPermission, Connection } from '@/types';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -14,6 +14,11 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
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 {
|
||||
calendar: Calendar | null;
|
||||
@ -21,20 +26,30 @@ interface CalendarFormProps {
|
||||
}
|
||||
|
||||
const colorSwatches = [
|
||||
'#3b82f6', // blue
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
'#22c55e', // green
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#3b82f6', '#ef4444', '#f97316', '#eab308',
|
||||
'#22c55e', '#8b5cf6', '#ec4899', '#06b6d4',
|
||||
];
|
||||
|
||||
export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(calendar?.name || '');
|
||||
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({
|
||||
mutationFn: async () => {
|
||||
@ -78,11 +93,41 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
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 showSharing = calendar && !calendar.is_system;
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogContent className={isShared && showSharing ? 'sm:max-w-lg' : undefined}>
|
||||
<DialogClose onClick={onClose} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
|
||||
@ -119,6 +164,45 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
</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>
|
||||
{canDelete && (
|
||||
<Button
|
||||
|
||||
55
frontend/src/components/calendar/CalendarMemberList.tsx
Normal file
55
frontend/src/components/calendar/CalendarMemberList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/calendar/CalendarMemberRow.tsx
Normal file
98
frontend/src/components/calendar/CalendarMemberRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/calendar/CalendarMemberSearch.tsx
Normal file
103
frontend/src/components/calendar/CalendarMemberSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -43,6 +43,7 @@ export default function CalendarPage() {
|
||||
|
||||
const { settings } = useSettings();
|
||||
const { data: calendars = [] } = useCalendars();
|
||||
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
|
||||
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Location data for event panel
|
||||
@ -149,8 +150,11 @@ export default function CalendarPage() {
|
||||
}, [panelOpen]);
|
||||
|
||||
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 => {
|
||||
@ -364,7 +368,7 @@ export default function CalendarPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Custom toolbar */}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@ -9,19 +9,41 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import CalendarForm from './CalendarForm';
|
||||
import TemplateForm from './TemplateForm';
|
||||
import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedCalendarSection';
|
||||
|
||||
interface CalendarSidebarProps {
|
||||
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 { data: calendars = [] } = useCalendars();
|
||||
const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingCalendar, setEditingCalendar] = useState<Calendar | null>(null);
|
||||
const [showTemplateForm, setShowTemplateForm] = useState(false);
|
||||
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({
|
||||
queryKey: ['event-templates'],
|
||||
queryFn: async () => {
|
||||
@ -84,7 +106,7 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
{/* Calendars list */}
|
||||
{/* Owned calendars list */}
|
||||
<div className="space-y-0.5">
|
||||
{calendars.map((cal) => (
|
||||
<div
|
||||
@ -116,6 +138,13 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Shared calendars section */}
|
||||
<SharedCalendarSection
|
||||
memberships={sharedCalendars}
|
||||
visibleSharedIds={visibleSharedIds}
|
||||
onVisibilityChange={handleSharedVisibilityChange}
|
||||
/>
|
||||
|
||||
{/* Templates section */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
|
||||
24
frontend/src/components/calendar/PermissionBadge.tsx
Normal file
24
frontend/src/components/calendar/PermissionBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/calendar/SharedCalendarSection.tsx
Normal file
90
frontend/src/components/calendar/SharedCalendarSection.tsx
Normal 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 };
|
||||
145
frontend/src/components/calendar/SharedCalendarSettings.tsx
Normal file
145
frontend/src/components/calendar/SharedCalendarSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
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 { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
||||
import axios from 'axios';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
import type { AppNotification } from '@/types';
|
||||
@ -11,6 +12,7 @@ import type { AppNotification } from '@/types';
|
||||
export default function NotificationToaster() {
|
||||
const { notifications, unreadCount, markRead } = useNotifications();
|
||||
const { respond } = useConnections();
|
||||
const { respondInvite } = useSharedCalendars();
|
||||
const queryClient = useQueryClient();
|
||||
const maxSeenIdRef = useRef(0);
|
||||
const initializedRef = useRef(false);
|
||||
@ -18,7 +20,9 @@ export default function NotificationToaster() {
|
||||
// Track in-flight request IDs so repeated clicks are blocked
|
||||
const respondingRef = useRef<Set<number>>(new Set());
|
||||
// Always call the latest respond — Sonner toasts capture closures at creation time
|
||||
const respondInviteRef = useRef(respondInvite);
|
||||
const respondRef = useRef(respond);
|
||||
respondInviteRef.current = respondInvite;
|
||||
respondRef.current = respond;
|
||||
const markReadRef = useRef(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
|
||||
useEffect(() => {
|
||||
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
||||
@ -91,11 +123,16 @@ export default function NotificationToaster() {
|
||||
if (newNotifications.some((n) => n.type === 'connection_request')) {
|
||||
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
|
||||
}
|
||||
if (newNotifications.some((n) => n.type === 'calendar_invite')) {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
|
||||
}
|
||||
|
||||
// Show toasts
|
||||
newNotifications.forEach((notification) => {
|
||||
if (notification.type === 'connection_request' && notification.source_id) {
|
||||
showConnectionRequestToast(notification);
|
||||
} else if (notification.type === 'calendar_invite' && notification.source_id) {
|
||||
showCalendarInviteToast(notification);
|
||||
} else {
|
||||
toast(notification.title || 'New Notification', {
|
||||
description: notification.message || undefined,
|
||||
@ -104,7 +141,7 @@ export default function NotificationToaster() {
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [notifications, handleConnectionRespond]);
|
||||
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond]);
|
||||
|
||||
const showConnectionRequestToast = (notification: AppNotification) => {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 { toast } from 'sonner';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import axios from 'axios';
|
||||
@ -16,6 +17,9 @@ import type { AppNotification } from '@/types';
|
||||
const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
|
||||
connection_request: { icon: UserPlus, color: 'text-violet-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' },
|
||||
warning: { icon: AlertCircle, color: 'text-amber-400' },
|
||||
};
|
||||
@ -33,11 +37,17 @@ export default function NotificationsPage() {
|
||||
} = useNotifications();
|
||||
|
||||
const { incomingRequests, respond, isResponding } = useConnections();
|
||||
const { incomingInvites, respondInvite, isResponding: isRespondingInvite } = useSharedCalendars();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
// 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(
|
||||
() => new Set(incomingRequests.map((r) => r.id)),
|
||||
[incomingRequests],
|
||||
@ -46,6 +56,10 @@ export default function NotificationsPage() {
|
||||
// Eagerly fetch incoming requests when notifications contain connection_request
|
||||
// entries whose source_id isn't in pendingRequestIds yet (stale connections data)
|
||||
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(
|
||||
(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) => {
|
||||
// Don't navigate for pending connection requests — let user act inline
|
||||
if (
|
||||
@ -250,6 +285,32 @@ export default function NotificationsPage() {
|
||||
</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 */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
|
||||
@ -1,13 +1,38 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { Calendar } from '@/types';
|
||||
import type { Calendar, SharedCalendarMembership } from '@/types';
|
||||
|
||||
export function useCalendars() {
|
||||
return useQuery({
|
||||
const ownedQuery = useQuery({
|
||||
queryKey: ['calendars'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<Calendar[]>('/calendars');
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -88,6 +88,8 @@ export function useConnections() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
196
frontend/src/hooks/useSharedCalendars.ts
Normal file
196
frontend/src/hooks/useSharedCalendars.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -81,6 +81,7 @@ export interface Calendar {
|
||||
is_default: boolean;
|
||||
is_system: boolean;
|
||||
is_visible: boolean;
|
||||
is_shared: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -439,3 +440,50 @@ export interface Connection {
|
||||
export interface UmbralSearchResponse {
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user