feat(sharing): pending-state invites, notification bell, sidebar polish

Shares used to activate instantly on the owner's side with no notice to
the recipient. Introduce a pending/accepted lifecycle so a recipient
gets a bell notification on login and explicitly Accept or Decline
before the shared item lands in their sidebar.

Backend
- Migration 0014 adds `status` + `accepted_at` to heap_shares and
  folder_shares; pre-existing rows are backfilled to 'accepted' so
  nothing disappears from anyone's current sidebar. One-migration trick:
  server_default 'accepted' during add_column, then strip so new inserts
  fall through to the Python model default 'pending'.
- New recipient-only endpoints: POST /sharing/{heaps|folders}/{id}/accept
  (idempotent) and /decline (hard delete, so re-invites are clean).
- New GET /sharing/pending returning {heaps, folders} of outstanding
  invites with target_name + owner_username + permission.
- list_shared_{heaps,folders} now filter to status='accepted' and carry
  share_id so the recipient can Leave without a second lookup.
- ShareResponse exposes status so the owner sees pending invites.

Frontend
- NotificationBell lives in the LeftSidebar user row: a Popover
  triggered by Bell with a count badge. Each row shows owner avatar,
  "{owner} shared {heap|folder} {name}" with a permission subtitle,
  and Accept / Decline inline. Polls /sharing/pending every 60s.
- Shared Avatar helper extracted to sharing/Avatar.tsx — used by
  ShareDialog, NotificationBell, and the sidebar shared rows so one
  user's identity colour is stable everywhere.
- Sidebar shared-row polish: owner avatar bubble + Eye/Pencil
  permission icon (was uppercase pill). Right-click opens a context
  menu with Open / Leave; Leave calls the existing recipient-revoke
  DELETE and invalidates the shared-{heaps,folders} query.
- ShareDialog shows an amber "Invited" pill next to pending recipients.
- New shadcn context-menu primitive (radix dep already installed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 23:17:53 +02:00
parent 11343c17dc
commit 319be20389
11 changed files with 845 additions and 107 deletions

View File

@@ -11,13 +11,31 @@ import {
Copy,
Trash2,
Users,
Eye,
LogOut,
Download as DownloadIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useSharedHeapsQuery } from '../../hooks/useSharingQueries'
import { heaps as heapsApi, downloads, type Heap } from '../../services/api'
import {
useSharedHeapsQuery,
SHARED_HEAPS_KEY,
} from '../../hooks/useSharingQueries'
import {
heaps as heapsApi,
downloads,
sharing as sharingApi,
type Heap,
} from '../../services/api'
import { Avatar } from '../sharing/Avatar'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
@@ -479,7 +497,9 @@ export function HeapsPanel() {
</div>
)}
{/* Shared with me */}
{/* Shared with me — mirrors the folder treatment in LeftSidebar:
* owner avatar + Eye/Pencil permission icon + right-click
* context menu with Open / Leave. */}
{sharedHeaps.length > 0 && expanded && (
<div className="mt-1">
<div className="px-3 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-text-faint">
@@ -487,39 +507,62 @@ export function HeapsPanel() {
</div>
{sharedHeaps.map((sh) => {
const isFiltered = currentSection === `heap-${sh.id}`
const PermissionIcon = sh.permission === 'write' ? Pencil : Eye
return (
<div
key={sh.id}
className={cn(
'group flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
)}
style={{ paddingLeft: '20px' }}
onClick={() =>
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
}
>
<Users
className={cn(
'h-3.5 w-3.5 flex-shrink-0',
isFiltered ? 'text-primary' : 'text-text-muted'
)}
/>
<span className="truncate" title={sh.name}>
{sh.name}
</span>
<span className="ml-0.5 truncate text-[10px] text-text-faint">
{sh.owner_username}
</span>
<span className="ml-auto rounded bg-surface-offset px-1 text-[9px] uppercase text-text-faint">
{sh.permission}
</span>
{sh.photo_count > 0 && (
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
{sh.photo_count}
</span>
)}
</div>
<ContextMenu key={sh.id}>
<ContextMenuTrigger asChild>
<div
className={cn(
'group flex h-[24px] cursor-pointer items-center gap-1.5 rounded px-2 text-[12px] leading-none',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
)}
style={{ paddingLeft: '20px' }}
onClick={() =>
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
}
>
<Avatar name={sh.owner_username} size="xs" />
<span className="truncate" title={`${sh.name} (shared by ${sh.owner_username})`}>
{sh.name}
</span>
<PermissionIcon
className="ml-auto h-3 w-3 flex-shrink-0 text-text-muted"
aria-label={sh.permission === 'write' ? 'Can edit' : 'Can view'}
/>
{sh.photo_count > 0 && (
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
{sh.photo_count}
</span>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() =>
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
}
>
<ShoppingBasket className="h-3.5 w-3.5 text-text-muted" />
Open
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="text-reject focus:bg-reject/10 focus:text-reject"
onSelect={async () => {
try {
await sharingApi.revokeHeapShare(sh.id, sh.share_id)
queryClient.invalidateQueries({ queryKey: SHARED_HEAPS_KEY })
toast.success(`Left ${sh.name}`)
} catch (err) {
toast.error('Could not leave', formatApiError(err))
}
}}
>
<LogOut className="h-3.5 w-3.5" />
Leave
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>