From f6e80064ff8c4869a6ba2cef908cc54326948463 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 30 Aug 2026 23:57:57 +0200 Subject: refactor(ui): extract Settings, Profile and Admin pages from app.js Admin page is lazy-loaded so non-admin users never fetch it. Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 963 +-------------------- 1 file changed, 12 insertions(+), 951 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 611cb27..99eb7ab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -5,7 +5,6 @@ import { import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; import { transfers, formatSpeed } from './transfers.js'; -import * as downloads from './downloads.js'; import * as platform from './platform.js'; import { Icon } from './icon.js'; import { formatSize } from './file-utils.js'; @@ -20,6 +19,8 @@ import { SearchPage, ConnectionPool } from './search-page.js'; import { MusicPlayerBar } from './music-player.js'; import { GroupName } from './group-name.js'; import { APPS } from './apps.js'; +import { SettingsPage } from './settings-page.js'; +import { ProfilePage } from './profile-page.js'; // ── Constants ──────────────────────────────────────────────────────────────── @@ -1325,959 +1326,19 @@ const THEME_OPTIONS = ['light', 'dark', 'system']; // ── Profile Page ──────────────────────────────────────────────────────────── // -// Split out of Settings: these four are about *you* — who the account is, the -// node you operate, the node identities this browser has pinned, and closing -// the account. Settings is about how the application behaves. Mixing them put -// an irreversible button two scrolls under a theme picker. - -function ProfilePage({ user, onLogout }) { - const [nodeKey, setNodeKey] = useState(''); - const [currentNodeKey, setCurrentNodeKey] = useState(null); - const [nodeKeyStatus, setNodeKeyStatus] = useState(''); - const [nodeKeyLoading, setNodeKeyLoading] = useState(false); - const [email, setEmail] = useState(''); - const [emailDraft, setEmailDraft] = useState(''); - const [emailEditing, setEmailEditing] = useState(false); - const [emailSaving, setEmailSaving] = useState(false); - const [emailStatus, setEmailStatus] = useState(''); - const [pinCount, setPinCount] = useState( - () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); - const [delOpen, setDelOpen] = useState(false); - const [delPass, setDelPass] = useState(''); - const [delError, setDelError] = useState(''); - const [deleting, setDeleting] = useState(false); - - const deleteAccount = useCallback(async (e) => { - e.preventDefault(); - setDeleting(true); - setDelError(''); - try { - const authKey = await window.MeshBayKeys.deriveAuthKey(delPass, user.username); - await hubFetch('/v1/users/me', { - method: 'DELETE', token: user.token, body: { auth_key: authKey }, - }); - onLogout(); - } catch (err) { - setDelError(err.message); - } finally { - setDeleting(false); - } - }, [delPass, user]); - - const clearPins = useCallback(() => { - window.MeshBayTransport?.clearNodePin?.(); - setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0); - }, []); +// ── Lazy-loaded Admin page (admin/moderator only) ───────────────────────── +let _AdminPage = null; +function LazyAdminPage(props) { + const [loaded, setLoaded] = useState(!!_AdminPage); useEffect(() => { - hubFetch('/v1/users/me', { token: user.token }) - .then(data => { - if (data.email) { setEmail(data.email); setEmailDraft(data.email); } - }) - .catch(() => {}); - hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token }) - .then(data => { - if (data.pk_node_ed25519) setCurrentNodeKey(data.pk_node_ed25519); - }) - .catch(() => {}); - }, [user.username, user.token]); - - const saveEmail = useCallback(async () => { - const val = emailDraft.trim(); - if (!val || val === email) { setEmailEditing(false); return; } - setEmailSaving(true); - setEmailStatus(''); - try { - await hubFetch('/v1/users/me', { - method: 'PATCH', token: user.token, body: { email: val }, - }); - setEmail(val); - setEmailEditing(false); - setEmailStatus(t('settings.email_saved')); - setTimeout(() => setEmailStatus(''), 3000); - } catch (e) { - setEmailStatus(e.message); - } finally { - setEmailSaving(false); - } - }, [emailDraft, email, user.token]); - - const submitNodeKey = useCallback(async () => { - const key = nodeKey.trim(); - if (!key) return; - setNodeKeyLoading(true); - setNodeKeyStatus(''); - try { - await hubFetch('/v1/users/me/node_key', { - method: 'PUT', token: user.token, - body: { pk_node_ed25519: key }, - }); - setCurrentNodeKey(key); - setNodeKey(''); - setNodeKeyStatus(t('settings.node_key_success')); - } catch (e) { - setNodeKeyStatus(e.message); - } finally { - setNodeKeyLoading(false); + if (!_AdminPage) { + import('./admin-page.js').then(m => { _AdminPage = m.AdminPage; setLoaded(true); }); } - }, [nodeKey, user.token]); - - return html` -
-

${t('profile.title')}

- -
-

${t('settings.profile')}

-
- ${t('settings.username')} - ${user.username} -
-
- ${t('settings.email')} - ${emailEditing - ? html` - setEmailDraft(e.target.value)} - onKeyDown=${e => e.key === 'Enter' && saveEmail()} - style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> - - - ` - : html` - ${email || '—'} - - ` - } -
- ${emailStatus && html`

${emailStatus}

`} -
- -
-

${t('settings.node_key')}

-

${t('settings.node_key_desc')}

- ${currentNodeKey && html` -
- ${t('settings.node_key_current')} - ${currentNodeKey} -
- `} -
- setNodeKey(e.target.value)} - onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} /> - -
- ${nodeKeyStatus && html` -

- ${nodeKeyStatus} -

- `} -
- -
-

