diff --git a/frontend/src/components/sharing/ShareDialog.tsx b/frontend/src/components/sharing/ShareDialog.tsx index 6f682db..0595d87 100644 --- a/frontend/src/components/sharing/ShareDialog.tsx +++ b/frontend/src/components/sharing/ShareDialog.tsx @@ -1,15 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { - Users, - Trash2, - Layers, - Folder, - Eye, - Pencil, - UserPlus, - Loader2, -} from 'lucide-react' +import { Users, Eye, Pencil, X, Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' import { sharing, @@ -29,7 +20,6 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { Label } from '@/components/ui/label' import { Select, SelectContent, @@ -38,6 +28,7 @@ import { SelectValue, } from '@/components/ui/select' import { Alert, AlertDescription } from '@/components/ui/alert' +import { Separator } from '@/components/ui/separator' interface ShareDialogProps { isOpen: boolean @@ -128,83 +119,43 @@ export function ShareDialog({ } }, [isOpen]) - const TypeIcon = type === 'heap' ? Layers : Folder + 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 {type === 'heap' ? 'heap' : 'folder'} + + + + Share + + “{targetName}” + + - - Manage who can access this {type}. + + Invite people to view or edit this {type}. - {/* Target chip — gives the action a clear subject instead of a - * bare "Sharing X" line floating under the title. */} -
- - - {targetName} - -
- - {/* People with access ────────────────────────────────────────── */} -
-
- - {shares.length > 0 && ( - - {shares.length} {shares.length === 1 ? 'person' : 'people'} - - )} -
- - {isLoading ? ( -
- - Loading shares… -
- ) : shares.length === 0 ? ( -
- Only you can see this {type}. Add people below to share it. -
- ) : ( -
    - {shares.map((share) => ( -
  • -
    - - - {share.shared_with_username} - -
    -
    - - -
    -
  • - ))} -
- )} -
- - {/* Add people ────────────────────────────────────────────────── */} + {/* 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() @@ -212,88 +163,141 @@ export function ShareDialog({ }} className="space-y-2" > - - - { + setUsername(v) + setError(null) + }} + disabled={isLoadingUsers || availableUsers.length === 0} + > + + + + + {availableUsers.map((u) => ( + + + + {u.username} + + + ))} + + + - - {/* Permission picker — segmented control style so the two - * options are visible at once and the difference between - * "view" and "edit" is obvious without opening a dropdown. */} -
-
- - -
+ + + + Editor + + + +
- {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_username} +
    +
    + {share.permission === 'write' ? ( + <> + + Can edit + + ) : ( + <> + + Can view + + )} +
    +
    + +
  • + ))} +
+ )} +
) @@ -301,10 +305,10 @@ export function ShareDialog({ // ── Helpers ──────────────────────────────────────────────────────────── -/** Circular initial bubble. Hashes the username into one of a small set - * of stable palette tints so each person's avatar reads consistently - * across the app — the colour isn't meaningful, it's just an identity - * cue to make a list of names feel less anonymous. */ +/** Circular initial bubble. Hashes the username into one of a small + * set of stable palette tints so each person's avatar reads + * consistently across the app — the colour isn't meaningful, just an + * identity cue that makes a list of names feel less anonymous. */ function Avatar({ name, size = 'md' }: { name: string; size?: 'sm' | 'md' }) { const initials = name.slice(0, 2).toUpperCase() const palette = [ @@ -317,7 +321,7 @@ function Avatar({ name, size = 'md' }: { name: string; size?: 'sm' | 'md' }) { let hash = 0 for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0 const tint = palette[Math.abs(hash) % palette.length] - const dims = size === 'sm' ? 'h-5 w-5 text-[9px]' : 'h-7 w-7 text-[11px]' + const dims = size === 'sm' ? 'h-5 w-5 text-[9px]' : 'h-8 w-8 text-[11px]' return ( ) } - -function PermissionPill({ permission }: { permission: string }) { - const isWrite = permission === 'write' - const Icon = isWrite ? Pencil : Eye - return ( - - - {isWrite ? 'Edit' : 'View'} - - ) -} - -function PermissionOption({ - icon: Icon, - label, - value, - current, - onSelect, -}: { - icon: typeof Eye - label: string - value: 'read' | 'write' - current: 'read' | 'write' - onSelect: (v: 'read' | 'write') => void -}) { - const active = current === value - return ( - - ) -}