import { useEffect, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Users, Eye, Pencil, X, Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' import { Avatar } from './Avatar' import { sharing, type ShareInfo, type ShareableUser, } from '../../services/api' import { formatApiError } from '../../lib/apiError' import { SHARED_HEAPS_KEY, SHARED_FOLDERS_KEY, } from '../../hooks/useSharingQueries' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Alert, AlertDescription } from '@/components/ui/alert' import { Separator } from '@/components/ui/separator' interface ShareDialogProps { isOpen: boolean type: 'heap' | 'folder' targetId: string targetName: string onClose: () => void } export function ShareDialog({ isOpen, type, targetId, targetName, onClose, }: ShareDialogProps) { const [username, setUsername] = useState('') const [permission, setPermission] = useState<'read' | 'write'>('read') const [error, setError] = useState(null) const queryClient = useQueryClient() const sharesQueryKey = [type, 'shares', targetId] const { data: shares = [], isLoading } = useQuery({ queryKey: sharesQueryKey, queryFn: () => type === 'heap' ? sharing.heapShares(targetId) : sharing.folderShares(targetId), enabled: isOpen, }) // All users that can be shared with (the caller is filtered out // server-side). Only fetched while the dialog is open. Cached for 60s // because the user directory changes slowly. const { data: allUsers = [], isLoading: isLoadingUsers } = useQuery< ShareableUser[] >({ queryKey: ['sharing', 'users'], queryFn: sharing.listUsers, enabled: isOpen, staleTime: 60_000, }) // Users who don't already have a share on this target. const availableUsers = useMemo(() => { const taken = new Set(shares.map((s) => s.shared_with_username)) return allUsers.filter((u) => !taken.has(u.username)) }, [allUsers, shares]) const addMutation = useMutation({ mutationFn: () => type === 'heap' ? sharing.shareHeap(targetId, username, permission) : sharing.shareFolder(targetId, username, permission), onSuccess: () => { setUsername('') setPermission('read') setError(null) queryClient.invalidateQueries({ queryKey: sharesQueryKey }) queryClient.invalidateQueries({ queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY, }) }, onError: (err) => { setError(formatApiError(err, 'Failed to share')) }, }) const revokeMutation = useMutation({ mutationFn: (shareId: string) => type === 'heap' ? sharing.revokeHeapShare(targetId, shareId) : sharing.revokeFolderShare(targetId, shareId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: sharesQueryKey }) queryClient.invalidateQueries({ queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY, }) }, }) useEffect(() => { if (isOpen) { setUsername('') setPermission('read') setError(null) } }, [isOpen]) const invitePlaceholder = isLoadingUsers ? 'Loading users…' : availableUsers.length === 0 ? shares.length > 0 ? 'Everyone already has access' : 'No users to share with' : 'Add people…' return ( !o && onClose()}> {/* flex flex-col so the gap-4 between header → invite row → * separator → list actually applies (DialogContent isn't a flex * container by default, which is what made the spacing feel * random on the previous pass). */} {/* Header — target name inlines into the title so there's no * separate "chip" container that reads like an empty input. * Google Drive / Notion / Linear all follow this pattern. */} Share “{targetName}” Invite people to view or edit this {type}. {/* Invite row — single compact line: user picker (flex-1) + * permission + primary action. Mirrors the Drive/Notion * invitation bar, where the whole flow is reachable without * scanning multiple labeled sections. */}
{ e.preventDefault() if (username.trim()) addMutation.mutate() }} className="space-y-2" >
{error && ( {error} )}
{/* People list — hoverable rows, each with avatar + name + * permission subtitle + X to revoke. This is the same layout * Drive/Notion use: one primary line per person, permission * relegated to a subtle subtitle rather than a loud pill. */}

People with access

{shares.length > 0 && ( {shares.length} )}
{isLoading ? (
Loading…
) : shares.length === 0 ? (

Only you can access this {type}.

) : (
    {shares.map((share) => (
  • {share.shared_with_display_name || share.shared_with_username} {share.status === 'pending' && ( Invited )}
    {share.permission === 'write' ? ( <> Can edit ) : ( <> Can view )}
  • ))}
)}
) }