${t('settings.node_pins')}

-

${t('settings.node_pins_hint')}

-
- ${t('settings.node_pins_count', { n: pinCount })} - -
-
- -
-

${t('settings.danger')}

-

${t('settings.delete_hint')}

- ${delError && html`

${delError}

`} - ${!delOpen - ? html`` - : html` -
-

${t('settings.delete_confirm')}

-
- setDelPass(e.target.value)} required /> - - -
-
- `} -
- -
- `; -} - -function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) { - const [locale, setLoc] = useState(getLocale); - const [muted, setMuted] = useState( - () => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted]))); - const [globalMute, setGlobalMute] = useState(false); - const [defaultTab, setDefaultTab] = useState('chat'); - // Off by default (musicbay.md §2.2): the ordinary expectation, matching - // Spotify/Deezer, is that the phone locks on its own idle timer while - // listening. This is for whoever would rather trade battery for it — - // e.g. to ride out the WebRTC screen-lock reconnect gap without waiting - // on the automatic recovery at all. - const [keepScreenOnAudio, setKeepScreenOnAudio] = useState(false); - - const onLocaleChange = useCallback((e) => { - const code = e.target.value; - setLocale(code); - setLoc(code); - window.location.reload(); }, []); - - const onThemeSelect = useCallback((e) => { - onThemeChange(e.target.value); - }, [onThemeChange]); - - useEffect(() => { - hubFetch('/v1/users/me/preferences', { token: user.token }) - .then(prefs => { - if (prefs.notifications_disabled === 'true') setGlobalMute(true); - if (prefs.default_tab) setDefaultTab(prefs.default_tab); - if (prefs.music_keep_screen_on === 'true') setKeepScreenOnAudio(true); - }) - .catch(() => {}); - }, [user.token]); - - const toggleKeepScreenOnAudio = useCallback(async () => { - const next = !keepScreenOnAudio; - setKeepScreenOnAudio(next); - try { - await hubFetch('/v1/users/me/preferences/music_keep_screen_on', { - method: 'PUT', token: user.token, - body: { value: next ? 'true' : 'false' }, - }); - // A string, matching what a fresh page load reads from the hub - // (prefs.music_keep_screen_on === 'true' above) — music-player.js - // compares against that same string, and userPrefs is one shared bag - // fed from both this immediate update and that load. - if (onPrefsChange) onPrefsChange({ music_keep_screen_on: next ? 'true' : 'false' }); - } catch (err) { - setKeepScreenOnAudio(!next); - } - }, [keepScreenOnAudio, user.token, onPrefsChange]); - - const toggleGlobalMute = useCallback(async () => { - const next = !globalMute; - setGlobalMute(next); - try { - await hubFetch('/v1/users/me/preferences/notifications_disabled', { - method: 'PUT', token: user.token, - body: { value: next ? 'true' : 'false' }, - }); - if (onPrefsChange) onPrefsChange({ notifications_disabled: next }); - } catch (err) { - setGlobalMute(!next); - } - }, [globalMute, user.token, onPrefsChange]); - - const toggleMute = useCallback(async (gid) => { - const next = !muted[gid]; - setMuted(prev => ({ ...prev, [gid]: next })); - try { - await hubFetch(`/v1/groups/${gid}/mute`, { - method: 'POST', token: user.token, body: { muted: next }, - }); - } catch (err) { - setMuted(prev => ({ ...prev, [gid]: !next })); - } - }, [muted, user.token]); - - const changeDefaultTab = useCallback(async (e) => { - const val = e.target.value; - setDefaultTab(val); - try { - await hubFetch('/v1/users/me/preferences/default_tab', { - method: 'PUT', token: user.token, - body: { value: val }, - }); - if (onPrefsChange) onPrefsChange({ default_tab: val }); - } catch { setDefaultTab(defaultTab); } - }, [defaultTab, user.token, onPrefsChange]); - - const [dlMode, setDlMode] = useState(() => downloads.getMode()); - const [dlDir, setDlDir] = useState(null); - const [dlError, setDlError] = useState(''); - // Read from the hub rather than written here: the two constants that used to - // sit in this markup said 0.1.0 and MNP 0.1 long after both had moved on. - const [hubInfo, setHubInfo] = useState(null); - // On a desktop build, whether the OS is really holding the keys. Electron's - // safeStorage falls back to a fixed key when no keyring is running — a - // headless session, a minimal desktop — and does it silently. Somebody who - // believes the OS is protecting their keys deserves to be told when it is not. - const [keyBackend, setKeyBackend] = useState(''); - // Changing the hub after the first run. Without this a typo on the first - // screen was permanent: the prompt only appears when no hub is set, so a - // wrong one left editing a JSON file by hand as the only way out. - const [hubInput, setHubInput] = useState(''); - const [hubError, setHubError] = useState(''); - useEffect(() => { - if (!platform.secrets.available) return; - platform.secrets.backend().then(setKeyBackend).catch(() => {}); - }, []); - - useEffect(() => { - hubFetch('/v1/hub/version').then(setHubInfo).catch(() => {}); - }, []); - - useEffect(() => { - // The desktop build remembers a path; the browser remembers a handle. Both - // answer "where do downloads go", and the row below renders either. - if (platform.folder.available) platform.folder.get().then(setDlDir); - else downloads.savedDirectory().then(setDlDir); - }, []); - - const pickFolder = useCallback(async () => { - try { - if (platform.folder.available) { - const dir = await platform.folder.choose(); - if (dir) setDlDir(dir); - return; - } - const handle = await downloads.chooseDirectory(); - setDlDir(handle); - } catch (err) { - // Reported where the folder controls are. This used to be written into - // the node-key status, two sections away, where nobody was looking. - if (err.name !== 'AbortError') setDlError(err.message); - } - }, []); - - - return html` -
-

