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:
67
backend/alembic/versions/0014_share_status.py
Normal file
67
backend/alembic/versions/0014_share_status.py
Normal file
@@ -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")
|
||||||
@@ -24,12 +24,19 @@ class HeapShare(Base):
|
|||||||
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
|
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
|
||||||
shared_with_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'
|
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())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
accepted_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("heap_id", "shared_with_id", name="uq_heap_share"),
|
UniqueConstraint("heap_id", "shared_with_id", name="uq_heap_share"),
|
||||||
Index("ix_heap_shares_shared_with", "shared_with_id"),
|
Index("ix_heap_shares_shared_with", "shared_with_id"),
|
||||||
Index("ix_heap_shares_heap_id", "heap_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)
|
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
|
||||||
shared_with_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'
|
permission = Column(String, nullable=False, default="read") # 'read' | 'write'
|
||||||
|
status = Column(String, nullable=False, default="pending")
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
accepted_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("folder_id", "shared_with_id", name="uq_folder_share"),
|
UniqueConstraint("folder_id", "shared_with_id", name="uq_folder_share"),
|
||||||
Index("ix_folder_shares_shared_with", "shared_with_id"),
|
Index("ix_folder_shares_shared_with", "shared_with_id"),
|
||||||
Index("ix_folder_shares_folder_id", "folder_id"),
|
Index("ix_folder_shares_folder_id", "folder_id"),
|
||||||
|
Index("ix_folder_shares_shared_with_status", "shared_with_id", "status"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ class ShareResponse(BaseModel):
|
|||||||
shared_with_id: str
|
shared_with_id: str
|
||||||
shared_with_username: str
|
shared_with_username: str
|
||||||
permission: str
|
permission: str
|
||||||
|
status: str # 'pending' | 'accepted'
|
||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
class SharedHeapResponse(BaseModel):
|
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
|
id: str
|
||||||
|
share_id: str
|
||||||
name: str
|
name: str
|
||||||
owner_username: str
|
owner_username: str
|
||||||
permission: str
|
permission: str
|
||||||
@@ -52,6 +57,7 @@ class SharedHeapResponse(BaseModel):
|
|||||||
|
|
||||||
class SharedFolderResponse(BaseModel):
|
class SharedFolderResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
share_id: str
|
||||||
name: str
|
name: str
|
||||||
folder_type: str
|
folder_type: str
|
||||||
owner_username: str
|
owner_username: str
|
||||||
@@ -59,6 +65,22 @@ class SharedFolderResponse(BaseModel):
|
|||||||
photo_count: int
|
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):
|
class ShareableUser(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
username: 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 ─────────────────────────────────────────────────────────
|
# ── Heap sharing ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/heaps/shared-with-me")
|
@router.get("/heaps/shared-with-me")
|
||||||
@@ -94,12 +179,14 @@ async def list_shared_heaps(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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(
|
result = await db.execute(
|
||||||
select(HeapShare, Heap, User)
|
select(HeapShare, Heap, User)
|
||||||
.join(Heap, HeapShare.heap_id == Heap.id)
|
.join(Heap, HeapShare.heap_id == Heap.id)
|
||||||
.join(User, HeapShare.owner_id == User.id)
|
.join(User, HeapShare.owner_id == User.id)
|
||||||
.where(HeapShare.shared_with_id == current_user.id)
|
.where(HeapShare.shared_with_id == current_user.id)
|
||||||
|
.where(HeapShare.status == "accepted")
|
||||||
)
|
)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
@@ -115,6 +202,7 @@ async def list_shared_heaps(
|
|||||||
|
|
||||||
items.append(SharedHeapResponse(
|
items.append(SharedHeapResponse(
|
||||||
id=heap.id,
|
id=heap.id,
|
||||||
|
share_id=share.id,
|
||||||
name=heap.name,
|
name=heap.name,
|
||||||
owner_username=owner.username,
|
owner_username=owner.username,
|
||||||
permission=share.permission,
|
permission=share.permission,
|
||||||
@@ -143,6 +231,7 @@ async def list_heap_shares(
|
|||||||
shared_with_id=user.id,
|
shared_with_id=user.id,
|
||||||
shared_with_username=user.username,
|
shared_with_username=user.username,
|
||||||
permission=share.permission,
|
permission=share.permission,
|
||||||
|
status=share.status,
|
||||||
created_at=share.created_at.isoformat() if share.created_at else "",
|
created_at=share.created_at.isoformat() if share.created_at else "",
|
||||||
)
|
)
|
||||||
for share, user in result.all()
|
for share, user in result.all()
|
||||||
@@ -186,6 +275,54 @@ async def share_heap(
|
|||||||
return {"status": "shared", "share_id": share.id}
|
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)
|
@router.delete("/heaps/{heap_id}/{share_id}", status_code=204)
|
||||||
async def revoke_heap_share(
|
async def revoke_heap_share(
|
||||||
heap_id: str,
|
heap_id: str,
|
||||||
@@ -217,11 +354,14 @@ async def list_shared_folders(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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(
|
result = await db.execute(
|
||||||
select(FolderShare, User)
|
select(FolderShare, User)
|
||||||
.join(User, FolderShare.owner_id == User.id)
|
.join(User, FolderShare.owner_id == User.id)
|
||||||
.where(FolderShare.shared_with_id == current_user.id)
|
.where(FolderShare.shared_with_id == current_user.id)
|
||||||
|
.where(FolderShare.status == "accepted")
|
||||||
)
|
)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
@@ -270,6 +410,7 @@ async def list_shared_folders(
|
|||||||
count = count_result.scalar() or 0
|
count = count_result.scalar() or 0
|
||||||
items.append(SharedFolderResponse(
|
items.append(SharedFolderResponse(
|
||||||
id=share.folder_id,
|
id=share.folder_id,
|
||||||
|
share_id=share.id,
|
||||||
name=name,
|
name=name,
|
||||||
folder_type=share.folder_type,
|
folder_type=share.folder_type,
|
||||||
owner_username=owner.username,
|
owner_username=owner.username,
|
||||||
@@ -314,6 +455,7 @@ async def list_folder_shares(
|
|||||||
shared_with_id=user.id,
|
shared_with_id=user.id,
|
||||||
shared_with_username=user.username,
|
shared_with_username=user.username,
|
||||||
permission=share.permission,
|
permission=share.permission,
|
||||||
|
status=share.status,
|
||||||
created_at=share.created_at.isoformat() if share.created_at else "",
|
created_at=share.created_at.isoformat() if share.created_at else "",
|
||||||
)
|
)
|
||||||
for share, user in result.all()
|
for share, user in result.all()
|
||||||
@@ -370,6 +512,50 @@ async def share_folder(
|
|||||||
return {"status": "shared", "share_id": share.id}
|
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)
|
@router.delete("/folders/{folder_id}/{share_id}", status_code=204)
|
||||||
async def revoke_folder_share(
|
async def revoke_folder_share(
|
||||||
folder_id: str,
|
folder_id: str,
|
||||||
|
|||||||
@@ -11,13 +11,31 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
|
Eye,
|
||||||
|
LogOut,
|
||||||
Download as DownloadIcon,
|
Download as DownloadIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
import { useSharedHeapsQuery } from '../../hooks/useSharingQueries'
|
import {
|
||||||
import { heaps as heapsApi, downloads, type Heap } from '../../services/api'
|
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 { useFilterStore } from '../../store/filterStore'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
@@ -479,7 +497,9 @@ export function HeapsPanel() {
|
|||||||
</div>
|
</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 && (
|
{sharedHeaps.length > 0 && expanded && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<div className="px-3 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-text-faint">
|
<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>
|
</div>
|
||||||
{sharedHeaps.map((sh) => {
|
{sharedHeaps.map((sh) => {
|
||||||
const isFiltered = currentSection === `heap-${sh.id}`
|
const isFiltered = currentSection === `heap-${sh.id}`
|
||||||
|
const PermissionIcon = sh.permission === 'write' ? Pencil : Eye
|
||||||
return (
|
return (
|
||||||
<div
|
<ContextMenu key={sh.id}>
|
||||||
key={sh.id}
|
<ContextMenuTrigger asChild>
|
||||||
className={cn(
|
<div
|
||||||
'group flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
className={cn(
|
||||||
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
'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={() =>
|
style={{ paddingLeft: '20px' }}
|
||||||
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
|
onClick={() =>
|
||||||
}
|
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
|
||||||
>
|
}
|
||||||
<Users
|
>
|
||||||
className={cn(
|
<Avatar name={sh.owner_username} size="xs" />
|
||||||
'h-3.5 w-3.5 flex-shrink-0',
|
<span className="truncate" title={`${sh.name} (shared by ${sh.owner_username})`}>
|
||||||
isFiltered ? 'text-primary' : 'text-text-muted'
|
{sh.name}
|
||||||
)}
|
</span>
|
||||||
/>
|
<PermissionIcon
|
||||||
<span className="truncate" title={sh.name}>
|
className="ml-auto h-3 w-3 flex-shrink-0 text-text-muted"
|
||||||
{sh.name}
|
aria-label={sh.permission === 'write' ? 'Can edit' : 'Can view'}
|
||||||
</span>
|
/>
|
||||||
<span className="ml-0.5 truncate text-[10px] text-text-faint">
|
{sh.photo_count > 0 && (
|
||||||
{sh.owner_username}
|
<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">
|
||||||
</span>
|
{sh.photo_count}
|
||||||
<span className="ml-auto rounded bg-surface-offset px-1 text-[9px] uppercase text-text-faint">
|
</span>
|
||||||
{sh.permission}
|
)}
|
||||||
</span>
|
</div>
|
||||||
{sh.photo_count > 0 && (
|
</ContextMenuTrigger>
|
||||||
<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">
|
<ContextMenuContent>
|
||||||
{sh.photo_count}
|
<ContextMenuItem
|
||||||
</span>
|
onSelect={() =>
|
||||||
)}
|
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,20 @@ import type { Photo } from '../../types/photo'
|
|||||||
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
||||||
import { ShareDialog } from '../sharing/ShareDialog'
|
import { ShareDialog } from '../sharing/ShareDialog'
|
||||||
import { UploadModal } from '../upload/UploadModal'
|
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 { useAuth } from '../../contexts/AuthContext'
|
||||||
import { useFeaturesQuery } from '../../hooks/useFeaturesQuery'
|
import { useFeaturesQuery } from '../../hooks/useFeaturesQuery'
|
||||||
import { useScanActivity } from '../../hooks/useScanActivity'
|
import { useScanActivity } from '../../hooks/useScanActivity'
|
||||||
@@ -860,7 +873,11 @@ export function LeftSidebar() {
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
|
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
|
||||||
{libraryTree.map((item) => renderTreeItem(item))}
|
{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 && (
|
{sharedFolders.length > 0 && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
||||||
@@ -868,39 +885,62 @@ export function LeftSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
{sharedFolders.map((sf) => {
|
{sharedFolders.map((sf) => {
|
||||||
const isSelected = currentSection === `folder-${sf.id}`
|
const isSelected = currentSection === `folder-${sf.id}`
|
||||||
|
const PermissionIcon = sf.permission === 'write' ? Pencil : Eye
|
||||||
return (
|
return (
|
||||||
<div
|
<ContextMenu key={sf.id}>
|
||||||
key={sf.id}
|
<ContextMenuTrigger asChild>
|
||||||
className={cn(
|
<div
|
||||||
'flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
className={cn(
|
||||||
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
'flex h-[24px] cursor-pointer items-center gap-1.5 rounded px-2 text-[12px] leading-none',
|
||||||
)}
|
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||||
style={{ paddingLeft: '20px' }}
|
)}
|
||||||
onClick={() =>
|
style={{ paddingLeft: '20px' }}
|
||||||
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
onClick={() =>
|
||||||
}
|
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
||||||
>
|
}
|
||||||
<Users
|
>
|
||||||
className={cn(
|
<Avatar name={sf.owner_username} size="xs" />
|
||||||
'h-3.5 w-3.5 flex-shrink-0',
|
<span className="truncate" title={`${sf.name} (shared by ${sf.owner_username})`}>
|
||||||
isSelected ? 'text-primary' : 'text-text-muted'
|
{sf.name}
|
||||||
)}
|
</span>
|
||||||
/>
|
<PermissionIcon
|
||||||
<span className="truncate" title={sf.name}>
|
className="ml-auto h-3 w-3 flex-shrink-0 text-text-muted"
|
||||||
{sf.name}
|
aria-label={sf.permission === 'write' ? 'Can edit' : 'Can view'}
|
||||||
</span>
|
/>
|
||||||
<span className="ml-0.5 truncate text-[10px] text-text-faint">
|
{sf.photo_count > 0 && (
|
||||||
{sf.owner_username}
|
<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">
|
||||||
</span>
|
{sf.photo_count}
|
||||||
<span className="ml-auto rounded bg-surface-offset px-1 text-[9px] uppercase text-text-faint">
|
</span>
|
||||||
{sf.permission}
|
)}
|
||||||
</span>
|
</div>
|
||||||
{sf.photo_count > 0 && (
|
</ContextMenuTrigger>
|
||||||
<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">
|
<ContextMenuContent>
|
||||||
{sf.photo_count}
|
<ContextMenuItem
|
||||||
</span>
|
onSelect={() =>
|
||||||
)}
|
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
|
<Folder 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.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))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
|
Leave
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -922,6 +962,11 @@ export function LeftSidebar() {
|
|||||||
<Shield className="inline h-2.5 w-2.5" />
|
<Shield className="inline h-2.5 w-2.5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* 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. */}
|
||||||
|
<NotificationBell />
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-reject flex-shrink-0"
|
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-reject flex-shrink-0"
|
||||||
|
|||||||
54
frontend/src/components/sharing/Avatar.tsx
Normal file
54
frontend/src/components/sharing/Avatar.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/** Hash-tinted initial bubble used across every sharing surface
|
||||||
|
* (ShareDialog, NotificationBell, sidebar shared rows). The tint
|
||||||
|
* isn't meaningful — it's just an identity cue so a list of names
|
||||||
|
* feels less anonymous. Using the same hash across components means
|
||||||
|
* a given user's bubble stays the same colour everywhere. */
|
||||||
|
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',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function avatarColor(name: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = (hash * 31 + name.charCodeAt(i)) | 0
|
||||||
|
}
|
||||||
|
return PALETTE[Math.abs(hash) % PALETTE.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({
|
||||||
|
name,
|
||||||
|
size = 'md',
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
size?: 'xs' | 'sm' | 'md'
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const initials = name.slice(0, 2).toUpperCase()
|
||||||
|
const tint = avatarColor(name)
|
||||||
|
const dims =
|
||||||
|
size === 'xs'
|
||||||
|
? 'h-4 w-4 text-[8px]'
|
||||||
|
: size === 'sm'
|
||||||
|
? 'h-5 w-5 text-[9px]'
|
||||||
|
: 'h-8 w-8 text-[11px]'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex shrink-0 items-center justify-center rounded-full font-semibold uppercase tracking-wide',
|
||||||
|
dims,
|
||||||
|
tint,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
151
frontend/src/components/sharing/NotificationBell.tsx
Normal file
151
frontend/src/components/sharing/NotificationBell.tsx
Normal file
@@ -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<PendingShare & { kind: 'heap' | 'folder' }>
|
||||||
|
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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-7 w-7 items-center justify-center rounded-md text-text-muted transition-colors',
|
||||||
|
'hover:bg-surface-2 hover:text-text',
|
||||||
|
count > 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'}
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 inline-flex min-w-[14px] items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold leading-[14px] tabular-nums text-black shadow-[0_0_0_1.5px_var(--bg,theme(colors.bg))]">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" sideOffset={6} className="w-80 p-0">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||||
|
<div className="text-sm font-semibold text-text">Pending invites</div>
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="text-[11px] tabular-nums text-text-faint">{count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{count === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-xs text-text-muted">
|
||||||
|
No pending invites.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-80 divide-y divide-border overflow-y-auto">
|
||||||
|
{invites.map((invite) => {
|
||||||
|
const TypeIcon = invite.kind === 'heap' ? Layers : Folder
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${invite.kind}:${invite.share_id}`}
|
||||||
|
className="flex flex-col gap-2 px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Avatar name={invite.owner_username} size="sm" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm leading-tight text-text">
|
||||||
|
<span className="font-medium">{invite.owner_username}</span>
|
||||||
|
<span className="text-text-muted"> shared </span>
|
||||||
|
<span className="inline-flex items-center gap-1 align-baseline">
|
||||||
|
<TypeIcon className="inline h-3 w-3 text-text-muted" />
|
||||||
|
<span className="font-medium">{invite.target_name}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-text-muted">
|
||||||
|
{invite.permission === 'write' ? (
|
||||||
|
<>
|
||||||
|
<Pencil className="h-2.5 w-2.5" />
|
||||||
|
Can edit
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="h-2.5 w-2.5" />
|
||||||
|
Can view
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isMutating}
|
||||||
|
onClick={() => handleDecline(invite)}
|
||||||
|
className="h-7 text-text-muted hover:bg-reject/10 hover:text-reject"
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={isMutating}
|
||||||
|
onClick={() => handleAccept(invite)}
|
||||||
|
className="h-7"
|
||||||
|
>
|
||||||
|
{acceptMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="mr-1 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Users, Eye, Pencil, X, Loader2 } from 'lucide-react'
|
import { Users, Eye, Pencil, X, Loader2 } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
import {
|
import {
|
||||||
sharing,
|
sharing,
|
||||||
type ShareInfo,
|
type ShareInfo,
|
||||||
@@ -260,8 +261,18 @@ export function ShareDialog({
|
|||||||
>
|
>
|
||||||
<Avatar name={share.shared_with_username} />
|
<Avatar name={share.shared_with_username} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium text-text">
|
<div className="flex items-center gap-1.5">
|
||||||
{share.shared_with_username}
|
<span className="truncate text-sm font-medium text-text">
|
||||||
|
{share.shared_with_username}
|
||||||
|
</span>
|
||||||
|
{share.status === 'pending' && (
|
||||||
|
<span
|
||||||
|
className="rounded-sm bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300"
|
||||||
|
title="Waiting for them to accept"
|
||||||
|
>
|
||||||
|
Invited
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-[11px] text-text-muted">
|
<div className="flex items-center gap-1 text-[11px] text-text-muted">
|
||||||
{share.permission === 'write' ? (
|
{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 (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex shrink-0 items-center justify-center rounded-full font-semibold uppercase tracking-wide',
|
|
||||||
dims,
|
|
||||||
tint
|
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
76
frontend/src/components/ui/context-menu.tsx
Normal file
76
frontend/src/components/ui/context-menu.tsx
Normal file
@@ -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<typeof ContextMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-surface p-1 text-text shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-text outline-none transition-colors focus:bg-surface-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const ContextMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const ContextMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-xs font-semibold text-text-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
}
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { sharing, type SharedHeap, type SharedFolder } from '../services/api'
|
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_HEAPS_KEY = ['sharing', 'heaps'] as const
|
||||||
export const SHARED_FOLDERS_KEY = ['sharing', 'folders'] as const
|
export const SHARED_FOLDERS_KEY = ['sharing', 'folders'] as const
|
||||||
|
export const PENDING_SHARES_KEY = ['sharing', 'pending'] as const
|
||||||
|
|
||||||
export function useSharedHeapsQuery() {
|
export function useSharedHeapsQuery() {
|
||||||
return useQuery<SharedHeap[]>({
|
return useQuery<SharedHeap[]>({
|
||||||
@@ -19,3 +28,76 @@ export function useSharedFoldersQuery() {
|
|||||||
staleTime: 30_000,
|
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<PendingSharesResponse>({
|
||||||
|
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<typeof useQueryClient>) {
|
||||||
|
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))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -785,6 +785,9 @@ export const downloads = {
|
|||||||
|
|
||||||
export interface SharedHeap {
|
export interface SharedHeap {
|
||||||
id: string
|
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
|
name: string
|
||||||
owner_username: string
|
owner_username: string
|
||||||
permission: 'read' | 'write'
|
permission: 'read' | 'write'
|
||||||
@@ -793,6 +796,7 @@ export interface SharedHeap {
|
|||||||
|
|
||||||
export interface SharedFolder {
|
export interface SharedFolder {
|
||||||
id: string
|
id: string
|
||||||
|
share_id: string
|
||||||
name: string
|
name: string
|
||||||
folder_type: 'folder' | 'source_root'
|
folder_type: 'folder' | 'source_root'
|
||||||
owner_username: string
|
owner_username: string
|
||||||
@@ -805,6 +809,9 @@ export interface ShareInfo {
|
|||||||
shared_with_id: string
|
shared_with_id: string
|
||||||
shared_with_username: string
|
shared_with_username: string
|
||||||
permission: 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
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,6 +820,23 @@ export interface ShareableUser {
|
|||||||
username: string
|
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 = {
|
export const sharing = {
|
||||||
// Shareable user directory for the share-dialog picker.
|
// Shareable user directory for the share-dialog picker.
|
||||||
listUsers: async (): Promise<ShareableUser[]> => {
|
listUsers: async (): Promise<ShareableUser[]> => {
|
||||||
@@ -853,6 +877,27 @@ export const sharing = {
|
|||||||
const response = await api.get('/sharing/folders/shared-with-me')
|
const response = await api.get('/sharing/folders/shared-with-me')
|
||||||
return response.data
|
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<PendingSharesResponse> => {
|
||||||
|
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
|
// Tags API
|
||||||
|
|||||||
Reference in New Issue
Block a user