From b6be24c3576a7e26e0d8b4ce2ab273fd74abd66e Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 21 Apr 2026 22:15:44 +0200 Subject: [PATCH] ui(sharing): redesign share dialog with clearer structure + affordances Target is now anchored in a chip at the top instead of a floating line. Existing shares and the add-user form are split into labeled sections with states for loading / empty. Each share row gets a hash-tinted initial avatar and a semantic permission pill (primary = edit, muted = view). The user picker is full-width with avatars in the dropdown, and permission becomes a segmented "Can view / Can edit" control alongside an icon-labeled Share button. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/sharing/ShareDialog.tsx | 320 +++++++++++++----- 1 file changed, 236 insertions(+), 84 deletions(-) diff --git a/frontend/src/components/sharing/ShareDialog.tsx b/frontend/src/components/sharing/ShareDialog.tsx index c84988f..6f682db 100644 --- a/frontend/src/components/sharing/ShareDialog.tsx +++ b/frontend/src/components/sharing/ShareDialog.tsx @@ -1,6 +1,16 @@ import { useEffect, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Users, Trash2 } from 'lucide-react' +import { + Users, + Trash2, + Layers, + Folder, + Eye, + Pencil, + UserPlus, + Loader2, +} from 'lucide-react' +import { cn } from '@/lib/utils' import { sharing, type ShareInfo, @@ -14,10 +24,12 @@ import { import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' import { Select, SelectContent, @@ -116,100 +128,164 @@ export function ShareDialog({ } }, [isOpen]) + const TypeIcon = type === 'heap' ? Layers : Folder + return ( !o && onClose()}> - + - + Share {type === 'heap' ? 'heap' : 'folder'} + + Manage who can access this {type}. + -
- Sharing {targetName} + {/* Target chip — gives the action a clear subject instead of a + * bare "Sharing X" line floating under the title. */} +
+ + + {targetName} +
- {/* Existing shares */} - {shares.length > 0 && ( -
- {shares.map((share) => ( -
-
- - {share.shared_with_username} - - - {share.permission} - -
- -
- ))} + {/* People with access ────────────────────────────────────────── */} +
+
+ + {shares.length > 0 && ( + + {shares.length} {shares.length === 1 ? 'person' : 'people'} + + )}
- )} - {isLoading && ( -
Loading shares...
- )} + {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 ────────────────────────────────────────────────── */}
{ e.preventDefault() if (username.trim()) addMutation.mutate() }} - className="space-y-3" + 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. */} +
+
- - - - - Read - Read + Write - - + + +
+
{error && ( @@ -217,17 +293,93 @@ export function ShareDialog({ {error} )} - -
- -
) } + +// ── 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. */ +function Avatar({ name, size = 'md' }: { name: string; size?: 'sm' | 'md' }) { + const initials = name.slice(0, 2).toUpperCase() + const palette = [ + 'bg-primary/25 text-primary', + 'bg-pick/25 text-pick', + 'bg-blue-500/25 text-blue-300', + 'bg-purple-500/25 text-purple-300', + 'bg-amber-500/25 text-amber-300', + ] + 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]' + return ( + + {initials} + + ) +} + +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 ( + + ) +}