${t('settings.title')}

- -
-

${t('settings.downloads')}

- ${(downloads.SUPPORTED || platform.folder.available) && html` - - -
- - ${dlDir ? t('settings.dl_folder', - { name: dlDir.name || String(dlDir) }) - : t('settings.dl_no_folder')} - - - - ${dlDir && !dlDir.isDefault && html` - - `} - -
- ${dlError && html`

${dlError}

`} - `} -
- -
-

${t('settings.appearance')}

-
- ${t('settings.theme')} - -
-
- ${t('settings.language')} - -
-
- -
-

${t('settings.groups')}

-
- ${t('settings.notif_global_disable')} - -
- ${!globalMute && html` -

${t('settings.notif_global_hint')}

- ${groups.map(g => html` -
- ${g.name} - -
- `)} - `} -
- -
-

${t('settings.defaults')}

-
- ${t('settings.default_tab')} - -
-

${t('settings.default_tab_hint')}

-
- ${t('settings.music_keep_screen_on')} - -
-

${t('settings.music_keep_screen_on_hint')}

-
- - ${platform.isNative && html` -
-

${t('settings.hub_heading')}

-
- ${t('settings.hub_current')} - ${platform.hubBase() || '—'} -
-

${t('settings.hub_hint')}

-
{ - e.preventDefault(); - setHubError(''); - try { - await window.meshbay.setHubBase(hubInput.trim()); - } catch (err) { setHubError(platform.bridgeMessage(err)); } - }} style="display:flex;gap:8px"> - setHubInput(e.target.value)} /> - -
- ${hubError && html`

${hubError}

`} -
- `} - - ${keyBackend && html` -
-

${t('settings.keys_heading')}

-
- ${t('settings.keys_where')} - ${keyBackend} -
- ${keyBackend === 'unprotected_fallback' && html` -

${t('settings.keys_unprotected')}

- `} - ${keyBackend === 'unavailable' && html` -

${t('settings.keys_unavailable')}

- `} -
- `} - -
-

