From b91bc89c5810431f92b7d1e2d30ba6ccde53e126 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:40:33 +0200 Subject: [PATCH] =?UTF-8?q?refactor(web):=20RegistrationPanel=20InviteRow?= =?UTF-8?q?=20=E2=86=92=20collapsed/expanded=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RegistrationPanel.tsx | 342 +++++++++++------- 1 file changed, 206 insertions(+), 136 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index 76dce4fa..2efc19e6 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -974,62 +974,34 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) { interface InviteRowProps { invite: InviteLinkSummary; + expanded: boolean; + onToggleExpand: () => void; onMutate: () => void; } /** - * One row in the invite list. Owns its own modal/popover/confirm-dialog state so the - * parent panel only deals with the list-level fetch + tab state. + * One row in the invite list. Renders as a clickable collapsed header that, when + * expanded, reveals a meta grid + status-specific action row. Owns its own modal + * and confirm-dialog state so the parent panel only manages list-level fetch + + * single-row expansion state (`expandedInviteId`). * * Action surface depends on `invite.status`: - * - active → Copy link · Edit · Revoke · ⋯ (View redemptions, Delete) - * - non-active → Reinstate · ⋯ (View redemptions, Delete) - * - * The kebab popover dismisses on outside click and Escape; clicks inside its items - * already call `setShowKebab(false)` before triggering the action. + * - active → Copy link · Edit · Revoke · View redemptions + * - non-active → Reinstate · Delete permanently · View redemptions */ -function InviteRow({ invite, onMutate }: InviteRowProps) { +function InviteRow({ invite, expanded, onToggleExpand, onMutate }: InviteRowProps) { const addToast = useUIStore((s) => s.addToast); const [showEdit, setShowEdit] = useState(false); const [showReinstate, setShowReinstate] = useState(false); const [showRedemptions, setShowRedemptions] = useState(false); - const [showKebab, setShowKebab] = useState(false); const [confirmRevoke, setConfirmRevoke] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const [actionLoading, setActionLoading] = useState(false); - const kebabContainerRef = useRef(null); - - // Dismiss kebab popover on outside click + Escape. Mirrors the pattern used in - // TransferOwnershipModal — listen on document, check containment via ref. - useEffect(() => { - if (!showKebab) return; - const handleMouseDown = (e: MouseEvent) => { - if ( - kebabContainerRef.current && - !kebabContainerRef.current.contains(e.target as Node) - ) { - setShowKebab(false); - } - }; - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - // Stop propagation so the parent settings modal doesn't ALSO close. - e.stopPropagation(); - setShowKebab(false); - } - }; - document.addEventListener('mousedown', handleMouseDown); - document.addEventListener('keydown', handleKey, true); - return () => { - document.removeEventListener('mousedown', handleMouseDown); - document.removeEventListener('keydown', handleKey, true); - }; - }, [showKebab]); const usageLabel = invite.maxUses === null - ? `${invite.usedCount} use${invite.usedCount === 1 ? '' : 's'} · unlimited` - : `${invite.usedCount} / ${invite.maxUses} uses`; + ? `${invite.usedCount} / ∞` + : `${invite.usedCount} / ${invite.maxUses}`; const usageNearLimit = invite.maxUses !== null && invite.maxUses > 0 && invite.usedCount / invite.maxUses >= 0.8; @@ -1071,110 +1043,191 @@ function InviteRow({ invite, onMutate }: InviteRowProps) { } }; - const statusPillClass = - invite.status === 'expired' - ? 'bg-rose-500/20 text-rose-400' - : invite.status === 'exhausted' - ? 'bg-amber-500/20 text-amber-400' - : 'bg-white/10 text-txt-tertiary'; + const isActive = invite.status === 'active'; + const createdByLabel = invite.createdByUsername ?? 'Unknown'; + + // Subtitle (collapsed view) + // active → "X / Y uses · Expires in 3 days · Created by alice" + // archived → "X / Y uses · Revoked 4/12/2026 · 2d ago" + const subtitle = isActive + ? `${usageLabel} uses · ${formatExpiry(invite)} · Created by ${createdByLabel}` + : `${usageLabel} uses · ${formatExpiry(invite)} · ${formatRelative(invite.createdAt)}`; + + // Archived row 1 second-cell label + value. EXHAUSTED has no dedicated terminal + // timestamp on the invite, so we surface lastRedeemedAt (the moment that drove + // it to exhausted) when known, falling back to em-dash if absent. + let archivedTerminalLabel: string; + let archivedTerminalValue: string; + if (invite.status === 'expired') { + archivedTerminalLabel = 'EXPIRED AT'; + archivedTerminalValue = invite.expiresAt !== null ? formatRelative(invite.expiresAt) : '—'; + } else if (invite.status === 'revoked') { + archivedTerminalLabel = 'REVOKED AT'; + archivedTerminalValue = invite.revokedAt !== null ? formatRelative(invite.revokedAt) : '—'; + } else { + // exhausted + archivedTerminalLabel = 'EXHAUSTED'; + archivedTerminalValue = + invite.lastRedeemedAt !== null ? formatRelative(invite.lastRedeemedAt) : '—'; + } + + const tokenDisplay = `…${invite.token.slice(-6)}`; + const lastRedeemedDisplay = + invite.lastRedeemedAt !== null ? formatRelative(invite.lastRedeemedAt) : '—'; return ( <> -
-
-
- - {invite.name} - - - · {usageLabel} - - {invite.status !== 'active' && ( - + {/* Collapsed clickable header */} +
+
+
+
+
- {invite.status} + {invite.name} +
+
{subtitle}
+
+
+
+ {!isActive && ( + + {inviteStatusLabel(invite.status)} )} -
-
- {formatExpiry(invite)} · Created by {invite.createdByUsername ?? 'Unknown'} ·{' '} - {formatRelative(invite.createdAt)} + {expanded ? '▾' : '▸'}
-
- {invite.status === 'active' ? ( - <> - - - - - ) : ( - - )} - - {showKebab && ( -
- - + {/* Expanded body */} + {expanded && ( +
+
+ {/* Row 1: USED · (EXPIRES | terminal-status AT) · CREATED */} +
+
+
Used
+
+ {usageLabel} +
+
+ {isActive ? ( +
+
Expires
+
{formatExpiry(invite)}
+
+ ) : ( +
+
+ {archivedTerminalLabel} +
+
{archivedTerminalValue}
+
+ )} +
+
Created
+
{formatRelative(invite.createdAt)}
+
+
+ + {/* Row 2: CREATED BY · TOKEN · LAST REDEEMED */} +
+
+
Created by
+
+ {createdByLabel} +
+
+
+
Token
+
+ {tokenDisplay} +
+
+
+
Last redeemed
+
{lastRedeemedDisplay}
+
+
+ + {/* Action row */} +
+ {isActive ? ( + <> + + + + + + ) : ( + <> + + + + + )} +
- )} -
+
+ )}
{showEdit && ( @@ -1247,6 +1300,15 @@ export function RegistrationPanel() { const [invitesLoading, setInvitesLoading] = useState(false); const [activeCount, setActiveCount] = useState(0); const [archivedCount, setArchivedCount] = useState(0); + // Single-row expansion state — only one InviteRow at a time may be expanded. + // Lifted to the panel so switching tabs can reset it; otherwise an expanded row + // that scrolls out of the visible list keeps stale state. + const [expandedInviteId, setExpandedInviteId] = useState(null); + + const handleTabSwitch = (next: 'active' | 'archived') => { + setTab(next); + setExpandedInviteId(null); + }; // Tracks the currently displayed tab so in-flight fetches can detect when // the user has switched tabs and discard their stale response. Without this @@ -1408,7 +1470,7 @@ export function RegistrationPanel() {