diff --git a/frontend/src/components/calendar/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx index 429a94a..9428093 100644 --- a/frontend/src/components/calendar/CalendarForm.tsx +++ b/frontend/src/components/calendar/CalendarForm.tsx @@ -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( + `/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 ( - + {calendar ? 'Edit Calendar' : 'New Calendar'} @@ -119,6 +164,45 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { + {showSharing && ( + <> +
+ + +
+ + {isShared && ( +
+
+ + + You (Owner) + +
+ + + + +
+ )} + + )} + {canDelete && ( + + ) : ( + + )} + + ); +} diff --git a/frontend/src/components/calendar/CalendarMemberSearch.tsx b/frontend/src/components/calendar/CalendarMemberSearch.tsx new file mode 100644 index 0000000..699b52a --- /dev/null +++ b/frontend/src/components/calendar/CalendarMemberSearch.tsx @@ -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(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 ( +
+
+ {isLoading ? ( + + ) : ( + + )} + setQuery(e.target.value)} + onFocus={() => setFocused(true)} + className="pl-8 h-9 text-sm" + /> +
+ + {focused && filtered.length > 0 && ( +
+ {filtered.map((conn) => { + const displayName = conn.connected_preferred_name || conn.connected_umbral_name; + const initial = displayName.charAt(0).toUpperCase(); + return ( + + ); + })} +
+ )} + + {focused && query.trim() && filtered.length === 0 && ( +
+

No matching connections

+
+ )} +
+ ); +} diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 8f68451..d177b0e 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -43,6 +43,7 @@ export default function CalendarPage() { const { settings } = useSettings(); const { data: calendars = [] } = useCalendars(); + const [visibleSharedIds, setVisibleSharedIds] = useState>(new Set()); const calendarContainerRef = useRef(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 (
- +
{/* Custom toolbar */} diff --git a/frontend/src/components/calendar/CalendarSidebar.tsx b/frontend/src/components/calendar/CalendarSidebar.tsx index 9636064..ad794dc 100644 --- a/frontend/src/components/calendar/CalendarSidebar.tsx +++ b/frontend/src/components/calendar/CalendarSidebar.tsx @@ -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) => 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(null); const [showTemplateForm, setShowTemplateForm] = useState(false); const [editingTemplate, setEditingTemplate] = useState(null); + const [sharedVisibility, setSharedVisibility] = useState>(() => 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)
- {/* Calendars list */} + {/* Owned calendars list */}
{calendars.map((cal) => (
+ {/* Shared calendars section */} + + {/* Templates section */}
diff --git a/frontend/src/components/calendar/PermissionBadge.tsx b/frontend/src/components/calendar/PermissionBadge.tsx new file mode 100644 index 0000000..118cf57 --- /dev/null +++ b/frontend/src/components/calendar/PermissionBadge.tsx @@ -0,0 +1,24 @@ +import { Eye, Pencil, Shield } from 'lucide-react'; +import type { CalendarPermission } from '@/types'; + +const config: Record = { + 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 ( + + {showIcon && } + {c.label} + + ); +} diff --git a/frontend/src/components/calendar/SharedCalendarSection.tsx b/frontend/src/components/calendar/SharedCalendarSection.tsx new file mode 100644 index 0000000..89997d7 --- /dev/null +++ b/frontend/src/components/calendar/SharedCalendarSection.tsx @@ -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 { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); + } catch { + return {}; + } +} + +function saveVisibility(v: Record) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(v)); +} + +interface SharedCalendarSectionProps { + memberships: SharedCalendarMembership[]; + visibleSharedIds: Set; + onVisibilityChange: (calendarId: number, visible: boolean) => void; +} + +export default function SharedCalendarSection({ + memberships, + visibleSharedIds, + onVisibilityChange, +}: SharedCalendarSectionProps) { + const [settingsFor, setSettingsFor] = useState(null); + + if (memberships.length === 0) return null; + + return ( + <> +
+
+ + Shared Calendars + +
+
+ {memberships.map((m) => { + const color = m.local_color || m.calendar_color; + const isVisible = visibleSharedIds.has(m.calendar_id); + return ( +
+ onVisibilityChange(m.calendar_id, !isVisible)} + className="shrink-0" + style={{ + accentColor: color, + borderColor: isVisible ? color : undefined, + backgroundColor: isVisible ? color : undefined, + }} + /> + + {m.calendar_name} + +
+ ); + })} +
+
+ + {settingsFor && ( + setSettingsFor(null)} + /> + )} + + ); +} + +export { STORAGE_KEY, loadVisibility, saveVisibility }; diff --git a/frontend/src/components/calendar/SharedCalendarSettings.tsx b/frontend/src/components/calendar/SharedCalendarSettings.tsx new file mode 100644 index 0000000..95dc1f4 --- /dev/null +++ b/frontend/src/components/calendar/SharedCalendarSettings.tsx @@ -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( + `/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 ( + + + + + Shared Calendar Settings + + +
+
+

{membership.calendar_name}

+
+ Your permission: + +
+
+ +
+ +
+ {colorSwatches.map((c) => ( +
+
+ +
+ + +
+ + {membership.can_add_others && ( +
+ + +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 91a1e88..783aca8 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -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>(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)?.calendar_name || 'a calendar'; + + toast.custom( + (id) => ( +
+
+
+ +
+
+

Calendar Invite

+

+ {notification.message || `You've been invited to ${calendarName}`} +

+
+ + +
+
+
+
+ ), + { id: `calendar-invite-${inviteId}`, duration: 30000 }, + ); + }; return null; } diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx index 9abd863..09d726d 100644 --- a/frontend/src/components/notifications/NotificationsPage.tsx +++ b/frontend/src/components/notifications/NotificationsPage.tsx @@ -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 = { 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('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() {
)} + + {/* Calendar invite actions (inline) */} + {notification.type === 'calendar_invite' && + notification.source_id && + pendingInviteIds.has(notification.source_id) && ( +
+ + +
+ )} {/* Timestamp + actions */}
diff --git a/frontend/src/hooks/useCalendars.ts b/frontend/src/hooks/useCalendars.ts index 444ec6d..825de5e 100644 --- a/frontend/src/hooks/useCalendars.ts +++ b/frontend/src/hooks/useCalendars.ts @@ -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('/calendars'); return data; }, }); + + const sharedQuery = useQuery({ + queryKey: ['calendars', 'shared'], + queryFn: async () => { + const { data } = await api.get('/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, + }; } diff --git a/frontend/src/hooks/useConnections.ts b/frontend/src/hooks/useConnections.ts index 7febfb6..a1e00fe 100644 --- a/frontend/src/hooks/useConnections.ts +++ b/frontend/src/hooks/useConnections.ts @@ -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'] }); }, }); diff --git a/frontend/src/hooks/useSharedCalendars.ts b/frontend/src/hooks/useSharedCalendars.ts new file mode 100644 index 0000000..a60e10b --- /dev/null +++ b/frontend/src/hooks/useSharedCalendars.ts @@ -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( + `/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 = {}; + 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, + }; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4476907..2bcb02a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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; +}