${t('settings.about')}

-
- ${t('settings.version')} - ${hubInfo ? hubInfo.hub : '—'} -
-
- ${t('settings.protocol')} - - ${hubInfo ? `MNP ${hubInfo.mnp} / MHP ${hubInfo.mhp}` : '—'} - -
-
-
- `; -} - -// ── Admin Panel ───────────────────────────────────────────────────────────── - -function AdminPage({ token, role }) { - const [tab, setTab] = useState('general'); - const [stats, setStats] = useState(null); - const [settings, setSettings] = useState(null); - const [settingsSaving, setSettingsSaving] = useState(false); - const [users, setUsers] = useState([]); - const [usersTotal, setUsersTotal] = useState(0); - const [userSearch, setUserSearch] = useState(''); - const [groups, setGroups] = useState([]); - const [groupsTotal, setGroupsTotal] = useState(0); - const [logs, setLogs] = useState([]); - const [logEvent, setLogEvent] = useState(''); - const [logOffset, setLogOffset] = useState(0); - const [blocklist, setBlocklist] = useState([]); - const [nodes, setNodes] = useState([]); - const [detailUser, setDetailUser] = useState(null); - const [error, setError] = useState(''); - - const headers = { Authorization: `Bearer ${token}` }; - - const loadStats = useCallback(async () => { - try { - const data = await hubFetch('/v1/admin/stats', { token }); - setStats(data); - } catch (e) { setError(e.message); } - }, [token]); - - const loadSettings = useCallback(async () => { - try { - const data = await hubFetch('/v1/admin/settings', { token }); - setSettings(data); - } catch (e) { setError(e.message); } - }, [token]); - - const saveSettings = useCallback(async (patch) => { - setSettingsSaving(true); - setError(''); - try { - // The response is the authoritative state — render that, not the - // optimistic value, so a rejected change never looks applied. - const data = await hubFetch('/v1/admin/settings', - { method: 'PATCH', body: patch, token }); - setSettings(data); - } catch (e) { setError(e.message); } - finally { setSettingsSaving(false); } - }, [token]); - - const loadUsers = useCallback(async (q = '') => { - try { - const data = await hubFetch(`/v1/admin/users?q=${encodeURIComponent(q)}&limit=100`, { token }); - setUsers(data.users); - setUsersTotal(data.total); - } catch (e) { setError(e.message); } - }, [token]); - - const loadGroups = useCallback(async () => { - try { - const data = await hubFetch('/v1/admin/groups?limit=100', { token }); - setGroups(data.groups); - setGroupsTotal(data.total); - } catch (e) { setError(e.message); } - }, [token]); - - const loadLogs = useCallback(async (event = '', offset = 0, append = false) => { - try { - let url = `/v1/admin/logs?limit=50&offset=${offset}`; - if (event) url += `&event=${encodeURIComponent(event)}`; - const data = await hubFetch(url, { token }); - setLogs(prev => append ? [...prev, ...data.logs] : data.logs); - } catch (e) { setError(e.message); } - }, [token]); - - const loadBlocklist = useCallback(async () => { - try { - const data = await hubFetch('/v1/admin/blocklist', { token }); - setBlocklist(data.entries); - } catch (e) { setError(e.message); } - }, [token]); - - useEffect(() => { - setError(''); - if (tab === 'general') loadSettings(); - else if (tab === 'stats') loadStats(); - else if (tab === 'users') loadUsers(userSearch); - else if (tab === 'groups') loadGroups(); - else if (tab === 'nodes') { - hubFetch('/v1/admin/nodes', { token }) - .then(d => setNodes(d.nodes || [])).catch(e => setError(e.message)); - } - else if (tab === 'logs') { setLogOffset(0); loadLogs(logEvent, 0); } - else if (tab === 'blocklist') loadBlocklist(); - }, [tab]); - - const deleteUser = useCallback(async (u) => { - // Suspension is the reversible tool and stays one click away; this one is - // not, so it names the account and says what it cannot reach. - if (!confirm(t('admin.delete_confirm', { user: u.username }))) return; - try { - await hubFetch(`/v1/admin/users/${u.id}`, { method: 'DELETE', token }); - loadUsers(userSearch); - } catch (err) { - alert(err.message); - } - }, [token, userSearch, loadUsers]); - - const patchUser = useCallback(async (userId, patch) => { - try { - await hubFetch(`/v1/admin/users/${userId}`, { method: 'PATCH', body: patch, token }); - loadUsers(userSearch); - if (detailUser && detailUser.id === userId) setDetailUser(null); - } catch (e) { setError(e.message); } - }, [token, userSearch, detailUser]); - - const patchGroup = useCallback(async (groupId, patch) => { - try { - await hubFetch(`/v1/admin/groups/${groupId}`, { method: 'PATCH', body: patch, token }); - loadGroups(); - } catch (e) { setError(e.message); } - }, [token]); - - const revokeGroup = useCallback(async (g) => { - // Suspending is the reversible tool and stays one click away; revoking - // pushes a signed revocation to every node hosting the group and there is - // no undo from here, so it names the group and asks first. - if (!confirm(t('admin.revoke_group_confirm', { group: g.name }))) return; - try { - await hubFetch('/v1/admin/revoke', - { method: 'POST', body: { target: 'group', target_id: g.id }, token }); - loadGroups(); - } catch (e) { setError(e.message); } - }, [token]); - - const showUserDetail = useCallback(async (userId) => { - try { - const data = await hubFetch(`/v1/admin/users/${userId}`, { token }); - setDetailUser(data); - } catch (e) { setError(e.message); } - }, [token]); - - const addToBlocklist = useCallback(async (hash, reason) => { - try { - await hubFetch('/v1/admin/blocklist', { method: 'POST', body: { content_hash: hash, reason }, token }); - loadBlocklist(); - } catch (e) { setError(e.message); } - }, [token]); - - const removeFromBlocklist = useCallback(async (hash) => { - try { - await hubFetch(`/v1/admin/blocklist/${hash}`, { method: 'DELETE', token }); - loadBlocklist(); - } catch (e) { setError(e.message); } - }, [token]); - - const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist']; - const canEditSettings = role === 'admin'; - - return html` -
-

