diff --git a/backend/alembic/versions/0014_share_status.py b/backend/alembic/versions/0014_share_status.py
new file mode 100644
index 0000000..5654064
--- /dev/null
+++ b/backend/alembic/versions/0014_share_status.py
@@ -0,0 +1,67 @@
+"""Add status + accepted_at to heap_shares and folder_shares
+
+Revision ID: 0014_share_status
+Revises: 0013_drop_old_ct
+Create Date: 2026-04-21
+
+Shares used to activate instantly on the owner's side. We now want a
+pending/accepted lifecycle so the recipient gets a notification bell and
+chooses to accept or decline before the shared item shows up in their
+sidebar.
+
+Backfill note: every pre-existing row is treated as `accepted` with
+accepted_at = created_at. This is a pragmatic fiction — it keeps the
+sidebar populated after the migration without anyone having to click
+accept on shares that were already live. Any future "accepted X ago" UI
+inheriting this backfilled timestamp should be aware it's not a real
+user-action moment.
+
+The one-migration trick: we add `status` with `server_default="accepted"`
+so the backfill happens in-place, then drop the default so new inserts
+fall through to the Python-side model default ("pending").
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0014_share_status"
+down_revision: Union[str, None] = "0013_drop_old_ct"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ for table in ("heap_shares", "folder_shares"):
+ op.add_column(
+ table,
+ sa.Column(
+ "status",
+ sa.String(),
+ nullable=False,
+ server_default="accepted",
+ ),
+ )
+ op.add_column(
+ table,
+ sa.Column("accepted_at", sa.DateTime(), nullable=True),
+ )
+ op.execute(
+ f"UPDATE {table} SET accepted_at = created_at "
+ "WHERE accepted_at IS NULL"
+ )
+ # Drop the DB default so new rows inherit the Python-side
+ # model default ("pending") instead of silently auto-accepting.
+ op.alter_column(table, "status", server_default=None)
+ op.create_index(
+ f"ix_{table}_shared_with_status",
+ table,
+ ["shared_with_id", "status"],
+ )
+
+
+def downgrade() -> None:
+ for table in ("heap_shares", "folder_shares"):
+ op.drop_index(f"ix_{table}_shared_with_status", table_name=table)
+ op.drop_column(table, "accepted_at")
+ op.drop_column(table, "status")
diff --git a/backend/app/models/sharing.py b/backend/app/models/sharing.py
index 29164a8..cd64492 100644
--- a/backend/app/models/sharing.py
+++ b/backend/app/models/sharing.py
@@ -24,12 +24,19 @@ class HeapShare(Base):
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
shared_with_id = Column(String, ForeignKey("users.id"), nullable=False)
permission = Column(String, nullable=False, default="read") # 'read' | 'write'
+ # Lifecycle: 'pending' while the recipient hasn't acted, 'accepted'
+ # once they've Accept'd in the notification bell. Decline deletes the
+ # row outright — see migration 0014 for the backfill of pre-existing
+ # rows to 'accepted' so nothing vanishes from existing sidebars.
+ status = Column(String, nullable=False, default="pending")
created_at = Column(DateTime, server_default=func.now())
+ accepted_at = Column(DateTime, nullable=True)
__table_args__ = (
UniqueConstraint("heap_id", "shared_with_id", name="uq_heap_share"),
Index("ix_heap_shares_shared_with", "shared_with_id"),
Index("ix_heap_shares_heap_id", "heap_id"),
+ Index("ix_heap_shares_shared_with_status", "shared_with_id", "status"),
)
@@ -43,10 +50,13 @@ class FolderShare(Base):
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
shared_with_id = Column(String, ForeignKey("users.id"), nullable=False)
permission = Column(String, nullable=False, default="read") # 'read' | 'write'
+ status = Column(String, nullable=False, default="pending")
created_at = Column(DateTime, server_default=func.now())
+ accepted_at = Column(DateTime, nullable=True)
__table_args__ = (
UniqueConstraint("folder_id", "shared_with_id", name="uq_folder_share"),
Index("ix_folder_shares_shared_with", "shared_with_id"),
Index("ix_folder_shares_folder_id", "folder_id"),
+ Index("ix_folder_shares_shared_with_status", "shared_with_id", "status"),
)
diff --git a/backend/app/routers/sharing.py b/backend/app/routers/sharing.py
index 9620f4e..6eb402c 100644
--- a/backend/app/routers/sharing.py
+++ b/backend/app/routers/sharing.py
@@ -39,11 +39,16 @@ class ShareResponse(BaseModel):
shared_with_id: str
shared_with_username: str
permission: str
+ status: str # 'pending' | 'accepted'
created_at: str
class SharedHeapResponse(BaseModel):
+ # `id` is the heap id (used for navigation). `share_id` is the
+ # heap_shares row id, needed so the recipient can "Leave" via the
+ # existing DELETE endpoint without a separate lookup.
id: str
+ share_id: str
name: str
owner_username: str
permission: str
@@ -52,6 +57,7 @@ class SharedHeapResponse(BaseModel):
class SharedFolderResponse(BaseModel):
id: str
+ share_id: str
name: str
folder_type: str
owner_username: str
@@ -59,6 +65,22 @@ class SharedFolderResponse(BaseModel):
photo_count: int
+class PendingInvite(BaseModel):
+ """A share that exists in the DB but hasn't been accepted yet. Powers
+ the notification bell in the left-sidebar user section."""
+ share_id: str
+ target_id: str # heap id or folder id
+ target_name: str
+ owner_username: str
+ permission: str
+ created_at: str
+
+
+class PendingInvitesResponse(BaseModel):
+ heaps: list[PendingInvite]
+ folders: list[PendingInvite]
+
+
class ShareableUser(BaseModel):
id: str
username: str
@@ -87,6 +109,69 @@ async def list_shareable_users(
]
+# ── Pending invites (recipient-facing, cross-type) ───────────────────────
+
+@router.get("/pending", response_model=PendingInvitesResponse)
+async def list_pending_invites(
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Every share targeting the current user that's still waiting on
+ them to accept. Feeds the notification bell in the sidebar."""
+ heap_rows = (await db.execute(
+ select(HeapShare, Heap, User)
+ .join(Heap, HeapShare.heap_id == Heap.id)
+ .join(User, HeapShare.owner_id == User.id)
+ .where(HeapShare.shared_with_id == current_user.id)
+ .where(HeapShare.status == "pending")
+ )).all()
+
+ heaps = [
+ PendingInvite(
+ share_id=share.id,
+ target_id=heap.id,
+ target_name=heap.name,
+ owner_username=owner.username,
+ permission=share.permission,
+ created_at=share.created_at.isoformat() if share.created_at else "",
+ )
+ for share, heap, owner in heap_rows
+ ]
+
+ folder_rows = (await db.execute(
+ select(FolderShare, User)
+ .join(User, FolderShare.owner_id == User.id)
+ .where(FolderShare.shared_with_id == current_user.id)
+ .where(FolderShare.status == "pending")
+ )).all()
+
+ folders: list[PendingInvite] = []
+ for share, owner in folder_rows:
+ if share.folder_type == "source_root":
+ entity = (await db.execute(
+ select(SourceRoot).where(SourceRoot.id == share.folder_id)
+ )).scalar_one_or_none()
+ else:
+ entity = (await db.execute(
+ select(Folder).where(Folder.id == share.folder_id)
+ )).scalar_one_or_none()
+ # If the underlying folder was deleted while an invite was
+ # still pending, just skip — the share is effectively orphaned
+ # and the owner's revoke path will clean it up.
+ if entity is None:
+ continue
+ folders.append(PendingInvite(
+ share_id=share.id,
+ target_id=share.folder_id,
+ target_name=entity.name,
+ owner_username=owner.username,
+ permission=share.permission,
+ created_at=share.created_at.isoformat() if share.created_at else "",
+ ))
+
+ return PendingInvitesResponse(heaps=heaps, folders=folders)
+
+
# ── Heap sharing ─────────────────────────────────────────────────────────
@router.get("/heaps/shared-with-me")
@@ -94,12 +179,14 @@ async def list_shared_heaps(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
- """List all heaps that have been shared with the current user."""
+ """List all accepted heap shares for the current user. Pending
+ invites are hidden here and surfaced via /sharing/pending instead."""
result = await db.execute(
select(HeapShare, Heap, User)
.join(Heap, HeapShare.heap_id == Heap.id)
.join(User, HeapShare.owner_id == User.id)
.where(HeapShare.shared_with_id == current_user.id)
+ .where(HeapShare.status == "accepted")
)
rows = result.all()
@@ -115,6 +202,7 @@ async def list_shared_heaps(
items.append(SharedHeapResponse(
id=heap.id,
+ share_id=share.id,
name=heap.name,
owner_username=owner.username,
permission=share.permission,
@@ -143,6 +231,7 @@ async def list_heap_shares(
shared_with_id=user.id,
shared_with_username=user.username,
permission=share.permission,
+ status=share.status,
created_at=share.created_at.isoformat() if share.created_at else "",
)
for share, user in result.all()
@@ -186,6 +275,54 @@ async def share_heap(
return {"status": "shared", "share_id": share.id}
+@router.post("/heaps/{heap_id}/accept", status_code=200)
+async def accept_heap_share(
+ heap_id: str,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Recipient accepts a pending heap invite. Idempotent — if the
+ share is already accepted, returns 200 anyway so double-clicks in
+ the notification popover are harmless."""
+ result = await db.execute(
+ select(HeapShare).where(
+ HeapShare.heap_id == heap_id,
+ HeapShare.shared_with_id == current_user.id,
+ )
+ )
+ share = result.scalar_one_or_none()
+ if share is None:
+ raise HTTPException(status_code=404, detail="Invite not found")
+ if share.status != "accepted":
+ share.status = "accepted"
+ share.accepted_at = func.now()
+ await db.commit()
+ return {"status": "accepted"}
+
+
+@router.post("/heaps/{heap_id}/decline", status_code=200)
+async def decline_heap_share(
+ heap_id: str,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Recipient declines a pending heap invite. The share row is
+ deleted — there's no separate 'declined' status. A re-invite just
+ creates a fresh pending row."""
+ result = await db.execute(
+ select(HeapShare).where(
+ HeapShare.heap_id == heap_id,
+ HeapShare.shared_with_id == current_user.id,
+ )
+ )
+ share = result.scalar_one_or_none()
+ if share is None:
+ raise HTTPException(status_code=404, detail="Invite not found")
+ await db.delete(share)
+ await db.commit()
+ return {"status": "declined"}
+
+
@router.delete("/heaps/{heap_id}/{share_id}", status_code=204)
async def revoke_heap_share(
heap_id: str,
@@ -217,11 +354,14 @@ async def list_shared_folders(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
- """List all folders/source roots shared with the current user."""
+ """List all accepted folder/source-root shares for the current
+ user. Pending invites are hidden here and surfaced via
+ /sharing/pending instead."""
result = await db.execute(
select(FolderShare, User)
.join(User, FolderShare.owner_id == User.id)
.where(FolderShare.shared_with_id == current_user.id)
+ .where(FolderShare.status == "accepted")
)
rows = result.all()
@@ -270,6 +410,7 @@ async def list_shared_folders(
count = count_result.scalar() or 0
items.append(SharedFolderResponse(
id=share.folder_id,
+ share_id=share.id,
name=name,
folder_type=share.folder_type,
owner_username=owner.username,
@@ -314,6 +455,7 @@ async def list_folder_shares(
shared_with_id=user.id,
shared_with_username=user.username,
permission=share.permission,
+ status=share.status,
created_at=share.created_at.isoformat() if share.created_at else "",
)
for share, user in result.all()
@@ -370,6 +512,50 @@ async def share_folder(
return {"status": "shared", "share_id": share.id}
+@router.post("/folders/{folder_id}/accept", status_code=200)
+async def accept_folder_share(
+ folder_id: str,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Recipient accepts a pending folder invite. Idempotent."""
+ result = await db.execute(
+ select(FolderShare).where(
+ FolderShare.folder_id == folder_id,
+ FolderShare.shared_with_id == current_user.id,
+ )
+ )
+ share = result.scalar_one_or_none()
+ if share is None:
+ raise HTTPException(status_code=404, detail="Invite not found")
+ if share.status != "accepted":
+ share.status = "accepted"
+ share.accepted_at = func.now()
+ await db.commit()
+ return {"status": "accepted"}
+
+
+@router.post("/folders/{folder_id}/decline", status_code=200)
+async def decline_folder_share(
+ folder_id: str,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Recipient declines a pending folder invite. Row is deleted."""
+ result = await db.execute(
+ select(FolderShare).where(
+ FolderShare.folder_id == folder_id,
+ FolderShare.shared_with_id == current_user.id,
+ )
+ )
+ share = result.scalar_one_or_none()
+ if share is None:
+ raise HTTPException(status_code=404, detail="Invite not found")
+ await db.delete(share)
+ await db.commit()
+ return {"status": "declined"}
+
+
@router.delete("/folders/{folder_id}/{share_id}", status_code=204)
async def revoke_folder_share(
folder_id: str,
diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx
index de16b00..bb561c9 100644
--- a/frontend/src/components/heaps/HeapsPanel.tsx
+++ b/frontend/src/components/heaps/HeapsPanel.tsx
@@ -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() {
)}
- {/* 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 && (
@@ -487,39 +507,62 @@ export function HeapsPanel() {
{sharedHeaps.map((sh) => {
const isFiltered = currentSection === `heap-${sh.id}`
+ const PermissionIcon = sh.permission === 'write' ? Pencil : Eye
return (
-
- navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
- }
- >
-
-
- {sh.name}
-
-
- {sh.owner_username}
-
-
- {sh.permission}
-
- {sh.photo_count > 0 && (
-
- {sh.photo_count}
-
- )}
-
+
+
+
+ navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
+ }
+ >
+
+
+ {sh.name}
+
+
+ {sh.photo_count > 0 && (
+
+ {sh.photo_count}
+
+ )}
+
+
+
+
+ navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
+ }
+ >
+
+ Open
+
+
+ {
+ 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))
+ }
+ }}
+ >
+
+ Leave
+
+
+
)
})}
diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx
index 25e537d..a4d8910 100644
--- a/frontend/src/components/layout/LeftSidebar.tsx
+++ b/frontend/src/components/layout/LeftSidebar.tsx
@@ -49,7 +49,20 @@ import type { Photo } from '../../types/photo'
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
import { ShareDialog } from '../sharing/ShareDialog'
import { UploadModal } from '../upload/UploadModal'
-import { useSharedFoldersQuery } from '../../hooks/useSharingQueries'
+import { sharing as sharingApi } from '../../services/api'
+import {
+ useSharedFoldersQuery,
+ SHARED_FOLDERS_KEY,
+} from '../../hooks/useSharingQueries'
+import { NotificationBell } from '../sharing/NotificationBell'
+import { Avatar } from '../sharing/Avatar'
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from '@/components/ui/context-menu'
import { useAuth } from '../../contexts/AuthContext'
import { useFeaturesQuery } from '../../hooks/useFeaturesQuery'
import { useScanActivity } from '../../hooks/useScanActivity'
@@ -860,7 +873,11 @@ export function LeftSidebar() {
{libraryTree.map((item) => renderTreeItem(item))}
- {/* Shared with me — folders shared by other users */}
+ {/* Shared with me — folders shared by other users. Each row
+ * shows the owner's avatar bubble (same hash-tinted palette
+ * as ShareDialog + the notification bell) and an Eye/Pencil
+ * icon for permission, so the vocabulary stays consistent
+ * across every sharing surface. */}
{sharedFolders.length > 0 && (
@@ -868,39 +885,62 @@ export function LeftSidebar() {
{sharedFolders.map((sf) => {
const isSelected = currentSection === `folder-${sf.id}`
+ const PermissionIcon = sf.permission === 'write' ? Pencil : Eye
return (
-
- navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
- }
- >
-
-
- {sf.name}
-
-
- {sf.owner_username}
-
-
- {sf.permission}
-
- {sf.photo_count > 0 && (
-
- {sf.photo_count}
-
- )}
-
+
+
+
+ navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
+ }
+ >
+
+
+ {sf.name}
+
+
+ {sf.photo_count > 0 && (
+
+ {sf.photo_count}
+
+ )}
+
+
+
+
+ navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
+ }
+ >
+
+ Open
+
+
+ {
+ try {
+ await sharingApi.revokeFolderShare(sf.id, sf.share_id)
+ queryClient.invalidateQueries({ queryKey: SHARED_FOLDERS_KEY })
+ toast.success(`Left ${sf.name}`)
+ } catch (err) {
+ toast.error('Could not leave', formatApiError(err))
+ }
+ }}
+ >
+
+ Leave
+
+
+
)
})}
@@ -922,6 +962,11 @@ export function LeftSidebar() {
)}
+ {/* Share-invite bell — click opens a popover listing any
+ * pending invites this user has. Sits here (rather than the
+ * TopBar) per the user's preference to keep the identity
+ * controls grouped. */}
+
+ {initials}
+
+ )
+}
diff --git a/frontend/src/components/sharing/NotificationBell.tsx b/frontend/src/components/sharing/NotificationBell.tsx
new file mode 100644
index 0000000..a680527
--- /dev/null
+++ b/frontend/src/components/sharing/NotificationBell.tsx
@@ -0,0 +1,151 @@
+import { useMemo, useState } from 'react'
+import { Bell, Layers, Folder, Eye, Pencil, Check, X, Loader2 } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+ usePendingSharesQuery,
+ useAcceptShareMutation,
+ useDeclineShareMutation,
+} from '../../hooks/useSharingQueries'
+import { toast } from '../ToastContainer'
+import type { PendingShare } from '../../services/api'
+import { Avatar } from './Avatar'
+
+/** Share invite notification bell. Sits in the LeftSidebar user
+ * section — polls /sharing/pending every 60s and shows a red-orange
+ * count badge when there are pending invites. Click opens a popover
+ * listing each invite with Accept / Decline inline. */
+export function NotificationBell() {
+ const [open, setOpen] = useState(false)
+ const { data } = usePendingSharesQuery()
+ const acceptMutation = useAcceptShareMutation()
+ const declineMutation = useDeclineShareMutation()
+
+ const invites = useMemo(() => {
+ if (!data) return [] as Array
+ return [
+ ...data.heaps.map((h) => ({ ...h, kind: 'heap' as const })),
+ ...data.folders.map((f) => ({ ...f, kind: 'folder' as const })),
+ ]
+ }, [data])
+
+ const count = invites.length
+ const isMutating = acceptMutation.isPending || declineMutation.isPending
+
+ const handleAccept = (invite: PendingShare & { kind: 'heap' | 'folder' }) => {
+ acceptMutation.mutate(
+ { kind: invite.kind, targetId: invite.target_id },
+ {
+ onSuccess: () => {
+ toast.success(`Joined ${invite.target_name}`)
+ },
+ },
+ )
+ }
+ const handleDecline = (invite: PendingShare & { kind: 'heap' | 'folder' }) => {
+ declineMutation.mutate({ kind: invite.kind, targetId: invite.target_id })
+ }
+
+ return (
+
+
+ 0 && 'text-text',
+ )}
+ aria-label={count > 0 ? `${count} pending invite${count === 1 ? '' : 's'}` : 'Notifications'}
+ title={count > 0 ? `${count} pending invite${count === 1 ? '' : 's'}` : 'No pending invites'}
+ >
+
+ {count > 0 && (
+
+ {count}
+
+ )}
+
+
+
+
+
Pending invites
+ {count > 0 && (
+
{count}
+ )}
+
+
+ {count === 0 ? (
+
+ No pending invites.
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/sharing/ShareDialog.tsx b/frontend/src/components/sharing/ShareDialog.tsx
index 0595d87..20b29f9 100644
--- a/frontend/src/components/sharing/ShareDialog.tsx
+++ b/frontend/src/components/sharing/ShareDialog.tsx
@@ -2,6 +2,7 @@ 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,
@@ -260,8 +261,18 @@ export function ShareDialog({
>
-
- {share.shared_with_username}
+
+
+ {share.shared_with_username}
+
+ {share.status === 'pending' && (
+
+ Invited
+
+ )}
{share.permission === 'write' ? (
@@ -303,35 +314,3 @@ 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, 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 = [
- '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-8 w-8 text-[11px]'
- return (
-
- {initials}
-
- )
-}
diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx
new file mode 100644
index 0000000..fb72142
--- /dev/null
+++ b/frontend/src/components/ui/context-menu.tsx
@@ -0,0 +1,76 @@
+import * as React from 'react'
+import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
+
+import { cn } from '@/lib/utils'
+
+const ContextMenu = ContextMenuPrimitive.Root
+const ContextMenuTrigger = ContextMenuPrimitive.Trigger
+const ContextMenuGroup = ContextMenuPrimitive.Group
+const ContextMenuPortal = ContextMenuPrimitive.Portal
+
+const ContextMenuContent = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
+
+const ContextMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
+
+const ContextMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
+
+const ContextMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
+
+export {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuLabel,
+ ContextMenuGroup,
+ ContextMenuPortal,
+}
diff --git a/frontend/src/hooks/useSharingQueries.ts b/frontend/src/hooks/useSharingQueries.ts
index 26516ee..0815899 100644
--- a/frontend/src/hooks/useSharingQueries.ts
+++ b/frontend/src/hooks/useSharingQueries.ts
@@ -1,8 +1,17 @@
-import { useQuery } from '@tanstack/react-query'
-import { sharing, type SharedHeap, type SharedFolder } from '../services/api'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+ sharing,
+ type PendingSharesResponse,
+ type SharedFolder,
+ type SharedHeap,
+} from '../services/api'
+import { useAuth } from '../contexts/AuthContext'
+import { formatApiError } from '../lib/apiError'
+import { toast } from '../components/ToastContainer'
export const SHARED_HEAPS_KEY = ['sharing', 'heaps'] as const
export const SHARED_FOLDERS_KEY = ['sharing', 'folders'] as const
+export const PENDING_SHARES_KEY = ['sharing', 'pending'] as const
export function useSharedHeapsQuery() {
return useQuery({
@@ -19,3 +28,76 @@ export function useSharedFoldersQuery() {
staleTime: 30_000,
})
}
+
+/** Pending invites powering the notification bell in the sidebar.
+ * Polls at 60s — invites don't need real-time, and the user can also
+ * manually trigger the popover to force a refetch. Gated on the
+ * auth'd user so we don't hammer /sharing/pending from the login
+ * screen before any tokens are available. */
+export function usePendingSharesQuery() {
+ const { user } = useAuth()
+ return useQuery({
+ queryKey: PENDING_SHARES_KEY,
+ queryFn: sharing.pendingShares,
+ enabled: !!user,
+ staleTime: 30_000,
+ refetchInterval: 60_000,
+ // Keep the stale data visible while the refetch is in flight —
+ // prevents the bell badge from flickering to 0 on every poll.
+ placeholderData: (prev) => prev,
+ })
+}
+
+interface ShareAction {
+ kind: 'heap' | 'folder'
+ targetId: string
+}
+
+/** Invalidate both the pending-list and the accepted-list keys after
+ * accept/decline/revoke-self so the bell count and the sidebar
+ * "Shared with me" section update together. */
+function invalidateShareKeys(queryClient: ReturnType) {
+ queryClient.invalidateQueries({ queryKey: PENDING_SHARES_KEY })
+ queryClient.invalidateQueries({ queryKey: SHARED_HEAPS_KEY })
+ queryClient.invalidateQueries({ queryKey: SHARED_FOLDERS_KEY })
+}
+
+export function useAcceptShareMutation() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: ({ kind, targetId }: ShareAction) =>
+ kind === 'heap'
+ ? sharing.acceptHeapShare(targetId)
+ : sharing.acceptFolderShare(targetId),
+ onSuccess: () => invalidateShareKeys(queryClient),
+ onError: (err: any) => {
+ // 404 = the owner revoked (or we raced a previous decline).
+ // Surface a softer message and refresh the list instead of a
+ // hard error toast.
+ if (err?.response?.status === 404) {
+ toast.info('Invite no longer available', 'The sender may have revoked it.')
+ invalidateShareKeys(queryClient)
+ return
+ }
+ toast.error('Could not accept', formatApiError(err))
+ },
+ })
+}
+
+export function useDeclineShareMutation() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: ({ kind, targetId }: ShareAction) =>
+ kind === 'heap'
+ ? sharing.declineHeapShare(targetId)
+ : sharing.declineFolderShare(targetId),
+ onSuccess: () => invalidateShareKeys(queryClient),
+ onError: (err: any) => {
+ if (err?.response?.status === 404) {
+ invalidateShareKeys(queryClient)
+ return
+ }
+ toast.error('Could not decline', formatApiError(err))
+ },
+ })
+}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 0facf51..b40a48d 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -785,6 +785,9 @@ export const downloads = {
export interface SharedHeap {
id: string
+ /** heap_shares row id — used by the "Leave" action to call the
+ * existing DELETE /sharing/heaps/{heap_id}/{share_id} endpoint. */
+ share_id: string
name: string
owner_username: string
permission: 'read' | 'write'
@@ -793,6 +796,7 @@ export interface SharedHeap {
export interface SharedFolder {
id: string
+ share_id: string
name: string
folder_type: 'folder' | 'source_root'
owner_username: string
@@ -805,6 +809,9 @@ export interface ShareInfo {
shared_with_id: string
shared_with_username: string
permission: string
+ /** 'pending' until the recipient accepts in the notification bell,
+ * then 'accepted'. Decline deletes the row outright. */
+ status: 'pending' | 'accepted'
created_at: string
}
@@ -813,6 +820,23 @@ export interface ShareableUser {
username: string
}
+/** One entry in the combined pending-shares response. `target_id` is
+ * the heap or folder id — heap vs folder is distinguished by which
+ * bucket (`heaps` or `folders`) the entry appears in. */
+export interface PendingShare {
+ share_id: string
+ target_id: string
+ target_name: string
+ owner_username: string
+ permission: 'read' | 'write'
+ created_at: string
+}
+
+export interface PendingSharesResponse {
+ heaps: PendingShare[]
+ folders: PendingShare[]
+}
+
export const sharing = {
// Shareable user directory for the share-dialog picker.
listUsers: async (): Promise => {
@@ -853,6 +877,27 @@ export const sharing = {
const response = await api.get('/sharing/folders/shared-with-me')
return response.data
},
+
+ // Acceptance flow — pending invites + accept/decline. The notification
+ // bell polls pendingShares; Accept/Decline call the respective
+ // endpoint with just the target id (backend resolves the share row
+ // from (target_id, current_user)).
+ pendingShares: async (): Promise => {
+ const response = await api.get('/sharing/pending')
+ return response.data
+ },
+ acceptHeapShare: async (heapId: string) => {
+ await api.post(`/sharing/heaps/${heapId}/accept`)
+ },
+ declineHeapShare: async (heapId: string) => {
+ await api.post(`/sharing/heaps/${heapId}/decline`)
+ },
+ acceptFolderShare: async (folderId: string) => {
+ await api.post(`/sharing/folders/${folderId}/accept`)
+ },
+ declineFolderShare: async (folderId: string) => {
+ await api.post(`/sharing/folders/${folderId}/decline`)
+ },
}
// Tags API