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 { 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
|
||||||
|
|||||||
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 { 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 */}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
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 { 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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_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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user