${t('admin.title')}

- ${error && html`
${error}
`} - -
- ${TABS.map(k => html` - - `)} -
- - ${tab === 'general' && settings && html` -
-

${t('admin.general_groups_heading')}

-
- ${t('admin.allow_public_groups_label')} - -
-

${t('admin.allow_public_groups_hint')}

- ${!canEditSettings && html` -

${t('admin.settings_readonly')}

`} -
- `} - - ${tab === 'stats' && stats && html` -
- ${[['users', 'stat_users'], ['groups', 'stat_groups'], - ['nodes', 'stat_nodes'], ['online_nodes', 'stat_online']].map(([k, label]) => html` -
-
${stats[k]}
-
${t('admin.' + label)}
-
- `)} -
- `} - - ${tab === 'users' && html` -
- { setUserSearch(e.target.value); loadUsers(e.target.value); }} /> - ${usersTotal} total -
- - - - - - - - - - ${users.length === 0 && html``} - ${users.map(u => html` - - - - - - - - `)} - -
${t('admin.col_username')}${t('admin.col_role')}${t('admin.col_status')}${t('admin.col_created')}${t('admin.col_actions')}
${t('admin.no_users')}
${u.username} - - ${u.status}${new Date(u.created_at).toLocaleDateString()} - - ${u.status === 'active' - ? html`` - : u.status === 'suspended' - ? html`` - : null - } - ${u.status !== 'deleted' && html` - - `} -
- `} - - ${tab === 'groups' && html` -
- ${groupsTotal} total -
- - - - - - - - - - - ${groups.length === 0 && html``} - ${groups.map(g => html` - - - - - - - - - `)} - -
${t('admin.col_name')}${t('admin.col_visibility')}${t('admin.col_members')}${t('admin.col_status')}${t('admin.col_created')}${t('admin.col_actions')}
${t('admin.no_groups')}
<${GroupName} name=${g.name} owner=${g.owner_username} />${g.visibility}${g.member_count}${g.status}${new Date(g.created_at).toLocaleDateString()} - ${g.status === 'active' - ? html`` - : g.status === 'suspended' - ? html`` - : null - } - ${g.status !== 'revoked' && html` - - `} -
- `} - - ${tab === 'nodes' && html` -

- ${t('admin.nodes_hint')} -

- - - - - - - - - - ${nodes.length === 0 && html` - - `} - ${nodes.map(n => html` - - - - - - - - `)} - -
${t('admin.col_username')}${t('admin.col_observed_ip')}${t('admin.col_hint')}${t('admin.col_last_seen')}${t('admin.col_status')}
${t('admin.no_nodes')}
${n.username || n.user_id.slice(0, 8)}${n.observed_ip || '—'} - ${n.endpoint_hint || '—'} - ${n.last_seen ? new Date(n.last_seen).toLocaleString() : '—'} - - ${n.online ? t('admin.node_online') : t('admin.node_offline')} - -
- `} - - ${tab === 'logs' && html` -
- -
- - - - - - - - - - ${logs.length === 0 && html``} - ${logs.map(lg => html` - - - - - - - - `)} - -
${t('admin.col_time')}${t('admin.col_user')}${t('admin.col_event')}${t('admin.col_ip')}${t('admin.col_detail')}
${t('admin.no_logs')}
${new Date(lg.timestamp).toLocaleString()}${lg.username || ''}${lg.event}${lg.ip_address}${lg.detail || ''}
- ${logs.length > 0 && logs.length % 50 === 0 && html` - - `} - `} - - ${tab === 'blocklist' && html` - <${BlocklistForm} onAdd=${addToBlocklist} /> - - - - - - - - - - ${blocklist.length === 0 && html``} - ${blocklist.map(b => html` - - - - - - - - `)} - -
${t('admin.col_hash')}${t('admin.col_reason')}${t('admin.col_date')}${t('admin.col_added_by')}${t('admin.col_actions')}
${t('admin.no_blocked')}
${b.hash.slice(0, 16)}...${b.reason}${new Date(b.added_at).toLocaleDateString()}${b.added_by || ''} - -
- `} - - ${detailUser && html` -
{ - if (e.target.classList.contains('admin-detail-overlay')) setDetailUser(null); - }}> -
-

${t('admin.user_detail')}

- ${[ - ['admin.col_username', detailUser.username], - ['admin.col_email', detailUser.email], - ['admin.col_role', detailUser.role], - ['admin.col_status', detailUser.status], - ['admin.col_created', new Date(detailUser.created_at).toLocaleString()], - ['admin.col_groups', detailUser.group_count], - ].map(([label, val]) => html` -
- ${t(label)} - ${val} -
- `)} - -
-
- `} -
- `; -} - -function BlocklistForm({ onAdd }) { - const [hash, setHash] = useState(''); - const [reason, setReason] = useState(''); - - const submit = (e) => { - e.preventDefault(); - if (hash.length === 64 && reason) { - onAdd(hash, reason); - setHash(''); - setReason(''); - } - }; - - return html` -
- setHash(e.target.value)} - pattern="[0-9a-f]{64}" required /> - setReason(e.target.value)} required /> - -
- `; + if (!loaded) return html`
+

`; + return html`<${_AdminPage} ...${props} />`; } // ── Lazy-loaded Node page (Electron only) ─────────────────────────────────── @@ -2670,7 +1731,7 @@ function App() { onPlayQueue=${handlePlayQueue} onStopMusic=${handleStopMusic} />`; } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') - ? html`<${AdminPage} token=${user.token} role=${user.role} />` + ? html`<${LazyAdminPage} token=${user.token} role=${user.role} />` : html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} onPurge=${purgeNotifications} />`; } else if (route === '/settings') { -- cgit v1.2.3