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:
@@ -467,7 +467,7 @@ Owns the two independent registration gates and the admin invite-link CRUD surfa
|
|||||||
- **Active rows:** `Copy link`, `Edit`, `Revoke`, kebab → `Delete permanently`, `View redemptions`.
|
- **Active rows:** `Copy link`, `Edit`, `Revoke`, kebab → `Delete permanently`, `View redemptions`.
|
||||||
- **Archived rows:** `Reinstate`, kebab → `Delete permanently`, `View redemptions`.
|
- **Archived rows:** `Reinstate`, kebab → `Delete permanently`, `View redemptions`.
|
||||||
|
|
||||||
**Modals:**
|
**Modals:** Create / Edit / Reinstate / Redemptions all use the shared [`Modal`](../../packages/web/src/components/ui/Modal.tsx) component with `mobileStyle="fullscreen"`, portaled to `document.body` (the parent settings dialog uses `glass-modal`'s `backdrop-filter`, which establishes a containing block — portaling escapes it so the child modal renders viewport-relative). On desktop they appear as the standard centered dialog with `max-w-md` (Redemptions: `max-w-lg`); on mobile they slide in fullscreen with the Modal's standard close button + safe-area padding. The decorative lavender/sky icon chip and helper paragraph live inside `children` (Modal's `title` prop renders the heading + close X).
|
||||||
|
|
||||||
- **Create Invite** — Name (1-64 chars), Max uses (radio: Unlimited / `[N >= 1]`), Expires (preset: `1 hour` / `24 hours` / `7 days` / `30 days` / `Never` / `Custom…`). Defaults: `maxUses: null`, `expiresAt: now + 7 days`. On success the URL is auto-copied to clipboard and the new row animates in at the top of the active list.
|
- **Create Invite** — Name (1-64 chars), Max uses (radio: Unlimited / `[N >= 1]`), Expires (preset: `1 hour` / `24 hours` / `7 days` / `30 days` / `Never` / `Custom…`). Defaults: `maxUses: null`, `expiresAt: now + 7 days`. On success the URL is auto-copied to clipboard and the new row animates in at the top of the active list.
|
||||||
- **Edit Invite** — same shape as Create, pre-filled. Hidden for `revoked` rows (Reinstate is the only path back).
|
- **Edit Invite** — same shape as Create, pre-filled. Hidden for `revoked` rows (Reinstate is the only path back).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Source files:
|
|||||||
- `packages/web/src/components/layout/MobileSpacesScreen.tsx` — Space strip + channel list (split-pane), folder support, voice user rows
|
- `packages/web/src/components/layout/MobileSpacesScreen.tsx` — Space strip + channel list (split-pane), folder support, voice user rows
|
||||||
- `packages/web/src/components/layout/MobileYouScreen.tsx` — User profile card, action rows, logout
|
- `packages/web/src/components/layout/MobileYouScreen.tsx` — User profile card, action rows, logout
|
||||||
- `packages/web/src/components/layout/MobileSettingsScreen.tsx` — Settings hub and direct-panel rendering via `initialPanel` prop
|
- `packages/web/src/components/layout/MobileSettingsScreen.tsx` — Settings hub and direct-panel rendering via `initialPanel` prop
|
||||||
- `packages/web/src/components/layout/MobileInstancePanel.tsx` — Admin-only instance settings hub (General, Streaming, Storage, Users)
|
- `packages/web/src/components/layout/MobileInstancePanel.tsx` — Admin-only instance settings hub (General, Registration, Federation, Streaming, Storage, Users; surfaces federation approval-count badge)
|
||||||
- `packages/web/src/components/layout/MobileMembersScreen.tsx` — Space member list grouped by role, with activity cards
|
- `packages/web/src/components/layout/MobileMembersScreen.tsx` — Space member list grouped by role, with activity cards
|
||||||
- `packages/web/src/components/layout/MobileVoiceFullScreen.tsx` — Full-screen voice call view with participant grid and control bar
|
- `packages/web/src/components/layout/MobileVoiceFullScreen.tsx` — Full-screen voice call view with participant grid and control bar
|
||||||
- `packages/web/src/components/layout/MobileVoiceMiniBar.tsx` — Persistent mini-bar overlay during voice calls
|
- `packages/web/src/components/layout/MobileVoiceMiniBar.tsx` — Persistent mini-bar overlay during voice calls
|
||||||
@@ -110,18 +110,41 @@ This means the hardware/browser back button pops the mobile screen stack. `popMo
|
|||||||
|
|
||||||
### Deep Link Reconstruction
|
### Deep Link Reconstruction
|
||||||
|
|
||||||
On mount, `MobileShell` checks `location.pathname` for `/channels/:spaceId/:channelId` and pushes a `channel-chat` screen if the stack is empty:
|
`MobileShell` watches `location.pathname` for `/channels/:spaceId/:channelId` and pushes a `channel-chat` screen when the URL changes to a channel route — both on mount (deep link / refresh) and on subsequent programmatic navigations (e.g. SpaceInviteCard Join button, joinByCode flows, any `useNavigate(...)` call).
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
|
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
|
||||||
if (match && mobileStack.length === 0) {
|
if (!match) return;
|
||||||
pushMobileScreen('channel-chat', { channelId, spaceId });
|
const spaceId = match[1] ?? '';
|
||||||
|
const channelId = match[2] ?? '';
|
||||||
|
const normalizedSpaceId = spaceId === '@me' ? '@me' : spaceId;
|
||||||
|
|
||||||
|
// Idempotency guard — read 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;
|
||||||
}
|
}
|
||||||
}, []); // Mount only
|
|
||||||
|
pushMobileScreen('channel-chat', { channelId, spaceId: normalizedSpaceId });
|
||||||
|
}, [location.pathname, pushMobileScreen]);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Why these dependencies and the idempotency guard exist:**
|
||||||
|
|
||||||
|
- The dep array intentionally excludes `mobileStack`. The current stack is read imperatively via `useUIStore.getState()` so that pushing an unrelated screen (e.g. `settings`) does not re-trigger this effect — otherwise we would re-push `channel-chat` on top of every newly pushed screen because pathname is still `/channels/...`.
|
||||||
|
- The guard catches the common in-app case where `MobileSpacesScreen` calls both `pushMobileScreen('channel-chat', …)` AND `navigate('/channels/…')`. The push happens first (no pathname change since `pushMobileScreen` calls `history.pushState` with no URL), then `navigate` mutates pathname → this effect re-runs → top already matches → skip.
|
||||||
|
- The guard also catches the popstate path: browser back pops both the history entry and the mobile stack; if the new pathname is a channel route already represented by the new top entry, we skip.
|
||||||
|
|
||||||
|
In-app navigation that lands on a different channel (e.g. tapping a `SpaceInviteCard` Join button while inside another chat) stacks the new `channel-chat` on top so back returns to the originating chat.
|
||||||
|
|
||||||
### User Profile Mobile Override
|
### User Profile Mobile Override
|
||||||
|
|
||||||
`uiStore:openUserProfile` detects `isMobile` and pushes a `user-profile` screen instead of showing a positioned popout:
|
`uiStore:openUserProfile` detects `isMobile` and pushes a `user-profile` screen instead of showing a positioned popout:
|
||||||
@@ -280,6 +303,8 @@ Event options: `touchstart` is `{ passive: true }`, `touchmove` is `{ passive: f
|
|||||||
| `settings-connections` | `MobileSettingsScreen` | `initialPanel="connections"` |
|
| `settings-connections` | `MobileSettingsScreen` | `initialPanel="connections"` |
|
||||||
| `settings-instance` | `MobileInstancePanel` | — |
|
| `settings-instance` | `MobileInstancePanel` | — |
|
||||||
| `settings-instance-general` | `GeneralPanel` (wrapped) | — |
|
| `settings-instance-general` | `GeneralPanel` (wrapped) | — |
|
||||||
|
| `settings-instance-registration` | `RegistrationPanel` (wrapped) | — |
|
||||||
|
| `settings-instance-federation` | `FederationPanel` (wrapped, forwards `onApprovalCountChange` → `uiStore.setFederationApprovalCount`) | — |
|
||||||
| `settings-instance-streaming` | `StreamingPanel` (wrapped) | — |
|
| `settings-instance-streaming` | `StreamingPanel` (wrapped) | — |
|
||||||
| `settings-instance-storage` | `StoragePanel` (wrapped) | — |
|
| `settings-instance-storage` | `StoragePanel` (wrapped) | — |
|
||||||
| `settings-instance-users` | `UsersPanel` (wrapped) | — |
|
| `settings-instance-users` | `UsersPanel` (wrapped) | — |
|
||||||
@@ -335,7 +360,7 @@ Split-pane layout: 60px `glass-strip` space strip on the left + channel list on
|
|||||||
|
|
||||||
- Settings gear in header (pushes `settings`)
|
- Settings gear in header (pushes `settings`)
|
||||||
- Profile card: banner/accent background, avatar (-10 overlap), display name, username, custom status, bio
|
- Profile card: banner/accent background, avatar (-10 overlap), display name, username, custom status, bio
|
||||||
- Action rows (each pushes a settings sub-screen): Edit Profile, Friends, Connections, Voice & Audio
|
- Action rows (each pushes a settings sub-screen): Edit Profile, Friends, Connections, Voice & Video
|
||||||
- Log Out button with `ConfirmDialog`
|
- Log Out button with `ConfirmDialog`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -356,12 +381,19 @@ Params: `{ channelId, spaceId }`
|
|||||||
|
|
||||||
Two modes controlled by `initialPanel` prop:
|
Two modes controlled by `initialPanel` prop:
|
||||||
|
|
||||||
1. **Hub mode** (`initialPanel` undefined): List of setting sections (Account, Voice & Audio, Privacy, Connections, Instance for admins). Each pushes `settings-{id}`.
|
1. **Hub mode** (`initialPanel` undefined): List of setting sections (Account, Voice & Video, Privacy, Connections, Instance for admins). Each pushes `settings-{id}`.
|
||||||
2. **Direct panel mode** (`initialPanel` set): Renders the corresponding panel component (AccountPanel, VoicePanel, PrivacyPanel, ConnectionsPanel) directly with a back header.
|
2. **Direct panel mode** (`initialPanel` set): Renders the corresponding panel component (AccountPanel, VoicePanel, PrivacyPanel, ConnectionsPanel) directly with a back header.
|
||||||
|
|
||||||
### MobileInstancePanel
|
### MobileInstancePanel
|
||||||
|
|
||||||
Admin-only instance settings hub. Pre-fetches instance settings and streaming limits on mount. Lists four sub-sections (General, Streaming, Storage, Users), each pushing `settings-instance-{id}`.
|
Admin-only instance settings hub. Pre-fetches instance settings and streaming limits on mount. Lists six sub-sections (General, Registration, Federation, Streaming, Storage, Users), each pushing `settings-instance-{id}`. Mirrors the desktop `InstancePanel` exactly.
|
||||||
|
|
||||||
|
The Federation row carries a numeric badge driven by `uiStore.federationApprovalCount` (capped at `99+`, styled like the unread-DM badge in `MobileBottomNav`). The badge source has two paths:
|
||||||
|
|
||||||
|
1. **Initial / standalone fetch:** `MobileInstancePanel` calls `api.federation.approvalRequests()` on mount and re-fetches when `onFederationPeersChanged` fires (mirrors what `FederationPanel`'s internal `PendingApprovals` component does). This makes the badge accurate before the admin enters the Federation panel.
|
||||||
|
2. **Live updates while inside the panel:** the wrapper around `FederationPanel` in `MobileShell.tsx` forwards the panel's `onApprovalCountChange` callback into `uiStore.setFederationApprovalCount`. As the admin approves/denies requests inside the panel, the count drops and the badge in the parent hub stays in sync.
|
||||||
|
|
||||||
|
`MobileInstancePanel` is rendered behind a top-level `isAdmin` guard from `MobileSettingsScreen` — non-admin users cannot reach it.
|
||||||
|
|
||||||
### MobileMembersScreen
|
### MobileMembersScreen
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useSettingsStore } from '../../stores/settingsStore';
|
import { useSettingsStore } from '../../stores/settingsStore';
|
||||||
import { MobileScreenHeader } from './MobileScreenHeader';
|
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',
|
id: 'general',
|
||||||
label: 'General',
|
label: 'General',
|
||||||
@@ -14,6 +22,26 @@ const sections = [
|
|||||||
</svg>
|
</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',
|
id: 'streaming',
|
||||||
label: '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() {
|
export function MobileInstancePanel() {
|
||||||
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||||||
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
|
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
|
||||||
@@ -55,23 +131,33 @@ export function MobileInstancePanel() {
|
|||||||
fetchStreamingLimits();
|
fetchStreamingLimits();
|
||||||
}, [fetchInstanceSettings, fetchStreamingLimits]);
|
}, [fetchInstanceSettings, fetchStreamingLimits]);
|
||||||
|
|
||||||
|
const approvalCount = useFederationApprovalCount(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-surface-base">
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
<MobileScreenHeader title="Instance" />
|
<MobileScreenHeader title="Instance" />
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{sections.map((section) => (
|
{sections.map((section) => {
|
||||||
<button
|
const badge = section.id === 'federation' && approvalCount > 0 ? approvalCount : null;
|
||||||
key={section.id}
|
return (
|
||||||
onClick={() => pushMobileScreen(`settings-instance-${section.id}`)}
|
<button
|
||||||
className="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-interactive-hover text-left transition-colors"
|
key={section.id}
|
||||||
>
|
onClick={() => pushMobileScreen(`settings-instance-${section.id}`)}
|
||||||
{section.icon}
|
className="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-interactive-hover text-left transition-colors"
|
||||||
<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}>
|
{section.icon}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
<span className="text-sm text-txt-primary flex-1">{section.label}</span>
|
||||||
</svg>
|
{badge !== null && (
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,10 +20,30 @@ import { FriendsPage } from '../chat/FriendsPage';
|
|||||||
import { ExplorePage } from '../chat/ExplorePage';
|
import { ExplorePage } from '../chat/ExplorePage';
|
||||||
import { UserProfileModal } from '../modals/UserProfileModal';
|
import { UserProfileModal } from '../modals/UserProfileModal';
|
||||||
import { GeneralPanel } from '../modals/instanceSettingsPanels/GeneralPanel';
|
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 { StreamingPanel } from '../modals/instanceSettingsPanels/StreamingPanel';
|
||||||
import { StoragePanel } from '../modals/instanceSettingsPanels/StoragePanel';
|
import { StoragePanel } from '../modals/instanceSettingsPanels/StoragePanel';
|
||||||
import { UsersPanel } from '../modals/instanceSettingsPanels/UsersPanel';
|
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> = {
|
const screenMap: Record<string, (params?: Record<string, string>) => React.ReactNode> = {
|
||||||
'channel-chat': (params) => <MobileChatScreen params={params} />,
|
'channel-chat': (params) => <MobileChatScreen params={params} />,
|
||||||
'friends': () => <FriendsPage mobile />,
|
'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 className="flex-1 overflow-y-auto p-4"><GeneralPanel /></div>
|
||||||
</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': () => (
|
'settings-instance-streaming': () => (
|
||||||
<div className="flex flex-col h-full bg-surface-base">
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
<MobileScreenHeader title="Streaming" />
|
<MobileScreenHeader title="Streaming" />
|
||||||
@@ -88,21 +115,52 @@ export function MobileShell() {
|
|||||||
enabled: mobileStack.length > 0,
|
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(() => {
|
useEffect(() => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
|
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
|
||||||
if (match && mobileStack.length === 0) {
|
if (!match) return;
|
||||||
const spaceId = match[1] ?? '';
|
const spaceId = match[1] ?? '';
|
||||||
const channelId = match[2] ?? '';
|
const channelId = match[2] ?? '';
|
||||||
if (spaceId === '@me') {
|
const normalizedSpaceId = spaceId === '@me' ? '@me' : spaceId;
|
||||||
pushMobileScreen('channel-chat', { channelId, spaceId: '@me' });
|
|
||||||
} else {
|
// Read current stack imperatively to avoid re-firing on stack changes.
|
||||||
pushMobileScreen('channel-chat', { channelId, spaceId });
|
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
|
// Sync browser back button with mobile stack
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from '../../../stores/settingsStore';
|
|||||||
import { useUIStore } from '../../../stores/uiStore';
|
import { useUIStore } from '../../../stores/uiStore';
|
||||||
import { Toggle } from '../../ui/Toggle';
|
import { Toggle } from '../../ui/Toggle';
|
||||||
import { ConfirmDialog } from '../../ui/ConfirmDialog';
|
import { ConfirmDialog } from '../../ui/ConfirmDialog';
|
||||||
|
import { Modal } from '../../ui/Modal';
|
||||||
|
|
||||||
interface RegistrationDraft {
|
interface RegistrationDraft {
|
||||||
registrationOpen: boolean;
|
registrationOpen: boolean;
|
||||||
@@ -394,16 +395,11 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
|
|||||||
nameInputRef.current?.focus();
|
nameInputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Escape closes the modal (capture phase so it fires before parent handlers)
|
// Block close (Escape / backdrop click) while a submit is in flight; the shared
|
||||||
useEffect(() => {
|
// Modal wires Escape + backdrop-click to onClose, so we gate them here rather
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
// than in a separate keydown listener.
|
||||||
if (e.key === 'Escape' && !submitting) {
|
const handleClose = useCallback(() => {
|
||||||
e.stopPropagation();
|
if (!submitting) onClose();
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKey, true);
|
|
||||||
return () => document.removeEventListener('keydown', handleKey, true);
|
|
||||||
}, [onClose, submitting]);
|
}, [onClose, submitting]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
@@ -455,138 +451,131 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<Modal
|
||||||
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
|
isOpen
|
||||||
onClick={!submitting ? onClose : undefined}
|
onClose={handleClose}
|
||||||
|
title="Create invite link"
|
||||||
|
mobileStyle="fullscreen"
|
||||||
|
maxWidth="max-w-md"
|
||||||
>
|
>
|
||||||
{/* Backdrop */}
|
{/* Decorative icon + helper text — kept inside children so the visual
|
||||||
<div className="absolute inset-0 bg-black/50" />
|
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 */}
|
<form
|
||||||
<div
|
onSubmit={(e) => {
|
||||||
className="relative w-full max-w-md mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden"
|
e.preventDefault();
|
||||||
onClick={(e) => e.stopPropagation()}
|
handleCreate();
|
||||||
|
}}
|
||||||
|
className="space-y-5"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Name */}
|
||||||
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3">
|
<div>
|
||||||
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Name</div>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
|
<input
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
ref={nameInputRef}
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
type="text"
|
||||||
</svg>
|
value={name}
|
||||||
</div>
|
onChange={(e) => setName(e.target.value)}
|
||||||
<div className="min-w-0">
|
maxLength={64}
|
||||||
<h3 className="text-[16px] font-bold text-txt-primary">Create invite link</h3>
|
placeholder="e.g. Friends batch 1"
|
||||||
<p className="text-[13px] text-txt-secondary leading-snug mt-0.5">
|
className="input-standard w-full"
|
||||||
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.
|
disabled={submitting}
|
||||||
</p>
|
/>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
{/* Expiry */}
|
||||||
onSubmit={(e) => {
|
<div>
|
||||||
e.preventDefault();
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
|
||||||
handleCreate();
|
<ExpirySelector
|
||||||
}}
|
value={expiryId}
|
||||||
className="px-5 pt-4 pb-5 space-y-5"
|
customDateTime={customDateTime}
|
||||||
>
|
onChange={(v, dt) => {
|
||||||
{/* Name */}
|
// Create's expiryId state is the narrower ExpiryPresetId; 'keep' cannot
|
||||||
<div>
|
// be returned because <ExpirySelector showKeep={false}> never renders it.
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Name</div>
|
if (v === 'keep') return;
|
||||||
<input
|
setExpiryId(v);
|
||||||
ref={nameInputRef}
|
setCustomDateTime(dt);
|
||||||
type="text"
|
}}
|
||||||
value={name}
|
showKeep={false}
|
||||||
onChange={(e) => setName(e.target.value)}
|
disabled={submitting}
|
||||||
maxLength={64}
|
/>
|
||||||
placeholder="e.g. Friends batch 1"
|
</div>
|
||||||
className="input-standard w-full"
|
|
||||||
|
{/* 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}
|
disabled={submitting}
|
||||||
/>
|
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
|
||||||
</div>
|
>
|
||||||
|
Cancel
|
||||||
{/* Max uses */}
|
</button>
|
||||||
<div>
|
<button
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Max uses</div>
|
type="submit"
|
||||||
<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}
|
|
||||||
disabled={submitting}
|
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>
|
||||||
|
</div>
|
||||||
{/* Actions */}
|
</form>
|
||||||
<div className="flex justify-center pt-2">
|
</Modal>,
|
||||||
<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>,
|
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -617,16 +606,10 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) {
|
|||||||
nameInputRef.current?.focus();
|
nameInputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Escape closes the modal (capture phase so it fires before the parent settings modal handler)
|
// Block close (Escape / backdrop click) while a save is in flight; the shared
|
||||||
useEffect(() => {
|
// Modal wires both to onClose for us.
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleClose = useCallback(() => {
|
||||||
if (e.key === 'Escape' && !submitting) {
|
if (!submitting) onClose();
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKey, true);
|
|
||||||
return () => document.removeEventListener('keydown', handleKey, true);
|
|
||||||
}, [onClose, submitting]);
|
}, [onClose, submitting]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -693,134 +676,126 @@ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) {
|
|||||||
const maxUsesMin = Math.max(1, invite.usedCount);
|
const maxUsesMin = Math.max(1, invite.usedCount);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<Modal
|
||||||
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
|
isOpen
|
||||||
onClick={!submitting ? onClose : undefined}
|
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
|
<form
|
||||||
className="relative w-full max-w-md mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden"
|
onSubmit={(e) => {
|
||||||
onClick={(e) => e.stopPropagation()}
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}}
|
||||||
|
className="space-y-5"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Name */}
|
||||||
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3">
|
<div>
|
||||||
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Name</div>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
|
<input
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
ref={nameInputRef}
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
type="text"
|
||||||
</svg>
|
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>
|
||||||
<div className="min-w-0">
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
<h3 className="text-[16px] font-bold text-txt-primary">Edit "{invite.name}"</h3>
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<p className="text-[13px] text-txt-secondary leading-snug mt-0.5">
|
<input
|
||||||
Adjust the limits on this invite link. The URL stays the same — anyone who already has it can still redeem under the new constraints.
|
type="radio"
|
||||||
</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
{/* Expiry */}
|
||||||
onSubmit={(e) => {
|
<div>
|
||||||
e.preventDefault();
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
|
||||||
handleSave();
|
<ExpirySelector
|
||||||
}}
|
value={expiryId}
|
||||||
className="px-5 pt-4 pb-5 space-y-5"
|
customDateTime={customDateTime}
|
||||||
>
|
onChange={(v, dt) => {
|
||||||
{/* Name */}
|
setExpiryId(v);
|
||||||
<div>
|
setCustomDateTime(dt);
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Name</div>
|
}}
|
||||||
<input
|
showKeep={true}
|
||||||
ref={nameInputRef}
|
disabled={submitting}
|
||||||
type="text"
|
/>
|
||||||
value={name}
|
</div>
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
maxLength={64}
|
{/* Actions */}
|
||||||
className="input-standard w-full"
|
<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}
|
disabled={submitting}
|
||||||
/>
|
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
|
||||||
</div>
|
>
|
||||||
|
Cancel
|
||||||
{/* Max uses */}
|
</button>
|
||||||
<div>
|
<button
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
type="submit"
|
||||||
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}
|
|
||||||
disabled={submitting}
|
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>
|
||||||
|
</div>
|
||||||
{/* Actions */}
|
</form>
|
||||||
<div className="flex justify-center pt-2">
|
</Modal>,
|
||||||
<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>,
|
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -858,16 +833,10 @@ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInvite
|
|||||||
const [customDateTime, setCustomDateTime] = useState<string>('');
|
const [customDateTime, setCustomDateTime] = useState<string>('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Escape closes (capture phase to avoid bubbling into parent settings modal)
|
// Block close (Escape / backdrop click) while reinstate is in flight; the
|
||||||
useEffect(() => {
|
// shared Modal wires both to onClose.
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleClose = useCallback(() => {
|
||||||
if (e.key === 'Escape' && !submitting) {
|
if (!submitting) onClose();
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKey, true);
|
|
||||||
return () => document.removeEventListener('keydown', handleKey, true);
|
|
||||||
}, [onClose, submitting]);
|
}, [onClose, submitting]);
|
||||||
|
|
||||||
const maxUsesMin = invite.usedCount + 1;
|
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.';
|
: 'This invite has lapsed. Reinstating reactivates the same URL — anyone who saved it will be able to use it again.';
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<Modal
|
||||||
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
|
isOpen
|
||||||
onClick={!submitting ? onClose : undefined}
|
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
|
<form
|
||||||
className="relative w-full max-w-md mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden"
|
onSubmit={(e) => {
|
||||||
onClick={(e) => e.stopPropagation()}
|
e.preventDefault();
|
||||||
|
handleReinstate();
|
||||||
|
}}
|
||||||
|
className="space-y-5"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Amber callout — only for revoked variant to reinforce the "new URL" consequence */}
|
||||||
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3">
|
{isRevoked && (
|
||||||
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
|
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/20 text-[13px] text-accent-amber">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
|
A new link will be generated. Anyone who had the old URL will not be able to use it.
|
||||||
<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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
{/* Expiry */}
|
||||||
onSubmit={(e) => {
|
<div>
|
||||||
e.preventDefault();
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
|
||||||
handleReinstate();
|
<ExpirySelector
|
||||||
}}
|
value={expiryId}
|
||||||
className="px-5 pt-4 pb-5 space-y-5"
|
customDateTime={customDateTime}
|
||||||
>
|
onChange={(v, dt) => {
|
||||||
{/* Amber callout — only for revoked variant to reinforce the "new URL" consequence */}
|
if (v === 'keep') return; // unreachable: showKeep={false}
|
||||||
{isRevoked && (
|
setExpiryId(v);
|
||||||
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/20 text-[13px] text-accent-amber">
|
setCustomDateTime(dt);
|
||||||
A new link will be generated. Anyone who had the old URL will not be able to use it.
|
}}
|
||||||
</div>
|
showKeep={false}
|
||||||
)}
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Max uses */}
|
{/* Actions */}
|
||||||
<div>
|
<div className="flex justify-center pt-2">
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-3 animate-slide-up">
|
||||||
Max uses <span className="normal-case font-normal text-txt-tertiary">(current: {invite.maxUses ?? '∞'}, used: {invite.usedCount})</span>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div className="flex items-center gap-4">
|
onClick={onClose}
|
||||||
<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}
|
|
||||||
disabled={submitting}
|
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>
|
||||||
|
</div>
|
||||||
{/* Actions */}
|
</form>
|
||||||
<div className="flex justify-center pt-2">
|
</Modal>,
|
||||||
<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>,
|
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1080,18 +1041,6 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
|
|||||||
const [redemptions, setRedemptions] = useState<InviteRedemption[] | null>(null);
|
const [redemptions, setRedemptions] = useState<InviteRedemption[] | null>(null);
|
||||||
const [error, setError] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api.invites
|
api.invites
|
||||||
@@ -1111,96 +1060,86 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
|
|||||||
}, [invite.id, addToast]);
|
}, [invite.id, addToast]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<Modal
|
||||||
className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in"
|
isOpen
|
||||||
onClick={onClose}
|
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
|
<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]"
|
className="sticky top-0 z-10 -mx-4 px-4 pb-3 space-y-3 bg-[var(--glass-modal-bg)] backdrop-blur-md"
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{invite.status === 'revoked' && (
|
||||||
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06] flex items-start gap-3 flex-shrink-0">
|
<div className="bg-accent-rose/10 border border-accent-rose/30 rounded p-2.5 text-xs text-accent-rose leading-relaxed">
|
||||||
<div className="w-10 h-10 rounded-lg bg-accent-sky/15 flex items-center justify-center flex-shrink-0">
|
This invite was revoked
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-accent-sky">
|
{invite.revokedAt
|
||||||
<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" />
|
? ` ${new Date(invite.revokedAt).toLocaleDateString()}`
|
||||||
</svg>
|
: ''}
|
||||||
|
. The redemptions below represent users who registered before revocation.
|
||||||
</div>
|
</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">
|
<div className="text-sm text-txt-tertiary">
|
||||||
{invite.status === 'revoked' && (
|
{invite.usedCount}
|
||||||
<div className="bg-accent-rose/10 border border-accent-rose/30 rounded p-2.5 text-xs text-accent-rose leading-relaxed">
|
{invite.maxUses !== null ? ` of ${invite.maxUses}` : ''} use
|
||||||
This invite was revoked
|
{invite.usedCount === 1 ? '' : 's'}
|
||||||
{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>
|
</div>
|
||||||
</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,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ interface UIState {
|
|||||||
setMobileTab: (tab: 'spaces' | 'dms' | 'you') => void;
|
setMobileTab: (tab: 'spaces' | 'dms' | 'you') => void;
|
||||||
pushMobileScreen: (screen: string, params?: Record<string, string>) => void;
|
pushMobileScreen: (screen: string, params?: Record<string, string>) => void;
|
||||||
popMobileScreen: () => void;
|
popMobileScreen: () => void;
|
||||||
|
|
||||||
|
// Federation approval-count badge (surfaced by MobileInstancePanel; kept fresh
|
||||||
|
// by the FederationPanel via `onApprovalCountChange` whenever an admin
|
||||||
|
// approves/denies a request from inside the panel)
|
||||||
|
federationApprovalCount: number;
|
||||||
|
setFederationApprovalCount: (count: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>()(
|
export const useUIStore = create<UIState>()(
|
||||||
@@ -172,6 +178,9 @@ export const useUIStore = create<UIState>()(
|
|||||||
// Note: do NOT call history.back() here if triggered by popstate event.
|
// Note: do NOT call history.back() here if triggered by popstate event.
|
||||||
// The MobileShell popstate handler manages this — see Task 5.
|
// The MobileShell popstate handler manages this — see Task 5.
|
||||||
},
|
},
|
||||||
|
|
||||||
|
federationApprovalCount: 0,
|
||||||
|
setFederationApprovalCount: (count) => set({ federationApprovalCount: count }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'backspace-ui-settings',
|
name: 'backspace-ui-settings',
|
||||||
|
|||||||
Reference in New Issue
Block a user