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>
104 lines
3.7 KiB
TypeScript
104 lines
3.7 KiB
TypeScript
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>
|
|
);
|
|
}
|