feat(mobile): admin reach + post-mount routing + RegistrationPanel modals → Modal
MobileInstancePanel gains Registration and Federation entries (parity with desktop's six sub-tabs). MobileShell registers the matching screenMap wrappers; the Federation entry surfaces the live approval-count badge via a new uiStore slot and a wrapper that forwards FederationPanel's onApprovalCountChange. MobileShell's deep-link reconstruction now reacts to post-mount pathname changes (was [] / mount-only) with an idempotency guard that skips the push when the topmost stack entry already represents the new URL — prevents the pushMobileScreen → history.pushState → location-effect double-push. RegistrationPanel's four portaled modals (CreateInvite, EditInvite, ReinstateInvite, Redemptions) now render through the shared <Modal> with mobileStyle="fullscreen", portaled to document.body to escape the parent settings dialog's backdrop-filter containing block. Desktop appearance preserved (same maxWidth, same sticky action bar).
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useSettingsStore } from '../../stores/settingsStore';
|
||||
import { MobileScreenHeader } from './MobileScreenHeader';
|
||||
import { api } from '../../api/client';
|
||||
import { onFederationPeersChanged } from '../../hooks/useWebSocket';
|
||||
|
||||
const sections = [
|
||||
type SectionDef = {
|
||||
id: 'general' | 'registration' | 'federation' | 'streaming' | 'storage' | 'users';
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
const sections: SectionDef[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'General',
|
||||
@@ -14,6 +22,26 @@ const sections = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'registration',
|
||||
label: 'Registration',
|
||||
icon: (
|
||||
// Heroicon: ticket — represents invite-style/registration management
|
||||
<svg className="w-5 h-5 text-txt-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'federation',
|
||||
label: 'Federation',
|
||||
icon: (
|
||||
// Heroicon: globe-alt — represents cross-instance federation
|
||||
<svg className="w-5 h-5 text-txt-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'streaming',
|
||||
label: 'Streaming',
|
||||
@@ -43,6 +71,54 @@ const sections = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Federation approval-count badge source.
|
||||
*
|
||||
* Mirrors the desktop InstancePanel/FederationPanel contract: the count comes
|
||||
* from `api.federation.approvalRequests()` and is kept fresh by the
|
||||
* `onFederationPeersChanged` WebSocket signal that FederationPanel itself
|
||||
* subscribes to. We can't read FederationPanel's internal state from here, so
|
||||
* we fetch the same endpoint independently — and we wire MobileShell to forward
|
||||
* `onApprovalCountChange` callbacks from FederationPanel into a shared store
|
||||
* slot so the badge updates live while the admin is inside the panel.
|
||||
*/
|
||||
function useFederationApprovalCount(enabled: boolean) {
|
||||
const liveCount = useUIStore((s) => s.federationApprovalCount);
|
||||
const setLiveCount = useUIStore((s) => s.setFederationApprovalCount);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
const result = await api.federation.approvalRequests();
|
||||
setLiveCount(result.requests.length);
|
||||
} catch {
|
||||
// Silently ignore — badge simply won't update on transient failures
|
||||
}
|
||||
}, [enabled, setLiveCount]);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
// Re-fetch when peer state changes (mirrors FederationPanel's own listener)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const unsub = onFederationPeersChanged(() => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
refetch();
|
||||
}, 500);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
if (timeout) clearTimeout(timeout);
|
||||
};
|
||||
}, [enabled, refetch]);
|
||||
|
||||
return liveCount;
|
||||
}
|
||||
|
||||
export function MobileInstancePanel() {
|
||||
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||||
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
|
||||
@@ -55,23 +131,33 @@ export function MobileInstancePanel() {
|
||||
fetchStreamingLimits();
|
||||
}, [fetchInstanceSettings, fetchStreamingLimits]);
|
||||
|
||||
const approvalCount = useFederationApprovalCount(true);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<MobileScreenHeader title="Instance" />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => pushMobileScreen(`settings-instance-${section.id}`)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-interactive-hover text-left transition-colors"
|
||||
>
|
||||
{section.icon}
|
||||
<span className="text-sm text-txt-primary flex-1">{section.label}</span>
|
||||
<svg className="w-4 h-4 text-txt-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
{sections.map((section) => {
|
||||
const badge = section.id === 'federation' && approvalCount > 0 ? approvalCount : null;
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => pushMobileScreen(`settings-instance-${section.id}`)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-interactive-hover text-left transition-colors"
|
||||
>
|
||||
{section.icon}
|
||||
<span className="text-sm text-txt-primary flex-1">{section.label}</span>
|
||||
{badge !== null && (
|
||||
<span className="min-w-[18px] h-[18px] px-1.5 bg-notification text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
<svg className="w-4 h-4 text-txt-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,10 +20,30 @@ import { FriendsPage } from '../chat/FriendsPage';
|
||||
import { ExplorePage } from '../chat/ExplorePage';
|
||||
import { UserProfileModal } from '../modals/UserProfileModal';
|
||||
import { GeneralPanel } from '../modals/instanceSettingsPanels/GeneralPanel';
|
||||
import { RegistrationPanel } from '../modals/instanceSettingsPanels/RegistrationPanel';
|
||||
import { FederationPanel } from '../modals/instanceSettingsPanels/FederationPanel';
|
||||
import { StreamingPanel } from '../modals/instanceSettingsPanels/StreamingPanel';
|
||||
import { StoragePanel } from '../modals/instanceSettingsPanels/StoragePanel';
|
||||
import { UsersPanel } from '../modals/instanceSettingsPanels/UsersPanel';
|
||||
|
||||
/**
|
||||
* Wrapper for the Federation sub-panel that forwards FederationPanel's
|
||||
* approval-count callback into the shared uiStore slot read by
|
||||
* MobileInstancePanel — this keeps the badge live while the admin is inside
|
||||
* the panel approving/denying requests.
|
||||
*/
|
||||
function MobileFederationPanelWrapper() {
|
||||
const setApprovalCount = useUIStore((s) => s.setFederationApprovalCount);
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<MobileScreenHeader title="Federation" />
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<FederationPanel onApprovalCountChange={setApprovalCount} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const screenMap: Record<string, (params?: Record<string, string>) => React.ReactNode> = {
|
||||
'channel-chat': (params) => <MobileChatScreen params={params} />,
|
||||
'friends': () => <FriendsPage mobile />,
|
||||
@@ -39,6 +59,13 @@ const screenMap: Record<string, (params?: Record<string, string>) => React.React
|
||||
<div className="flex-1 overflow-y-auto p-4"><GeneralPanel /></div>
|
||||
</div>
|
||||
),
|
||||
'settings-instance-registration': () => (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<MobileScreenHeader title="Registration" />
|
||||
<div className="flex-1 overflow-y-auto p-4"><RegistrationPanel /></div>
|
||||
</div>
|
||||
),
|
||||
'settings-instance-federation': () => <MobileFederationPanelWrapper />,
|
||||
'settings-instance-streaming': () => (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
<MobileScreenHeader title="Streaming" />
|
||||
@@ -88,21 +115,52 @@ export function MobileShell() {
|
||||
enabled: mobileStack.length > 0,
|
||||
});
|
||||
|
||||
// Reconstruct mobile stack from URL on mount (deep link / refresh support)
|
||||
// Reconstruct mobile stack from URL on mount AND on subsequent pathname
|
||||
// changes (deep link, refresh, programmatic navigate from SpaceInviteCard
|
||||
// Join, joinByCode flows, etc.).
|
||||
//
|
||||
// Subscribes to `location.pathname` only — NOT to `mobileStack`. This is
|
||||
// important because pushing an unrelated screen (e.g. settings) must not
|
||||
// re-trigger this effect; otherwise we would re-push the channel-chat on
|
||||
// top of every newly-pushed screen, since pathname is still `/channels/...`.
|
||||
// We read the current stack imperatively via `useUIStore.getState()` for
|
||||
// the idempotency guard.
|
||||
//
|
||||
// Idempotency guard: callers like MobileSpacesScreen call BOTH
|
||||
// `pushMobileScreen('channel-chat', …)` AND `navigate('/channels/…')`.
|
||||
// The pushMobileScreen call alone doesn't change pathname (history.pushState
|
||||
// with no URL preserves it), but the navigate call does — and that pathname
|
||||
// change re-runs this effect after the screen is already on top. The guard
|
||||
// below catches that case by inspecting the topmost stack entry. We also
|
||||
// guard against the popstate path: when the user navigates back, popstate
|
||||
// pops both the browser history AND our stack; the resulting pathname change
|
||||
// matches the new top entry, so we skip.
|
||||
useEffect(() => {
|
||||
const path = location.pathname;
|
||||
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
|
||||
if (match && mobileStack.length === 0) {
|
||||
const spaceId = match[1] ?? '';
|
||||
const channelId = match[2] ?? '';
|
||||
if (spaceId === '@me') {
|
||||
pushMobileScreen('channel-chat', { channelId, spaceId: '@me' });
|
||||
} else {
|
||||
pushMobileScreen('channel-chat', { channelId, spaceId });
|
||||
}
|
||||
if (!match) return;
|
||||
const spaceId = match[1] ?? '';
|
||||
const channelId = match[2] ?? '';
|
||||
const normalizedSpaceId = spaceId === '@me' ? '@me' : spaceId;
|
||||
|
||||
// Read current stack imperatively to avoid re-firing on stack changes.
|
||||
const currentStack = useUIStore.getState().mobileStack;
|
||||
const top = currentStack[currentStack.length - 1];
|
||||
if (
|
||||
top &&
|
||||
top.screen === 'channel-chat' &&
|
||||
top.params?.channelId === channelId &&
|
||||
top.params?.spaceId === normalizedSpaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Only on mount
|
||||
|
||||
// If the stack has channel-chat entries for OTHER channels, we still push
|
||||
// — this preserves back-stack semantics for in-app navigation (e.g. tapping
|
||||
// a SpaceInviteCard Join button while inside a chat should stack the new
|
||||
// channel on top so back returns to the originating chat).
|
||||
pushMobileScreen('channel-chat', { channelId, spaceId: normalizedSpaceId });
|
||||
}, [location.pathname, pushMobileScreen]);
|
||||
|
||||
// Sync browser back button with mobile stack
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from '../../../stores/settingsStore';
|
||||
import { useUIStore } from '../../../stores/uiStore';
|
||||
import { Toggle } from '../../ui/Toggle';
|
||||
import { ConfirmDialog } from '../../ui/ConfirmDialog';
|
||||
import { Modal } from '../../ui/Modal';
|
||||
|
||||
interface RegistrationDraft {
|
||||
registrationOpen: boolean;
|
||||
@@ -394,16 +395,11 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
|
||||
nameInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Escape closes the modal (capture phase so it fires before parent handlers)
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !submitting) {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKey, true);
|
||||
return () => document.removeEventListener('keydown', handleKey, true);
|
||||
// Block close (Escape / backdrop click) while a submit is in flight; the shared
|
||||
// Modal wires Escape + backdrop-click to onClose, so we gate them here rather
|
||||
// than in a separate keydown listener.
|
||||
const handleClose = useCallback(() => {
|
||||
if (!submitting) onClose();
|
||||
}, [onClose, submitting]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -455,138 +451,131 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
|
||||
onClick={!submitting ? onClose : undefined}
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={handleClose}
|
||||
title="Create invite link"
|
||||
mobileStyle="fullscreen"
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
{/* Decorative icon + helper text — kept inside children so the visual
|
||||
identity (lavender icon chip + descriptive paragraph) is preserved
|
||||
across desktop and mobile fullscreen. */}
|
||||
<div className="flex items-start gap-3 -mt-1 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">
|
||||
Generate a shareable link that lets people register on this instance. You'll set how many times it can be used and when it expires.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Modal panel — stop propagation so backdrop click doesn't fire inside */}
|
||||
<div
|
||||
className="relative w-full max-w-md mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
className="space-y-5"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[16px] font-bold text-txt-primary">Create invite link</h3>
|
||||
<p className="text-[13px] text-txt-secondary leading-snug mt-0.5">
|
||||
Generate a shareable link that lets people register on this instance. You'll set how many times it can be used and when it expires.
|
||||
</p>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Name</div>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={64}
|
||||
placeholder="e.g. Friends batch 1"
|
||||
className="input-standard w-full"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max uses */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Max uses</div>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="maxUsesMode"
|
||||
checked={unlimited}
|
||||
onChange={() => setUnlimited(true)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<span className="text-txt-primary">Unlimited</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="maxUsesMode"
|
||||
checked={!unlimited}
|
||||
onChange={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={maxUses}
|
||||
onChange={(e) => {
|
||||
setMaxUses(e.target.value);
|
||||
setUnlimited(false);
|
||||
}}
|
||||
onClick={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="input-standard w-16 text-center disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-txt-secondary">uses</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
className="px-5 pt-4 pb-5 space-y-5"
|
||||
>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Name</div>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={64}
|
||||
placeholder="e.g. Friends batch 1"
|
||||
className="input-standard w-full"
|
||||
{/* Expiry */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
|
||||
<ExpirySelector
|
||||
value={expiryId}
|
||||
customDateTime={customDateTime}
|
||||
onChange={(v, dt) => {
|
||||
// Create's expiryId state is the narrower ExpiryPresetId; 'keep' cannot
|
||||
// be returned because <ExpirySelector showKeep={false}> never renders it.
|
||||
if (v === 'keep') return;
|
||||
setExpiryId(v);
|
||||
setCustomDateTime(dt);
|
||||
}}
|
||||
showKeep={false}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-3 animate-slide-up">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max uses */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Max uses</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="maxUsesMode"
|
||||
checked={unlimited}
|
||||
onChange={() => setUnlimited(true)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<span className="text-txt-primary">Unlimited</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="maxUsesMode"
|
||||
checked={!unlimited}
|
||||
onChange={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={maxUses}
|
||||
onChange={(e) => {
|
||||
setMaxUses(e.target.value);
|
||||
setUnlimited(false);
|
||||
}}
|
||||
onClick={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="input-standard w-16 text-center disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-txt-secondary">uses</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expiry */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
|
||||
<ExpirySelector
|
||||
value={expiryId}
|
||||
customDateTime={customDateTime}
|
||||
onChange={(v, dt) => {
|
||||
// Create's expiryId state is the narrower ExpiryPresetId; 'keep' cannot
|
||||
// be returned because <ExpirySelector showKeep={false}> never renders it.
|
||||
if (v === 'keep') return;
|
||||
setExpiryId(v);
|
||||
setCustomDateTime(dt);
|
||||
}}
|
||||
showKeep={false}
|
||||
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
/>
|
||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating…' : 'Create link'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-3 animate-slide-up">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating…' : 'Create link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
</div>
|
||||
</form>
|
||||
</Modal>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -617,16 +606,10 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) {
|
||||
nameInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Escape closes the modal (capture phase so it fires before the parent settings modal handler)
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !submitting) {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKey, true);
|
||||
return () => document.removeEventListener('keydown', handleKey, true);
|
||||
// Block close (Escape / backdrop click) while a save is in flight; the shared
|
||||
// Modal wires both to onClose for us.
|
||||
const handleClose = useCallback(() => {
|
||||
if (!submitting) onClose();
|
||||
}, [onClose, submitting]);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -693,134 +676,126 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) {
|
||||
const maxUsesMin = Math.max(1, invite.usedCount);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
|
||||
onClick={!submitting ? onClose : undefined}
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={handleClose}
|
||||
title={`Edit "${invite.name}"`}
|
||||
mobileStyle="fullscreen"
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<div className="flex items-start gap-3 -mt-1 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">
|
||||
Adjust the limits on this invite link. The URL stays the same — anyone who already has it can still redeem under the new constraints.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative w-full max-w-md mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
className="space-y-5"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Name</div>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={64}
|
||||
className="input-standard w-full"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max uses */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
||||
Max uses <span className="normal-case font-normal text-txt-tertiary">({invite.usedCount} used)</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[16px] font-bold text-txt-primary">Edit "{invite.name}"</h3>
|
||||
<p className="text-[13px] text-txt-secondary leading-snug mt-0.5">
|
||||
Adjust the limits on this invite link. The URL stays the same — anyone who already has it can still redeem under the new constraints.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="editMaxUsesMode"
|
||||
checked={unlimited}
|
||||
onChange={() => setUnlimited(true)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<span className="text-txt-primary">Unlimited</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="editMaxUsesMode"
|
||||
checked={!unlimited}
|
||||
onChange={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={maxUsesMin}
|
||||
value={maxUses}
|
||||
onChange={(e) => {
|
||||
setMaxUses(e.target.value);
|
||||
setUnlimited(false);
|
||||
}}
|
||||
onClick={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="input-standard w-16 text-center disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-txt-secondary">uses</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
className="px-5 pt-4 pb-5 space-y-5"
|
||||
>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Name</div>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={64}
|
||||
className="input-standard w-full"
|
||||
{/* Expiry */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
|
||||
<ExpirySelector
|
||||
value={expiryId}
|
||||
customDateTime={customDateTime}
|
||||
onChange={(v, dt) => {
|
||||
setExpiryId(v);
|
||||
setCustomDateTime(dt);
|
||||
}}
|
||||
showKeep={true}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-3 animate-slide-up">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max uses */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
||||
Max uses <span className="normal-case font-normal text-txt-tertiary">({invite.usedCount} used)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="editMaxUsesMode"
|
||||
checked={unlimited}
|
||||
onChange={() => setUnlimited(true)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<span className="text-txt-primary">Unlimited</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="editMaxUsesMode"
|
||||
checked={!unlimited}
|
||||
onChange={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={maxUsesMin}
|
||||
value={maxUses}
|
||||
onChange={(e) => {
|
||||
setMaxUses(e.target.value);
|
||||
setUnlimited(false);
|
||||
}}
|
||||
onClick={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="input-standard w-16 text-center disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-txt-secondary">uses</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expiry */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
|
||||
<ExpirySelector
|
||||
value={expiryId}
|
||||
customDateTime={customDateTime}
|
||||
onChange={(v, dt) => {
|
||||
setExpiryId(v);
|
||||
setCustomDateTime(dt);
|
||||
}}
|
||||
showKeep={true}
|
||||
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
/>
|
||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-3 animate-slide-up">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
</div>
|
||||
</form>
|
||||
</Modal>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -858,16 +833,10 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite
|
||||
const [customDateTime, setCustomDateTime] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Escape closes (capture phase to avoid bubbling into parent settings modal)
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !submitting) {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKey, true);
|
||||
return () => document.removeEventListener('keydown', handleKey, true);
|
||||
// Block close (Escape / backdrop click) while reinstate is in flight; the
|
||||
// shared Modal wires both to onClose.
|
||||
const handleClose = useCallback(() => {
|
||||
if (!submitting) onClose();
|
||||
}, [onClose, submitting]);
|
||||
|
||||
const maxUsesMin = invite.usedCount + 1;
|
||||
@@ -931,130 +900,122 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite
|
||||
: 'This invite has lapsed. Reinstating reactivates the same URL — anyone who saved it will be able to use it again.';
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
|
||||
onClick={!submitting ? onClose : undefined}
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={handleClose}
|
||||
title={`Reinstate "${invite.name}"`}
|
||||
mobileStyle="fullscreen"
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<div className="flex items-start gap-3 -mt-1 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative w-full max-w-md mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleReinstate();
|
||||
}}
|
||||
className="space-y-5"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
{/* Amber callout — only for revoked variant to reinforce the "new URL" consequence */}
|
||||
{isRevoked && (
|
||||
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/20 text-[13px] text-accent-amber">
|
||||
A new link will be generated. Anyone who had the old URL will not be able to use it.
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[16px] font-bold text-txt-primary">Reinstate "{invite.name}"</h3>
|
||||
<p className="text-[13px] text-txt-secondary leading-snug mt-0.5">{subtitle}</p>
|
||||
)}
|
||||
|
||||
{/* Max uses */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
||||
Max uses <span className="normal-case font-normal text-txt-tertiary">(current: {invite.maxUses ?? '∞'}, used: {invite.usedCount})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="reinstateMaxUsesMode"
|
||||
checked={unlimited}
|
||||
onChange={() => setUnlimited(true)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<span className="text-txt-primary">Unlimited</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="reinstateMaxUsesMode"
|
||||
checked={!unlimited}
|
||||
onChange={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={maxUsesMin}
|
||||
value={maxUses}
|
||||
onChange={(e) => {
|
||||
setMaxUses(e.target.value);
|
||||
setUnlimited(false);
|
||||
}}
|
||||
onClick={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="input-standard w-16 text-center disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-txt-secondary">uses</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleReinstate();
|
||||
}}
|
||||
className="px-5 pt-4 pb-5 space-y-5"
|
||||
>
|
||||
{/* Amber callout — only for revoked variant to reinforce the "new URL" consequence */}
|
||||
{isRevoked && (
|
||||
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/20 text-[13px] text-accent-amber">
|
||||
A new link will be generated. Anyone who had the old URL will not be able to use it.
|
||||
</div>
|
||||
)}
|
||||
{/* Expiry */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
|
||||
<ExpirySelector
|
||||
value={expiryId}
|
||||
customDateTime={customDateTime}
|
||||
onChange={(v, dt) => {
|
||||
if (v === 'keep') return; // unreachable: showKeep={false}
|
||||
setExpiryId(v);
|
||||
setCustomDateTime(dt);
|
||||
}}
|
||||
showKeep={false}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max uses */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
||||
Max uses <span className="normal-case font-normal text-txt-tertiary">(current: {invite.maxUses ?? '∞'}, used: {invite.usedCount})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="reinstateMaxUsesMode"
|
||||
checked={unlimited}
|
||||
onChange={() => setUnlimited(true)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<span className="text-txt-primary">Unlimited</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="reinstateMaxUsesMode"
|
||||
checked={!unlimited}
|
||||
onChange={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={maxUsesMin}
|
||||
value={maxUses}
|
||||
onChange={(e) => {
|
||||
setMaxUses(e.target.value);
|
||||
setUnlimited(false);
|
||||
}}
|
||||
onClick={() => setUnlimited(false)}
|
||||
disabled={submitting}
|
||||
className="input-standard w-16 text-center disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-txt-secondary">uses</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expiry */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
|
||||
<ExpirySelector
|
||||
value={expiryId}
|
||||
customDateTime={customDateTime}
|
||||
onChange={(v, dt) => {
|
||||
if (v === 'keep') return; // unreachable: showKeep={false}
|
||||
setExpiryId(v);
|
||||
setCustomDateTime(dt);
|
||||
}}
|
||||
showKeep={false}
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-3 animate-slide-up">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
/>
|
||||
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? 'Reinstating…'
|
||||
: isRevoked
|
||||
? 'Reinstate with new link'
|
||||
: 'Reinstate'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-3 animate-slide-up">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? 'Reinstating…'
|
||||
: isRevoked
|
||||
? 'Reinstate with new link'
|
||||
: 'Reinstate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
</div>
|
||||
</form>
|
||||
</Modal>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1080,18 +1041,6 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
|
||||
const [redemptions, setRedemptions] = useState<InviteRedemption[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// Escape closes (capture phase to avoid bubbling into parent settings modal)
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKey, true);
|
||||
return () => document.removeEventListener('keydown', handleKey, true);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.invites
|
||||
@@ -1111,96 +1060,86 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
|
||||
}, [invite.id, addToast]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
|
||||
onClick={onClose}
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={`Redemptions for "${invite.name}"`}
|
||||
mobileStyle="fullscreen"
|
||||
maxWidth="max-w-lg"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<div className="flex items-start gap-3 -mt-1 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-sky/15 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-accent-sky">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">
|
||||
Users who registered using this invite link, in the order they signed up.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sticky summary band — pins at top of scroll area so the count + revoked
|
||||
warning stay visible while the list scrolls under them. The negative
|
||||
horizontal margin + padding compensates the body's `p-4` so the
|
||||
background can extend edge-to-edge. */}
|
||||
<div
|
||||
className="relative w-full max-w-lg mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden flex flex-col max-h-[80vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="sticky top-0 z-10 -mx-4 px-4 pb-3 space-y-3 bg-[var(--glass-modal-bg)] backdrop-blur-md"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3 flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-sky/15 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-accent-sky">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
{invite.status === 'revoked' && (
|
||||
<div className="bg-accent-rose/10 border border-accent-rose/30 rounded p-2.5 text-xs text-accent-rose leading-relaxed">
|
||||
This invite was revoked
|
||||
{invite.revokedAt
|
||||
? ` ${new Date(invite.revokedAt).toLocaleDateString()}`
|
||||
: ''}
|
||||
. The redemptions below represent users who registered before revocation.
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-[16px] font-bold text-txt-primary">Redemptions for "{invite.name}"</h3>
|
||||
<p className="text-[13px] text-txt-secondary leading-snug mt-0.5">
|
||||
Users who registered using this invite link, in the order they signed up.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="w-8 h-8 rounded-md text-txt-tertiary hover:text-txt-primary hover:bg-white/[0.06] flex items-center justify-center flex-shrink-0 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-5 pt-4 pb-3 space-y-3">
|
||||
{invite.status === 'revoked' && (
|
||||
<div className="bg-accent-rose/10 border border-accent-rose/30 rounded p-2.5 text-xs text-accent-rose leading-relaxed">
|
||||
This invite was revoked
|
||||
{invite.revokedAt
|
||||
? ` ${new Date(invite.revokedAt).toLocaleDateString()}`
|
||||
: ''}
|
||||
. The redemptions below represent users who registered before revocation.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-txt-tertiary">
|
||||
{invite.usedCount}
|
||||
{invite.maxUses !== null ? ` of ${invite.maxUses}` : ''} use
|
||||
{invite.usedCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-4">
|
||||
{redemptions === null ? (
|
||||
<div className="text-sm text-txt-tertiary px-3 py-2">Loading…</div>
|
||||
) : redemptions.length === 0 ? (
|
||||
<div className="text-sm text-txt-tertiary px-3 py-2">
|
||||
{error ? 'Could not load redemptions.' : 'No redemptions yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{redemptions.map((r) => {
|
||||
const showCurrent =
|
||||
!r.isDeleted &&
|
||||
r.currentUsername !== null &&
|
||||
r.currentUsername !== r.registrantUsername;
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-center justify-between gap-3 py-1.5 px-3 rounded hover:bg-surface-input cursor-default"
|
||||
>
|
||||
<span className="text-sm text-txt-primary truncate">
|
||||
{r.registrantUsername}
|
||||
{(r.isDeleted || showCurrent) && (
|
||||
<span className="text-txt-tertiary">
|
||||
{' '}
|
||||
(now {r.isDeleted ? 'Deleted User' : r.currentUsername})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-txt-tertiary shrink-0">
|
||||
{new Date(r.redeemedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-txt-tertiary">
|
||||
{invite.usedCount}
|
||||
{invite.maxUses !== null ? ` of ${invite.maxUses}` : ''} use
|
||||
{invite.usedCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
<div className="-mx-1">
|
||||
{redemptions === null ? (
|
||||
<div className="text-sm text-txt-tertiary px-3 py-2">Loading…</div>
|
||||
) : redemptions.length === 0 ? (
|
||||
<div className="text-sm text-txt-tertiary px-3 py-2">
|
||||
{error ? 'Could not load redemptions.' : 'No redemptions yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{redemptions.map((r) => {
|
||||
const showCurrent =
|
||||
!r.isDeleted &&
|
||||
r.currentUsername !== null &&
|
||||
r.currentUsername !== r.registrantUsername;
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-center justify-between gap-3 py-1.5 px-3 rounded hover:bg-surface-input cursor-default"
|
||||
>
|
||||
<span className="text-sm text-txt-primary truncate">
|
||||
{r.registrantUsername}
|
||||
{(r.isDeleted || showCurrent) && (
|
||||
<span className="text-txt-tertiary">
|
||||
{' '}
|
||||
(now {r.isDeleted ? 'Deleted User' : r.currentUsername})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-txt-tertiary shrink-0">
|
||||
{new Date(r.redeemedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user