@bhooai/nexus-admin 2.0.6 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.tsx +124 -120
- package/src/alertCenter.tsx +3 -2
- package/src/api.ts +11 -11
- package/src/icons.tsx +104 -0
- package/src/index.css +77 -19
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import logoUrl from './assets/bhooai-nexus-logo.svg';
|
|
3
|
+
import { Icon, isIconName, type IconName } from './icons.js';
|
|
3
4
|
import { SafeGotoDialog, SafeGotoLink } from '@bhooai/nexus-safe-goto';
|
|
4
5
|
import { AlertProvider, useAlerts, useAdminAlert, ToastStack } from './alertCenter.js';
|
|
5
6
|
import {
|
|
@@ -26,7 +27,7 @@ type ToggleStyle = 'chip' | 'neon' | 'icon' | 'orb' | 'track';
|
|
|
26
27
|
const TOGGLE_STYLES: Array<{ id: ToggleStyle; label: string; hint: string }> = [
|
|
27
28
|
{ id: 'chip', label: 'Chip ON/OFF', hint: 'Default · glass chip' },
|
|
28
29
|
{ id: 'neon', label: 'Neon glass', hint: 'Blue→violet border' },
|
|
29
|
-
{ id: 'icon', label: 'Icon knob', hint: '
|
|
30
|
+
{ id: 'icon', label: 'Icon knob', hint: 'Power · bolt glow' },
|
|
30
31
|
{ id: 'orb', label: 'Orb dot', hint: 'Pulse halo' },
|
|
31
32
|
{ id: 'track', label: 'Gradient-track', hint: 'Sliding trail' },
|
|
32
33
|
];
|
|
@@ -183,14 +184,14 @@ function PageHead({ title, children }: { title: string; children?: React.ReactNo
|
|
|
183
184
|
);
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
function PageHero({ kicker, title, desc, glyph, art }: { kicker: string; title: React.ReactNode; desc: string; glyph:
|
|
187
|
+
function PageHero({ kicker, title, desc, glyph, art }: { kicker: string; title: React.ReactNode; desc: string; glyph: IconName; art?: string }) {
|
|
187
188
|
return (
|
|
188
189
|
<div className="config-intro">
|
|
189
190
|
<div><span className="admin-overline">{kicker}</span><h1>{title}</h1><p>{desc}</p></div>
|
|
190
191
|
{art ? (
|
|
191
|
-
<div className={`hero-art hero-art--${art}`}><strong
|
|
192
|
+
<div className={`hero-art hero-art--${art}`}><strong><Icon name={glyph} size={26} /></strong><span className="ha-a" /><span className="ha-b" /><span className="ha-c" /></div>
|
|
192
193
|
) : (
|
|
193
|
-
<div className="hero-orbit-art"><span /><i /><b /><strong
|
|
194
|
+
<div className="hero-orbit-art"><span /><i /><b /><strong><Icon name={glyph} size={26} /></strong></div>
|
|
194
195
|
)}
|
|
195
196
|
</div>
|
|
196
197
|
);
|
|
@@ -211,7 +212,7 @@ function ConfirmDialog(props: {
|
|
|
211
212
|
<div className="confirm-overlay" onClick={props.onCancel}>
|
|
212
213
|
<div className="confirm-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
|
213
214
|
<div className="confirm-dialog-head">
|
|
214
|
-
<span className="confirm-dialog-mark">{props.danger ? '!' :
|
|
215
|
+
<span className="confirm-dialog-mark">{props.danger ? '!' : <Icon name="info" size={15} />}</span>
|
|
215
216
|
<strong>{props.title}</strong>
|
|
216
217
|
<button className="confirm-dialog-close" onClick={props.onCancel} aria-label="Cancel">×</button>
|
|
217
218
|
</div>
|
|
@@ -445,20 +446,20 @@ function Dashboard({ user, onUserChange, onLogout }: { user: AdminUser | null; o
|
|
|
445
446
|
const [shellWidth] = useState(() => Math.round((typeof window === 'undefined' ? 0 : window.screen.width) * 0.8));
|
|
446
447
|
const displayName = user?.name || user?.email?.split('@')[0] || 'Admin';
|
|
447
448
|
const initials = displayName.trim().split(/\s+/).map((p) => p[0]).join('').slice(0, 2).toUpperCase() || '?';
|
|
448
|
-
const nav: { id: Tab; label: string; icon:
|
|
449
|
-
{ id: 'overview', label: 'Overview', icon: '
|
|
450
|
-
{ id: 'processes', label: 'Processes', icon: '
|
|
451
|
-
{ id: 'logs', label: 'Logs', icon: '
|
|
452
|
-
{ id: 'config', label: 'Config JSON', icon: '
|
|
453
|
-
{ id: 'env', label: 'Environment', icon: '
|
|
454
|
-
{ id: 'plugins', label: 'Plugins', icon: '
|
|
455
|
-
{ id: 'users', label: 'Users', icon: '
|
|
456
|
-
{ id: 'monitoring', label: 'Monitoring', icon: '
|
|
457
|
-
{ id: 'payments', label: 'Payments', icon: '
|
|
458
|
-
{ id: 'databases', label: 'Databases', icon: '
|
|
459
|
-
{ id: 'schema', label: 'AI Schema', icon: '
|
|
460
|
-
{ id: 'ai', label: 'AI Agents', icon: '
|
|
461
|
-
{ id: 'theme', label: 'Theme Centre', icon: '
|
|
449
|
+
const nav: { id: Tab; label: string; icon: IconName; keywords?: string[]; desc?: string }[] = [
|
|
450
|
+
{ id: 'overview', label: 'Overview', icon: 'house', keywords: ['home', 'dashboard', 'project', 'summary'], desc: 'Project overview, services pulse and quick links.' },
|
|
451
|
+
{ id: 'processes', label: 'Processes', icon: 'server', keywords: ['services', 'supervisor', 'dev', 'run', 'terminal', 'server'], desc: 'Live service states with start/stop/restart control.' },
|
|
452
|
+
{ id: 'logs', label: 'Logs', icon: 'terminal', keywords: ['console', 'error', 'output', 'stdout', 'stderr', 'supervisor'], desc: 'Aggregated service output, filterable and searchable.' },
|
|
453
|
+
{ id: 'config', label: 'Config JSON', icon: 'braces', keywords: ['configuration', 'runtime', 'settings', 'json', 'source file', 'nexus.config'], desc: 'Runtime overrides are stored in nexus.runtime.json.' },
|
|
454
|
+
{ id: 'env', label: 'Environment', icon: 'key', keywords: ['dotenv', 'variables', 'secrets', 'keys', '.env'], desc: 'Masked environment values are ready to edit.' },
|
|
455
|
+
{ id: 'plugins', label: 'Plugins', icon: 'puzzle', keywords: ['extensions', 'modules', 'addons', 'gallery'], desc: 'Registered plugin pages, slots and surfaces.' },
|
|
456
|
+
{ id: 'users', label: 'Users', icon: 'users', keywords: ['roles', 'permissions', 'accounts', 'team', 'access', 'rbac'], desc: 'Accounts, roles and permission grants.' },
|
|
457
|
+
{ id: 'monitoring', label: 'Monitoring', icon: 'activity', keywords: ['metrics', 'telemetry', 'health', 'uptime', 'traffic', 'stats'], desc: 'Live backend metrics and request traffic.' },
|
|
458
|
+
{ id: 'payments', label: 'Payments', icon: 'dollar', keywords: ['orders', 'transactions', 'money', 'ledger', 'checkout', 'razorpay', 'paypal', 'stripe'], desc: 'Orders, transactions and provider status.' },
|
|
459
|
+
{ id: 'databases', label: 'Databases', icon: 'database', keywords: ['mongo', 'mongodb', 'collections', 'data', 'tables', 'docs'], desc: 'Databases, collections and their documents.' },
|
|
460
|
+
{ id: 'schema', label: 'AI Schema', icon: 'sparkles', keywords: ['generate', 'ai', 'model', 'json schema', 'validator'], desc: 'Generate MongoDB schemas from plain English.' },
|
|
461
|
+
{ id: 'ai', label: 'AI Agents', icon: 'bot', keywords: ['providers', 'chat', 'playground', 'ollama', 'openai', 'models', 'settings'], desc: 'AI settings, providers and chat playground.' },
|
|
462
|
+
{ id: 'theme', label: 'Theme Centre', icon: 'palette', keywords: ['appearance', 'colors', 'palette', 'ui', 'customize', 'surface'], desc: 'Curated themes, palettes and surface tuning.' },
|
|
462
463
|
];
|
|
463
464
|
const searchResults = useMemo(() => {
|
|
464
465
|
const q = search.trim().toLowerCase();
|
|
@@ -528,7 +529,7 @@ function Dashboard({ user, onUserChange, onLogout }: { user: AdminUser | null; o
|
|
|
528
529
|
<nav className="admin-nav">
|
|
529
530
|
{nav.slice(0, 5).map((n) => (
|
|
530
531
|
<button key={n.id} onClick={() => { setTab(n.id); setMobileNav(false); }} className={tab === n.id ? 'is-active' : ''}>
|
|
531
|
-
<span className="admin-nav-icon"
|
|
532
|
+
<span className="admin-nav-icon"><Icon name={n.icon} size={16} /></span>{n.label}
|
|
532
533
|
{n.id === 'env' && <b>NEW</b>}
|
|
533
534
|
</button>
|
|
534
535
|
))}
|
|
@@ -537,17 +538,17 @@ function Dashboard({ user, onUserChange, onLogout }: { user: AdminUser | null; o
|
|
|
537
538
|
<nav className="admin-nav">
|
|
538
539
|
{nav.slice(5).map((n) => (
|
|
539
540
|
<button key={n.id} onClick={() => { setTab(n.id); setMobileNav(false); }} className={tab === n.id ? 'is-active' : ''}>
|
|
540
|
-
<span className="admin-nav-icon"
|
|
541
|
+
<span className="admin-nav-icon"><Icon name={n.icon} size={16} /></span>{n.label}
|
|
541
542
|
</button>
|
|
542
543
|
))}
|
|
543
544
|
</nav>
|
|
544
545
|
</aside>
|
|
545
546
|
<div className="admin-main-shell">
|
|
546
547
|
<header className={`admin-topbar${mobileSearch ? ' is-mobile-search' : ''}`}>
|
|
547
|
-
<button className="admin-mobile-menu" onClick={() => setMobileNav(true)} aria-label="Open navigation"
|
|
548
|
+
<button className="admin-mobile-menu" onClick={() => setMobileNav(true)} aria-label="Open navigation"><Icon name="menu" size={16} /></button>
|
|
548
549
|
<div className="admin-window-tab"><span className="window-tab-mark">â—ˆ</span><strong>{title}</strong></div>
|
|
549
550
|
<div className="admin-search" ref={searchRef}>
|
|
550
|
-
<span
|
|
551
|
+
<span className="admin-search-icon"><Icon name="search" size={14} /></span>
|
|
551
552
|
<input
|
|
552
553
|
ref={searchInputRef}
|
|
553
554
|
value={search}
|
|
@@ -577,7 +578,7 @@ function Dashboard({ user, onUserChange, onLogout }: { user: AdminUser | null; o
|
|
|
577
578
|
) : (
|
|
578
579
|
searchResults.map((n) => (
|
|
579
580
|
<button key={n.id} className={tab === n.id ? 'is-active' : ''} onMouseDown={(e) => { e.preventDefault(); setTab(n.id); setSearch(''); setSearchOpen(false); setMobileSearch(false); }}>
|
|
580
|
-
<span className="admin-search-result-icon"
|
|
581
|
+
<span className="admin-search-result-icon"><Icon name={n.icon} size={14} /></span>{n.label}
|
|
581
582
|
</button>
|
|
582
583
|
))
|
|
583
584
|
)}
|
|
@@ -597,13 +598,13 @@ function Dashboard({ user, onUserChange, onLogout }: { user: AdminUser | null; o
|
|
|
597
598
|
setSearch('');
|
|
598
599
|
setSearchOpen(false);
|
|
599
600
|
}
|
|
600
|
-
}}>{mobileSearch ?
|
|
601
|
+
}}>{mobileSearch ? <Icon name="x" size={16} /> : <Icon name="search" size={16} />}</button>
|
|
601
602
|
<div ref={appearanceRef} className="admin-account">
|
|
602
|
-
<button className="admin-icon-button" onClick={() => { setAppearanceOpen(!appearanceOpen); setAccountOpen(false); }} aria-label="Open appearance settings" aria-expanded={appearanceOpen}
|
|
603
|
+
<button className="admin-icon-button" onClick={() => { setAppearanceOpen(!appearanceOpen); setAccountOpen(false); }} aria-label="Open appearance settings" aria-expanded={appearanceOpen}><Icon name="palette" size={16} /></button>
|
|
603
604
|
{appearanceOpen && <AppearancePanel theme={theme} uiMode={uiMode} onThemeChange={(value) => { setTheme(value); window.localStorage.setItem('nexus-admin-theme', value); }} onUiModeChange={(value) => { setUiMode(value); window.localStorage.setItem('nexus-admin-ui', value); }} />}
|
|
604
605
|
</div>
|
|
605
606
|
<div className="admin-notify" ref={notifyRef}>
|
|
606
|
-
<button className={`admin-icon-button${railOpen === 'notify' ? ' is-active' : ''}`} aria-label="Notifications" aria-expanded={railOpen === 'notify'} onClick={() => setRailOpen(railOpen === 'notify' ? null : 'notify')}
|
|
607
|
+
<button className={`admin-icon-button${railOpen === 'notify' ? ' is-active' : ''}`} aria-label="Notifications" aria-expanded={railOpen === 'notify'} onClick={() => setRailOpen(railOpen === 'notify' ? null : 'notify')}><Icon name="bell" size={16} />{alerts.length > 0 && <b>{alerts.length > 99 ? '99+' : alerts.length}</b>}</button>
|
|
607
608
|
{railOpen === 'notify' && <NotifyPanel onClose={() => setRailOpen(null)} />}
|
|
608
609
|
</div>
|
|
609
610
|
<div className="admin-account" ref={accountRef}>
|
|
@@ -619,10 +620,10 @@ function Dashboard({ user, onUserChange, onLogout }: { user: AdminUser | null; o
|
|
|
619
620
|
</div>
|
|
620
621
|
<div className="account-divider" />
|
|
621
622
|
<button className="account-item" onClick={() => { setSettingsOpen(true); setAccountOpen(false); }}>
|
|
622
|
-
<span className="account-item-icon"
|
|
623
|
+
<span className="account-item-icon"><Icon name="settings" size={15} /></span><span><b>Settings</b><small>Profile, security, project & package</small></span>
|
|
623
624
|
</button>
|
|
624
625
|
<button className="account-item account-danger" onClick={onLogout}>
|
|
625
|
-
<span className="account-item-icon"
|
|
626
|
+
<span className="account-item-icon"><Icon name="logOut" size={15} /></span><span><b>Sign out</b><small>End this session</small></span>
|
|
626
627
|
</button>
|
|
627
628
|
</div>
|
|
628
629
|
)}
|
|
@@ -652,7 +653,7 @@ function Dashboard({ user, onUserChange, onLogout }: { user: AdminUser | null; o
|
|
|
652
653
|
<div className="inspector-tabs"><b>Summary</b><span>Activity</span></div>
|
|
653
654
|
<div className="inspector-card"><span className="admin-overline">CURRENT SURFACE</span><strong>{title}</strong><p>{nav.find((item) => item.id === tab)?.desc ?? 'Manage the running Nexus project from this workspace.'}</p></div>
|
|
654
655
|
<div className="inspector-card inspector-list"><span className="admin-overline">QUICK LINKS</span><button onClick={() => setTab('config')}>Configuration <b>→</b></button><button onClick={() => setTab('env')}>Environment <b>→</b></button><button onClick={() => setTab('logs')}>Logs <b>→</b></button></div>
|
|
655
|
-
<button className="glass-chip-btn-primary w-full justify-between" onClick={() => setTab('overview')}
|
|
656
|
+
<button className="glass-chip-btn-primary w-full justify-between" onClick={() => setTab('overview')}><Icon name="activity" size={13} /> Open project overview <span>→</span></button>
|
|
656
657
|
<div className="admin-side-widget">
|
|
657
658
|
<div className="admin-widget-heading"><span>RUNTIME LOAD</span><b>LIVE</b></div>
|
|
658
659
|
<div className="load-line"><span>CPU</span><i><b style={{ width: '32%' }} /></i><strong>32%</strong></div>
|
|
@@ -662,9 +663,9 @@ function Dashboard({ user, onUserChange, onLogout }: { user: AdminUser | null; o
|
|
|
662
663
|
</aside>
|
|
663
664
|
</div>
|
|
664
665
|
<div className="admin-tool-rail" ref={railRef}>
|
|
665
|
-
<button data-tip="Smart search" className={railOpen === 'search' ? 'is-active' : ''} onClick={() => { setRailOpen(railOpen === 'search' ? null : 'search'); if (railOpen !== 'search') { searchInputRef.current?.focus(); setSearchOpen(true); } }}
|
|
666
|
-
<button data-tip="AI Notes" className={railOpen === 'notes' ? 'is-active' : ''} onClick={() => setRailOpen(railOpen === 'notes' ? null : 'notes')}
|
|
667
|
-
<button data-tip="Screen recall" className={railOpen === 'activity' ? 'is-active' : ''} onClick={() => setRailOpen(railOpen === 'activity' ? null : 'activity')}
|
|
666
|
+
<button data-tip="Smart search" className={railOpen === 'search' ? 'is-active' : ''} onClick={() => { setRailOpen(railOpen === 'search' ? null : 'search'); if (railOpen !== 'search') { searchInputRef.current?.focus(); setSearchOpen(true); } }}><Icon name="search" size={16} /></button>
|
|
667
|
+
<button data-tip="AI Notes" className={railOpen === 'notes' ? 'is-active' : ''} onClick={() => setRailOpen(railOpen === 'notes' ? null : 'notes')}><Icon name="fileText" size={16} /></button>
|
|
668
|
+
<button data-tip="Screen recall" className={railOpen === 'activity' ? 'is-active' : ''} onClick={() => setRailOpen(railOpen === 'activity' ? null : 'activity')}><Icon name="activity" size={16} /></button>
|
|
668
669
|
</div>
|
|
669
670
|
{railOpen && railOpen !== 'notify' && (
|
|
670
671
|
<div ref={railPanelRef}>
|
|
@@ -987,7 +988,7 @@ function RailPanel({
|
|
|
987
988
|
}: {
|
|
988
989
|
open: string;
|
|
989
990
|
onClose: () => void;
|
|
990
|
-
nav: { id: Tab; label: string; icon:
|
|
991
|
+
nav: { id: Tab; label: string; icon: IconName; keywords?: string[]; desc?: string }[];
|
|
991
992
|
tab: Tab;
|
|
992
993
|
favorites: Tab[];
|
|
993
994
|
onToggleFavorite: (id: Tab) => void;
|
|
@@ -1016,7 +1017,7 @@ function RailPanel({
|
|
|
1016
1017
|
<>
|
|
1017
1018
|
<div className="rail-panel-head"><span className="admin-overline">SMART SEARCH</span></div>
|
|
1018
1019
|
<p className="rail-panel-text">Press <kbd>⌘ K</kbd> (or <kbd>Ctrl K</kbd>) to jump to any surface from the top search bar — or click below to focus it now.</p>
|
|
1019
|
-
<button type="button" className="glass-chip-btn-primary w-full justify-center" onClick={onSearch}
|
|
1020
|
+
<button type="button" className="glass-chip-btn-primary w-full justify-center" onClick={onSearch}><Icon name="search" size={12} /> Focus search</button>
|
|
1020
1021
|
<div className="rail-divider" />
|
|
1021
1022
|
<span className="rail-panel-hint">Try: roles, mongo, palette, stripe, ollama, payments…</span>
|
|
1022
1023
|
</>
|
|
@@ -1085,7 +1086,7 @@ function RailPanel({
|
|
|
1085
1086
|
{nav.map((n) => (
|
|
1086
1087
|
<li key={n.id}>
|
|
1087
1088
|
<button type="button" className={favorites.includes(n.id) ? 'is-on' : ''} onClick={() => onToggleFavorite(n.id)}>
|
|
1088
|
-
<span className="rail-fav-icon"
|
|
1089
|
+
<span className="rail-fav-icon"><Icon name={n.icon} size={14} /></span><span className="rail-fav-label">{n.label}</span><span className="rail-fav-check">{favorites.includes(n.id) ? '✓' : '+'}</span>
|
|
1089
1090
|
</button>
|
|
1090
1091
|
</li>
|
|
1091
1092
|
))}
|
|
@@ -1125,12 +1126,12 @@ function NotifyPanel({ onClose }: { onClose: () => void }) {
|
|
|
1125
1126
|
return (
|
|
1126
1127
|
<div className="notify-panel" role="dialog" aria-modal="false">
|
|
1127
1128
|
<div className="notify-head">
|
|
1128
|
-
<span className="notify-head-icon"
|
|
1129
|
+
<span className="notify-head-icon"><Icon name="bell" size={15} /></span>
|
|
1129
1130
|
<div className="notify-head-titles">
|
|
1130
1131
|
<span className="admin-overline">NOTIFICATIONS</span>
|
|
1131
1132
|
<strong>{hasAny ? `${alerts.length + (notify?.length ?? 0)} notification${alerts.length + (notify?.length ?? 0) === 1 ? '' : 's'}` : 'All clear'}</strong>
|
|
1132
1133
|
</div>
|
|
1133
|
-
<button type="button" className="notify-refresh" aria-label="Refresh notifications" onClick={() => void loadNotify()} disabled={notifyBusy}>{notifyBusy ? '…' :
|
|
1134
|
+
<button type="button" className="notify-refresh" aria-label="Refresh notifications" onClick={() => void loadNotify()} disabled={notifyBusy}>{notifyBusy ? '…' : <Icon name="refresh" size={13} />}</button>
|
|
1134
1135
|
<button type="button" className="rail-x" aria-label="Close notifications" onClick={onClose}>×</button>
|
|
1135
1136
|
</div>
|
|
1136
1137
|
|
|
@@ -1184,9 +1185,9 @@ function NotifyPanel({ onClose }: { onClose: () => void }) {
|
|
|
1184
1185
|
)}
|
|
1185
1186
|
</>
|
|
1186
1187
|
) : loading ? (
|
|
1187
|
-
<div className="notify-empty"><span className="notify-empty-icon"
|
|
1188
|
+
<div className="notify-empty"><span className="notify-empty-icon"><Icon name="bell" size={18} /></span><strong>Loading notifications…</strong></div>
|
|
1188
1189
|
) : (
|
|
1189
|
-
<div className="notify-empty"><span className="notify-empty-icon"
|
|
1190
|
+
<div className="notify-empty"><span className="notify-empty-icon"><Icon name="bell" size={18} /></span><strong>All clear</strong><p>No errors or alerts right now.</p></div>
|
|
1190
1191
|
)}
|
|
1191
1192
|
</div>
|
|
1192
1193
|
</div>
|
|
@@ -1275,12 +1276,12 @@ function ThemeCentre({
|
|
|
1275
1276
|
<div className="space-y-6">
|
|
1276
1277
|
<PageHead title="Theme Centre">
|
|
1277
1278
|
<div className="heading-actions">
|
|
1278
|
-
<button type="button" className="workspace-action" onClick={() => { void importJson(); }}
|
|
1279
|
-
<button type="button" className="workspace-action" onClick={exportJson}>{copied ? 'Copied' :
|
|
1280
|
-
<button type="button" className="workspace-action" onClick={resetAll}
|
|
1279
|
+
<button type="button" className="workspace-action" onClick={() => { void importJson(); }}><Icon name="download" size={12} /> Import</button>
|
|
1280
|
+
<button type="button" className="workspace-action" onClick={exportJson}>{copied ? 'Copied' : <><Icon name="upload" size={12} /> Export</>}</button>
|
|
1281
|
+
<button type="button" className="workspace-action" onClick={resetAll}><Icon name="refresh" size={12} /> Reset all</button>
|
|
1281
1282
|
</div>
|
|
1282
1283
|
</PageHead>
|
|
1283
|
-
<PageHero kicker="SURFACE STUDIO" title={<>Make it <em>yours.</em></>} desc="Curated themes, your own palette, particle fields and surface tuning — applied live to this workspace and remembered locally in your browser." glyph="
|
|
1284
|
+
<PageHero kicker="SURFACE STUDIO" title={<>Make it <em>yours.</em></>} desc="Curated themes, your own palette, particle fields and surface tuning — applied live to this workspace and remembered locally in your browser." glyph="palette" art="palette" />
|
|
1284
1285
|
<section className="glass-card space-y-4">
|
|
1285
1286
|
<div className="flex items-center justify-between">
|
|
1286
1287
|
<div><span className="admin-overline">CURATED THEMES</span><h3 className="glass-h3">Start from a surface</h3></div>
|
|
@@ -1309,7 +1310,7 @@ function ThemeCentre({
|
|
|
1309
1310
|
<div className="flex items-center justify-between">
|
|
1310
1311
|
<div><span className="admin-overline">CUSTOM THEME</span><h3 className="glass-h3">Your own palette</h3></div>
|
|
1311
1312
|
<div className="flex items-center gap-2">
|
|
1312
|
-
<button type="button" className="glass-chip-btn" onClick={() => { const p = randomAccentPalette(); onCustom({ ...p, active: !custom.active ? true : custom.active }); }}
|
|
1313
|
+
<button type="button" className="glass-chip-btn" onClick={() => { const p = randomAccentPalette(); onCustom({ ...p, active: !custom.active ? true : custom.active }); }}><Icon name="sparkles" size={12} /> Randomize</button>
|
|
1313
1314
|
<button type="button" className={`custom-toggle${custom.active ? ' is-active' : ''}`} onClick={() => onCustom({ active: !custom.active })}>{custom.active ? 'Applied · live' : 'Apply custom'}</button>
|
|
1314
1315
|
<button type="button" className="glass-chip-btn" onClick={() => onCustom({ ...THEME_PRESETS[theme], active: true })}>Seed from {theme}</button>
|
|
1315
1316
|
</div>
|
|
@@ -1364,7 +1365,7 @@ function ThemeCentre({
|
|
|
1364
1365
|
<div className="flex items-center justify-between">
|
|
1365
1366
|
<div><span className="admin-overline">PARTICLE FIELD</span><h3 className="glass-h3">Floating background dust</h3></div>
|
|
1366
1367
|
<div className="flex items-center gap-2">
|
|
1367
|
-
<button type="button" className="glass-chip-btn" onClick={() => onParticles({ colors: randomParticleColors() })}
|
|
1368
|
+
<button type="button" className="glass-chip-btn" onClick={() => onParticles({ colors: randomParticleColors() })}><Icon name="sparkles" size={12} /> Randomize</button>
|
|
1368
1369
|
<button type="button" className={`custom-toggle${particles.amount > 0 ? ' is-active' : ''}`} onClick={() => onParticles({ amount: particles.amount > 0 ? 0 : 14 })}>{particles.amount > 0 ? 'On' : 'Off'}</button>
|
|
1369
1370
|
</div>
|
|
1370
1371
|
</div>
|
|
@@ -1456,11 +1457,11 @@ function Overview({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
|
|
|
1456
1457
|
<div className="overview-hero"><div><span className="admin-overline">GOOD MORNING, ADMIN</span><h1>Your project is <em>in orbit.</em></h1><p>One calm surface for the services, configuration, data, and AI that power your Nexus application.</p></div><div className="hero-orbit-art"><span /><i /><b /><strong>NX</strong></div></div>
|
|
1457
1458
|
<div className="overview-stat-grid"><div className="overview-stat"><span>ACTIVE SERVICES</span><strong>{running}<small>/{services.length || 4}</small></strong><b className="stat-good">◠running now</b></div><div className="overview-stat"><span>HTTP REQUESTS</span><strong>{requestCount || '—'}</strong><b>since boot</b></div><div className="overview-stat"><span>UPTIME</span><strong>{metrics ? `${Math.round((metrics.uptime ?? 0) / 60)}m` : '—'}</strong><b>backend process</b></div><div className="overview-stat"><span>ENVIRONMENT</span><strong>DEV</strong><b className="stat-violet">local workspace</b></div></div>
|
|
1458
1459
|
<section className="workspace-panel live-traffic-panel"><div className="panel-title-row"><div><span className="admin-overline">HTTP REQUESTS</span><h3>Live traffic</h3></div><span className="live-badge"><i className="status-dot good" /> LIVE</span></div><TrafficChart samples={traffic} total={requestCount} last={traffic.length ? traffic[traffic.length - 1] : 0} onNavigate={onNavigate} /></section>
|
|
1459
|
-
<div className="overview-columns"><section className="workspace-panel"><div className="panel-title-row"><div><span className="admin-overline">PROJECT SURFACES</span><h3>Everything in one place</h3></div><button onClick={() => onNavigate('config')} className="text-action">View config →</button></div><div className="surface-grid"><button className="surface-card acc-blue" onClick={() => onNavigate('config')}><span className="surface-icon"
|
|
1460
|
+
<div className="overview-columns"><section className="workspace-panel"><div className="panel-title-row"><div><span className="admin-overline">PROJECT SURFACES</span><h3>Everything in one place</h3></div><button onClick={() => onNavigate('config')} className="text-action">View config →</button></div><div className="surface-grid"><button className="surface-card acc-blue" onClick={() => onNavigate('config')}><span className="surface-icon"><Icon name="braces" size={20} /></span><strong>Runtime config</strong><small>nexus.runtime.json overrides</small><i className="surface-go">→</i></button><button className="surface-card acc-violet" onClick={() => onNavigate('env')}><span className="surface-icon"><Icon name="key" size={20} /></span><strong>Environment</strong><small>Masked secrets editor</small><i className="surface-go">→</i></button><button className="surface-card acc-pink" onClick={() => onNavigate('databases')}><span className="surface-icon"><Icon name="database" size={20} /></span><strong>Data layer</strong><small>Mongo databases & collections</small><i className="surface-go">→</i></button><button className="surface-card acc-blue" onClick={() => onNavigate('theme')}><span className="surface-icon"><Icon name="sparkles" size={20} /></span><strong>AI theme</strong><small>Generate schemas from English</small><i className="surface-go">→</i></button><button className="surface-card acc-violet" onClick={() => onNavigate('users')}><span className="surface-icon"><Icon name="users" size={20} /></span><strong>Users & roles</strong><small>Accounts, roles & grants</small><i className="surface-go">→</i></button><button className="surface-card acc-pink" onClick={() => onNavigate('payments')}><span className="surface-icon"><Icon name="dollar" size={20} /></span><strong>Payments</strong><small>Orders, transactions & providers</small><i className="surface-go">→</i></button><button className="surface-card acc-blue" onClick={() => onNavigate('ai')}><span className="surface-icon"><Icon name="bot" size={20} /></span><strong>AI agents</strong><small>Providers & chat playground</small><i className="surface-go">→</i></button></div></section><section className="workspace-panel pulse-panel"><div className="panel-title-row"><div><span className="admin-overline">SYSTEM PULSE</span><h3>Services are moving</h3></div><span className="live-badge"><i className="status-dot good" /> LIVE</span></div><div className="pulse-bars">{[34,55,42,78,62,88,52,72,48,66,84,58].map((height, index) => <i key={index} style={{ height: `${height}%` }} />)}</div><p>Live supervisor status refreshes every five seconds.</p><button onClick={() => onNavigate('monitoring')} className="text-action">Open monitoring →</button></section></div>
|
|
1460
1461
|
<section className="workspace-panel">
|
|
1461
1462
|
<div className="panel-title-row">
|
|
1462
1463
|
<div><span className="admin-overline">PREFLIGHT DIAGNOSTICS</span><h3>Dependencies are being probed</h3></div>
|
|
1463
|
-
<button onClick={runChecks} disabled={preflightBusy} className="glass-chip-btn">{preflightBusy ? 'probing…' :
|
|
1464
|
+
<button onClick={runChecks} disabled={preflightBusy} className="glass-chip-btn">{preflightBusy ? 'probing…' : <><Icon name="refresh" size={12} /> run checks</>}</button>
|
|
1464
1465
|
</div>
|
|
1465
1466
|
<p className="preflight-note">The Python server pings the backend API, AI server, GraphQL endpoint and data-store ports, and reports latency per target.</p>
|
|
1466
1467
|
{preflightErr && (
|
|
@@ -1559,9 +1560,9 @@ function Processes() {
|
|
|
1559
1560
|
return (
|
|
1560
1561
|
<div className="space-y-6">
|
|
1561
1562
|
<PageHead title="Processes">
|
|
1562
|
-
<button onClick={refresh} className="glass-chip-btn"
|
|
1563
|
+
<button onClick={refresh} className="glass-chip-btn"><Icon name="refresh" size={12} /> refresh</button>
|
|
1563
1564
|
</PageHead>
|
|
1564
|
-
<PageHero kicker="SERVICE CONTROL PLANE" title={<>Your services, <em>in check.</em></>} desc="Every backend, frontend and AI process the supervisor is tending — live status, uptime, PID and one-click control." glyph="
|
|
1565
|
+
<PageHero kicker="SERVICE CONTROL PLANE" title={<>Your services, <em>in check.</em></>} desc="Every backend, frontend and AI process the supervisor is tending — live status, uptime, PID and one-click control." glyph="server" art="bars" />
|
|
1565
1566
|
<div className="glass-panel overflow-hidden p-2">
|
|
1566
1567
|
<table className="glass-table">
|
|
1567
1568
|
<thead><tr className="text-slate-400"><th>Service</th><th className="text-center">Status</th><th className="text-center">PID</th><th className="text-center">Uptime</th><th className="text-center">Actions</th></tr></thead>
|
|
@@ -1771,14 +1772,14 @@ function Logs({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
|
|
|
1771
1772
|
<div className="space-y-6">
|
|
1772
1773
|
<PageHead title="Logs">
|
|
1773
1774
|
<div className="flex items-center gap-2">
|
|
1774
|
-
<button aria-label="Refresh logs" onClick={() => void refresh()} className="glass-chip-btn"
|
|
1775
|
+
<button aria-label="Refresh logs" onClick={() => void refresh()} className="glass-chip-btn"><Icon name="refresh" size={12} /> refresh</button>
|
|
1775
1776
|
<button aria-pressed={live} onClick={() => setLive(!live)} className={`glass-chip-btn log-live-btn${live ? ' is-live' : ''}`}>{live ? 'â— live' : 'â—‹ paused'}</button>
|
|
1776
|
-
<button aria-label="Copy filtered logs" onClick={() => void copyAll()} className="glass-chip-btn">{copied ? 'Copied' :
|
|
1777
|
-
<button aria-label="Summarize logs with AI" onClick={() => void runAiSummary()} disabled={aiSummaryBusy} className="glass-chip-btn log-ai-btn"
|
|
1778
|
-
<button aria-label="Clear all logs" onClick={async () => { try { await clearLogs(); await refresh(); } catch { /* supervisor may not be reachable */ } }} className="glass-chip-btn-danger"
|
|
1777
|
+
<button aria-label="Copy filtered logs" onClick={() => void copyAll()} className="glass-chip-btn">{copied ? 'Copied' : <><Icon name="copy" size={12} /> copy</>}</button>
|
|
1778
|
+
<button aria-label="Summarize logs with AI" onClick={() => void runAiSummary()} disabled={aiSummaryBusy} className="glass-chip-btn log-ai-btn"><Icon name="sparkles" size={12} /> {aiSummaryBusy ? 'summarizing…' : 'ai summary'}</button>
|
|
1779
|
+
<button aria-label="Clear all logs" onClick={async () => { try { await clearLogs(); await refresh(); } catch { /* supervisor may not be reachable */ } }} className="glass-chip-btn-danger"><Icon name="trash" size={12} /> clear</button>
|
|
1779
1780
|
</div>
|
|
1780
1781
|
</PageHead>
|
|
1781
|
-
<PageHero kicker="ERROR CONSOLE" title={<>Every failure, <em>in one stream.</em></>} desc="Aggregated stdout, stderr and supervisor events from every service — live as the supervisor records them, filterable and searchable." glyph="
|
|
1782
|
+
<PageHero kicker="ERROR CONSOLE" title={<>Every failure, <em>in one stream.</em></>} desc="Aggregated stdout, stderr and supervisor events from every service — live as the supervisor records them, filterable and searchable." glyph="terminal" art="wave" />
|
|
1782
1783
|
{err && (
|
|
1783
1784
|
<div role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-400">
|
|
1784
1785
|
Logs unavailable — {err}. Is <code className="font-mono">nexus dev</code> running?
|
|
@@ -1811,11 +1812,11 @@ function Logs({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
|
|
|
1811
1812
|
{aiSummaryOpen && (
|
|
1812
1813
|
<div className="log-ai-panel">
|
|
1813
1814
|
<div className="log-ai-head">
|
|
1814
|
-
<span className="log-ai-title"
|
|
1815
|
+
<span className="log-ai-title"><Icon name="sparkles" size={12} /> AI summary{aiModel ? ` · ${aiModel}` : ''}</span>
|
|
1815
1816
|
<div className="log-ai-actions">
|
|
1816
|
-
{aiSummaryBusy && <button onClick={stopAiSummary} className="glass-chip-btn-danger"
|
|
1817
|
-
{!aiSummaryBusy && aiSummary && <button onClick={() => void copyAiSummary()} className="glass-chip-btn"
|
|
1818
|
-
<button onClick={() => { setAiSummaryOpen(false); setAiSummary(''); setAiSummaryErr(''); }} className="glass-chip-btn"
|
|
1817
|
+
{aiSummaryBusy && <button onClick={stopAiSummary} className="glass-chip-btn-danger"><Icon name="square" size={10} /> stop</button>}
|
|
1818
|
+
{!aiSummaryBusy && aiSummary && <button onClick={() => void copyAiSummary()} className="glass-chip-btn"><Icon name="copy" size={12} /> copy</button>}
|
|
1819
|
+
<button onClick={() => { setAiSummaryOpen(false); setAiSummary(''); setAiSummaryErr(''); }} className="glass-chip-btn"><Icon name="x" size={12} /> dismiss</button>
|
|
1819
1820
|
</div>
|
|
1820
1821
|
</div>
|
|
1821
1822
|
{aiSummaryErr && (
|
|
@@ -1840,7 +1841,7 @@ function Logs({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
|
|
|
1840
1841
|
</div>
|
|
1841
1842
|
{ordered.length === 0 ? (
|
|
1842
1843
|
<div className="log-empty">
|
|
1843
|
-
<span className="log-empty-glyph"
|
|
1844
|
+
<span className="log-empty-glyph"><Icon name="terminal" size={26} /></span>
|
|
1844
1845
|
<strong>No log lines match the current filters.</strong>
|
|
1845
1846
|
<p>Try widening the level filter or clearing the search text.</p>
|
|
1846
1847
|
</div>
|
|
@@ -1941,7 +1942,7 @@ function LintChecks({ run, title, overline }: { run: () => Promise<LintReport>;
|
|
|
1941
1942
|
<section className="workspace-panel lint-panel">
|
|
1942
1943
|
<div className="panel-title-row">
|
|
1943
1944
|
<div><span className="admin-overline">{overline}</span><h3>{title}</h3></div>
|
|
1944
|
-
<button onClick={doRun} disabled={busy} className="glass-chip-btn">{busy ? 'linting…' :
|
|
1945
|
+
<button onClick={doRun} disabled={busy} className="glass-chip-btn">{busy ? 'linting…' : <><Icon name="refresh" size={12} /> run lint</>}</button>
|
|
1945
1946
|
</div>
|
|
1946
1947
|
{err && <p className="preflight-empty">{err}</p>}
|
|
1947
1948
|
{report ? (
|
|
@@ -1983,13 +1984,13 @@ function AiAgents({ toggle }: { toggle: ToggleStyle }) {
|
|
|
1983
1984
|
return (
|
|
1984
1985
|
<div className="space-y-6">
|
|
1985
1986
|
<PageHead title="AI Agents" />
|
|
1986
|
-
<PageHero kicker="AI WORKSPACE" title={<>Agents, <em>at your command.</em></>} desc="Manage AI settings, configure providers with API keys, and test agents with a live chat playground." glyph="
|
|
1987
|
+
<PageHero kicker="AI WORKSPACE" title={<>Agents, <em>at your command.</em></>} desc="Manage AI settings, configure providers with API keys, and test agents with a live chat playground." glyph="sparkles" art="spark" />
|
|
1987
1988
|
|
|
1988
1989
|
{/* Sub-tab nav */}
|
|
1989
1990
|
<div className="workspace-tabs">
|
|
1990
|
-
{([['settings', 'AI Settings', '
|
|
1991
|
+
{([['settings', 'AI Settings', 'settings'], ['providers', 'AI Providers', 'grid'], ['chat', 'Chat Playground', 'message']] as const).map(([id, label, icon]) => (
|
|
1991
1992
|
<button key={id} onClick={() => setAiTab(id)} className={aiTab === id ? 'is-active' : ''}>
|
|
1992
|
-
<span className="mr-1.5"
|
|
1993
|
+
<span className="mr-1.5"><Icon name={icon} size={13} /></span>{label}
|
|
1993
1994
|
</button>
|
|
1994
1995
|
))}
|
|
1995
1996
|
</div>
|
|
@@ -2089,28 +2090,28 @@ const BUILTIN_PROVIDER_IDS = new Set([
|
|
|
2089
2090
|
'openrouter', 'lmstudio', 'alephalpha', 'stability', 'azure',
|
|
2090
2091
|
]);
|
|
2091
2092
|
|
|
2092
|
-
/** Display metadata for known providers:
|
|
2093
|
-
const PROVIDER_META: Record<string, { glyph: string; hue: number }> = {
|
|
2094
|
-
ollama: { glyph: '
|
|
2095
|
-
openai: { glyph: '
|
|
2096
|
-
anthropic: { glyph: '
|
|
2097
|
-
google: { glyph: '
|
|
2098
|
-
groq: { glyph: '
|
|
2099
|
-
mistral: { glyph: '
|
|
2100
|
-
cohere: { glyph: '
|
|
2101
|
-
together: { glyph: '
|
|
2102
|
-
fireworks: { glyph: '
|
|
2103
|
-
deepseek: { glyph: '
|
|
2104
|
-
perplexity: { glyph: '
|
|
2105
|
-
xai: { glyph: '
|
|
2106
|
-
replicate: { glyph: '
|
|
2107
|
-
huggingface: { glyph: '
|
|
2108
|
-
nvidia: { glyph: '
|
|
2109
|
-
openrouter: { glyph: '
|
|
2110
|
-
lmstudio: { glyph: '
|
|
2111
|
-
alephalpha: { glyph: '
|
|
2112
|
-
stability: { glyph: '
|
|
2113
|
-
azure: { glyph: '
|
|
2093
|
+
/** Display metadata for known providers: an icon + an HSL hue for the avatar accent. */
|
|
2094
|
+
const PROVIDER_META: Record<string, { glyph: IconName | string; hue: number }> = {
|
|
2095
|
+
ollama: { glyph: 'bot', hue: 152 },
|
|
2096
|
+
openai: { glyph: 'sparkles', hue: 165 },
|
|
2097
|
+
anthropic: { glyph: 'message', hue: 265 },
|
|
2098
|
+
google: { glyph: 'globe', hue: 217 },
|
|
2099
|
+
groq: { glyph: 'zap', hue: 38 },
|
|
2100
|
+
mistral: { glyph: 'wind', hue: 210 },
|
|
2101
|
+
cohere: { glyph: 'layers', hue: 190 },
|
|
2102
|
+
together: { glyph: 'users', hue: 230 },
|
|
2103
|
+
fireworks: { glyph: 'flame', hue: 12 },
|
|
2104
|
+
deepseek: { glyph: 'search', hue: 200 },
|
|
2105
|
+
perplexity: { glyph: 'helpCircle', hue: 280 },
|
|
2106
|
+
xai: { glyph: 'x', hue: 0 },
|
|
2107
|
+
replicate: { glyph: 'repeat', hue: 160 },
|
|
2108
|
+
huggingface: { glyph: 'smile', hue: 25 },
|
|
2109
|
+
nvidia: { glyph: 'shield', hue: 120 },
|
|
2110
|
+
openrouter: { glyph: 'gitFork', hue: 240 },
|
|
2111
|
+
lmstudio: { glyph: 'monitor', hue: 180 },
|
|
2112
|
+
alephalpha: { glyph: 'bookOpen', hue: 300 },
|
|
2113
|
+
stability: { glyph: 'image', hue: 320 },
|
|
2114
|
+
azure: { glyph: 'cloud', hue: 200 },
|
|
2114
2115
|
};
|
|
2115
2116
|
|
|
2116
2117
|
/** Derive a deterministic HSL hue from an arbitrary string (for custom provider avatars). */
|
|
@@ -2120,7 +2121,7 @@ function hashHue(s: string): number {
|
|
|
2120
2121
|
return h;
|
|
2121
2122
|
}
|
|
2122
2123
|
|
|
2123
|
-
function providerMeta(id: string): { glyph: string; hue: number } {
|
|
2124
|
+
function providerMeta(id: string): { glyph: IconName | string; hue: number } {
|
|
2124
2125
|
if (PROVIDER_META[id]) return PROVIDER_META[id]!;
|
|
2125
2126
|
const letter = id.charAt(0).toUpperCase() || '?';
|
|
2126
2127
|
return { glyph: letter, hue: hashHue(id) };
|
|
@@ -2351,17 +2352,20 @@ function AiProviders({ toggle }: { toggle: ToggleStyle }) {
|
|
|
2351
2352
|
|
|
2352
2353
|
{/* Filter bar */}
|
|
2353
2354
|
<div className="provider-filter-bar">
|
|
2354
|
-
<
|
|
2355
|
-
className="
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2355
|
+
<div className="relative flex-1 sm:w-64">
|
|
2356
|
+
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500"><Icon name="search" size={13} /></span>
|
|
2357
|
+
<input
|
|
2358
|
+
className="glass-input !py-1.5 text-sm w-full sm:w-64 pl-8"
|
|
2359
|
+
placeholder="Search providers…"
|
|
2360
|
+
type="search"
|
|
2361
|
+
autoComplete="off"
|
|
2362
|
+
autoCapitalize="none"
|
|
2363
|
+
autoCorrect="off"
|
|
2364
|
+
spellCheck={false}
|
|
2365
|
+
value={query}
|
|
2366
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
2367
|
+
/>
|
|
2368
|
+
</div>
|
|
2365
2369
|
<div className="flex flex-wrap gap-1.5">
|
|
2366
2370
|
{filterPills.map(([id, label, count]) => (
|
|
2367
2371
|
<button
|
|
@@ -2497,7 +2501,7 @@ function ProviderCard({ provider, toggle, anim, busy, isCustom, testing, testRes
|
|
|
2497
2501
|
<div className={`provider-card ${cardClass}${anim === 'out' ? ' is-switch-out' : ''}${anim === 'in' ? ' is-switch-in' : ''}`} style={{ ['--avatar-hue' as string]: String(meta.hue) }}>
|
|
2498
2502
|
{/* Header: avatar + label/id + toggle */}
|
|
2499
2503
|
<div className="flex items-center gap-2.5">
|
|
2500
|
-
<span className="provider-avatar" style={{ ['--avatar-hue' as string]: String(meta.hue) }}>{meta.glyph}</span>
|
|
2504
|
+
<span className="provider-avatar" style={{ ['--avatar-hue' as string]: String(meta.hue) }}>{isIconName(meta.glyph) ? <Icon name={meta.glyph} size={16} /> : meta.glyph}</span>
|
|
2501
2505
|
<div className="flex-1 min-w-0">
|
|
2502
2506
|
<div className="font-medium text-sm text-slate-200 truncate">{provider.label}</div>
|
|
2503
2507
|
<div className="text-[11px] text-slate-500 font-mono truncate">{provider.id}</div>
|
|
@@ -2553,7 +2557,7 @@ function ProviderCard({ provider, toggle, anim, busy, isCustom, testing, testRes
|
|
|
2553
2557
|
) : (
|
|
2554
2558
|
<>
|
|
2555
2559
|
<code className="text-xs text-slate-300 font-mono truncate flex-1">{provider.defaultModel || '—'}</code>
|
|
2556
|
-
<button type="button" onClick={() => setEditingModel(true)} disabled={busy} className="glass-chip-btn shrink-0 text-[10px]" title="Edit model"
|
|
2560
|
+
<button type="button" onClick={() => setEditingModel(true)} disabled={busy} className="glass-chip-btn shrink-0 text-[10px]" title="Edit model"><Icon name="pencil" size={11} /></button>
|
|
2557
2561
|
</>
|
|
2558
2562
|
)}
|
|
2559
2563
|
</div>
|
|
@@ -2574,7 +2578,7 @@ function ProviderCard({ provider, toggle, anim, busy, isCustom, testing, testRes
|
|
|
2574
2578
|
{/* Footer: baseUrl + website + set key */}
|
|
2575
2579
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
2576
2580
|
<code className="text-[10px] text-slate-500 truncate flex-1 min-w-[100px]">{provider.baseUrl}</code>
|
|
2577
|
-
<SafeGotoLink href={websiteFromBaseUrl(provider.baseUrl)} className="glass-chip-btn shrink-0 text-[10px]"
|
|
2581
|
+
<SafeGotoLink href={websiteFromBaseUrl(provider.baseUrl)} className="glass-chip-btn shrink-0 text-[10px]"><Icon name="externalLink" size={11} /></SafeGotoLink>
|
|
2578
2582
|
<button
|
|
2579
2583
|
type="button"
|
|
2580
2584
|
onClick={onOpenKey}
|
|
@@ -2582,14 +2586,14 @@ function ProviderCard({ provider, toggle, anim, busy, isCustom, testing, testRes
|
|
|
2582
2586
|
className="glass-chip-btn shrink-0 text-[10px]"
|
|
2583
2587
|
>key</button>
|
|
2584
2588
|
{isCustom && (
|
|
2585
|
-
<button type="button" onClick={onRemove} disabled={busy} className="glass-chip-btn-danger shrink-0 text-[10px]" title="Remove provider"
|
|
2589
|
+
<button type="button" onClick={onRemove} disabled={busy} className="glass-chip-btn-danger shrink-0 text-[10px]" title="Remove provider"><Icon name="trash" size={11} /></button>
|
|
2586
2590
|
)}
|
|
2587
2591
|
</div>
|
|
2588
2592
|
|
|
2589
2593
|
{/* Test row */}
|
|
2590
2594
|
<div className="flex items-center gap-2 pt-0.5 border-t border-white/[0.06]">
|
|
2591
2595
|
<button type="button" onClick={onTest} disabled={busy || testing} className="glass-chip-btn shrink-0 text-[10px]">
|
|
2592
|
-
{testing ? 'testing…' :
|
|
2596
|
+
{testing ? 'testing…' : <><Icon name="zap" size={11} /> test</>}
|
|
2593
2597
|
</button>
|
|
2594
2598
|
{testResult && (
|
|
2595
2599
|
testResult.ok ? (
|
|
@@ -2951,7 +2955,7 @@ function AiChatPlayground() {
|
|
|
2951
2955
|
<div className="flex items-center justify-between">
|
|
2952
2956
|
<h3 className="glass-h3">Chat playground</h3>
|
|
2953
2957
|
<div className="flex gap-2">
|
|
2954
|
-
<button onClick={checkAi} disabled={streaming} className="glass-chip-btn"
|
|
2958
|
+
<button onClick={checkAi} disabled={streaming} className="glass-chip-btn"><Icon name="refresh" size={12} /> refresh models</button>
|
|
2955
2959
|
<button onClick={clearChat} disabled={streaming} className="glass-chip-btn">clear</button>
|
|
2956
2960
|
</div>
|
|
2957
2961
|
</div>
|
|
@@ -2980,7 +2984,7 @@ function AiChatPlayground() {
|
|
|
2980
2984
|
</div>
|
|
2981
2985
|
|
|
2982
2986
|
{!provider && (
|
|
2983
|
-
<p className="text-xs text-amber-400"
|
|
2987
|
+
<p className="text-xs text-amber-400"><Icon name="warning" size={12} className="mb-0.5 mr-1 inline" /> Select a provider above. If no providers are listed, go to the AI Providers tab and enable one, or click "refresh models" to detect the AI server's available providers.</p>
|
|
2984
2988
|
)}
|
|
2985
2989
|
|
|
2986
2990
|
{/* System prompt */}
|
|
@@ -3094,7 +3098,7 @@ function Config({ onNavigate }: { onNavigate?: (tab: Tab) => void }) {
|
|
|
3094
3098
|
|
|
3095
3099
|
return (
|
|
3096
3100
|
<div className="admin-view-stack">
|
|
3097
|
-
<PageHead title="Configuration"><div className="heading-actions"><span className="restart-badge">RESTART REQUIRED AFTER SAVE</span><button onClick={load} className="workspace-action"
|
|
3101
|
+
<PageHead title="Configuration"><div className="heading-actions"><span className="restart-badge">RESTART REQUIRED AFTER SAVE</span><button onClick={load} className="workspace-action"><Icon name="refresh" size={12} /> Reload</button></div></PageHead>
|
|
3098
3102
|
<div className="config-intro"><div><span className="admin-overline">PROJECT CONTROL PLANE</span><h1>Configure your <em>runtime.</em></h1><p>Use key/value rows for safe JSON overrides, or switch to source mode for the typed project configuration.</p></div><div className="hero-art hero-art--cubes"><strong>{'{ }'}</strong><span className="ha-a" /><span className="ha-b" /><span className="ha-c" /></div></div>
|
|
3099
3103
|
<div className="workspace-tabs">{([['runtime', 'Runtime JSON'], ['source', 'Source file'], ['effective', 'Effective view']] as const).map(([id, label]) => <button key={id} onClick={() => setMode(id)} className={mode === id ? 'is-active' : ''}>{label}</button>)}</div>
|
|
3100
3104
|
{mode === 'runtime' && <section className="workspace-panel config-panel"><div className="panel-title-row"><div><span className="admin-overline">NEXUS CONFIG · KEY/VALUE</span><h3>Configuration overrides</h3></div><button onClick={addKey} className="workspace-action">+ Add key</button></div><p className="panel-description">Existing configuration is loaded into the rows below. Changes are written to nexus.runtime.json and validated against the Nexus schema before saving. Secrets are redacted and managed in the Environment surface.</p><div className="key-add-bar">
|
|
@@ -3154,8 +3158,8 @@ function Environment({ onNavigate }: { onNavigate?: (tab: Tab) => void }) {
|
|
|
3154
3158
|
const save = async () => { setBusy(true); setMsg(''); try { await putAdminEnv(entries, file); setMsg(`Saved ${file}. Restart services to apply environment changes.`); setRestartDialog(true); await load(); } catch (e: any) { setMsg(`Error: ${e?.message ?? e}`); } finally { setBusy(false); } };
|
|
3155
3159
|
return (
|
|
3156
3160
|
<div className="admin-view-stack">
|
|
3157
|
-
<PageHead title="Environment"><div className="heading-actions"><span className="secret-badge">SECRETS MASKED</span><button onClick={() => load()} className="workspace-action"
|
|
3158
|
-
<div className="config-intro env-intro"><div><span className="admin-overline">LOCAL ENVIRONMENT · {data?.path ?? '.env'}</span><h1>Your environment, <em>under control.</em></h1><p>Manage project variables as key/value pairs. Secret values stay on disk and are never returned to the browser.</p></div><div className="env-lock-art"><i className="env-lock-glyph"
|
|
3161
|
+
<PageHead title="Environment"><div className="heading-actions"><span className="secret-badge">SECRETS MASKED</span><button onClick={() => load()} className="workspace-action"><Icon name="refresh" size={12} /> Reload</button></div></PageHead>
|
|
3162
|
+
<div className="config-intro env-intro"><div><span className="admin-overline">LOCAL ENVIRONMENT · {data?.path ?? '.env'}</span><h1>Your environment, <em>under control.</em></h1><p>Manage project variables as key/value pairs. Secret values stay on disk and are never returned to the browser.</p></div><div className="env-lock-art"><i className="env-lock-glyph"><Icon name="lock" size={26} /></i><span>PRIVATE</span></div></div>
|
|
3159
3163
|
<section className="workspace-panel config-panel"><div className="panel-title-row"><div><span className="admin-overline">{file.toUpperCase()} FILE · PRESERVES COMMENTS</span><h3>Environment variables</h3></div><button onClick={addVariable} className="workspace-action">+ Add variable</button></div><div className="env-add-bar">
|
|
3160
3164
|
<input value={newKey} onChange={(event) => setNewKey(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addVariable(); }} placeholder="NEXUS_SERVER_PORT" />
|
|
3161
3165
|
<div className="env-value-wrap"><input value={newValue} onChange={(event) => setNewValue(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addVariable(); }} placeholder="value" /><span className="secret-mark">{newKey && /SECRET|PASSWORD|PASS|TOKEN|PRIVATE|API_KEY|CLIENT_SECRET|ACCESS_KEY|KEY_SECRET/i.test(newKey) ? 'â—' : 'â—‹'}</span></div>
|
|
@@ -3200,7 +3204,7 @@ function Plugins() {
|
|
|
3200
3204
|
return (
|
|
3201
3205
|
<div className="space-y-6">
|
|
3202
3206
|
<PageHead title="Plugins" />
|
|
3203
|
-
<PageHero kicker="EXTENSION GALLERY" title={<>Extend Nexus, <em>effortlessly.</em></>} desc="Every admin page, slot and surface that plugin modules register — a living index of what's available to your build." glyph="
|
|
3207
|
+
<PageHero kicker="EXTENSION GALLERY" title={<>Extend Nexus, <em>effortlessly.</em></>} desc="Every admin page, slot and surface that plugin modules register — a living index of what's available to your build." glyph="puzzle" art="grid" />
|
|
3204
3208
|
<div className="glass-card space-y-4">
|
|
3205
3209
|
<h3 className="glass-h3">Admin pages</h3>
|
|
3206
3210
|
<ul className="space-y-1">
|
|
@@ -3276,7 +3280,7 @@ function Users({ currentUserEmail }: { currentUserEmail?: string }) {
|
|
|
3276
3280
|
return (
|
|
3277
3281
|
<div className="space-y-6">
|
|
3278
3282
|
<PageHead title="Users & Roles" />
|
|
3279
|
-
<PageHero kicker="ACCESS & PERMISSIONS" title={<>Your team, <em>in control.</em></>} desc="Every account that can reach this Nexus surface — with role grants and restrictions applied in one built-in permission catalog." glyph="
|
|
3283
|
+
<PageHero kicker="ACCESS & PERMISSIONS" title={<>Your team, <em>in control.</em></>} desc="Every account that can reach this Nexus surface — with role grants and restrictions applied in one built-in permission catalog." glyph="users" art="people" />
|
|
3280
3284
|
|
|
3281
3285
|
<div className="glass-panel overflow-hidden p-2">
|
|
3282
3286
|
<table className="glass-table">
|
|
@@ -3476,7 +3480,7 @@ function Monitoring() {
|
|
|
3476
3480
|
return (
|
|
3477
3481
|
<div className="space-y-6">
|
|
3478
3482
|
<PageHead title="Monitoring" />
|
|
3479
|
-
<PageHero kicker="LIVE TELEMETRY" title={<>Watch the whole <em>machine.</em></>} desc="A calm, self-refreshing readout of the backend heartbeat — uptime, PID and request traffic without the noise." glyph="
|
|
3483
|
+
<PageHero kicker="LIVE TELEMETRY" title={<>Watch the whole <em>machine.</em></>} desc="A calm, self-refreshing readout of the backend heartbeat — uptime, PID and request traffic without the noise." glyph="activity" art="coins" />
|
|
3480
3484
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
|
3481
3485
|
<div className="glass-card"><div className="glass-h3">Uptime</div><div className="mt-2 text-3xl font-bold text-slate-100">{Math.round(data.uptime ?? 0)}<span className="ml-1 text-sm font-normal text-slate-400">s</span></div></div>
|
|
3482
3486
|
<div className="glass-card"><div className="glass-h3">PID</div><div className="mt-2 font-mono text-3xl font-bold text-slate-100">{data.pid}</div></div>
|
|
@@ -3574,10 +3578,10 @@ function Payments({ toggle }: { toggle: ToggleStyle }) {
|
|
|
3574
3578
|
<option value="all" className="bg-slate-900">All providers</option>
|
|
3575
3579
|
{ALL_PROVIDERS.map((p) => <option key={p} value={p} className="bg-slate-900">{p}</option>)}
|
|
3576
3580
|
</select>
|
|
3577
|
-
<button onClick={() => load()} className="glass-chip-btn"
|
|
3581
|
+
<button onClick={() => load()} className="glass-chip-btn"><Icon name="refresh" size={12} /> refresh</button>
|
|
3578
3582
|
</div>
|
|
3579
3583
|
</PageHead>
|
|
3580
|
-
<PageHero kicker="PAYMENT OPERATIONS" title={<>Money flow, <em>on the ledger.</em></>} desc="Every charge, refund and provider event from your payment stack — one surface to reconcile what went through." glyph="
|
|
3584
|
+
<PageHero kicker="PAYMENT OPERATIONS" title={<>Money flow, <em>on the ledger.</em></>} desc="Every charge, refund and provider event from your payment stack — one surface to reconcile what went through." glyph="dollar" art="cards" />
|
|
3581
3585
|
|
|
3582
3586
|
<div className="workspace-tabs">
|
|
3583
3587
|
{([
|
|
@@ -3720,7 +3724,7 @@ function TestConsole({ toggle, status, onRun, onNotice, onCreated }: { toggle: T
|
|
|
3720
3724
|
<div className="flex items-center justify-between">
|
|
3721
3725
|
<h3 className="glass-h3">Provider status</h3>
|
|
3722
3726
|
<button onClick={async () => { setProbeBusy(true); try { await onRun(); } finally { setProbeBusy(false); } }} className="glass-chip-btn" disabled={probeBusy}>
|
|
3723
|
-
{probeBusy ? 'checking…' :
|
|
3727
|
+
{probeBusy ? 'checking…' : <><Icon name="refresh" size={12} /> re-run checks</>}
|
|
3724
3728
|
</button>
|
|
3725
3729
|
</div>
|
|
3726
3730
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
@@ -3742,7 +3746,7 @@ function TestConsole({ toggle, status, onRun, onNotice, onCreated }: { toggle: T
|
|
|
3742
3746
|
<div className="text-[11px] text-slate-500 font-mono truncate">{p.sandbox ? 'sandbox mode' : 'live mode'}</div>
|
|
3743
3747
|
</div>
|
|
3744
3748
|
<span className={p.ok ? 'dot-ok' : 'dot-bad'} title={p.ok ? 'working' : 'not working'} />
|
|
3745
|
-
<button onClick={() => openKeys(p)} className="glass-chip-btn shrink-0 text-[10px]" title="Set API key / secret"
|
|
3749
|
+
<button onClick={() => openKeys(p)} className="glass-chip-btn shrink-0 text-[10px]" title="Set API key / secret"><Icon name="key" size={11} /> keys</button>
|
|
3746
3750
|
</div>
|
|
3747
3751
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
3748
3752
|
{p.enabled
|
|
@@ -3934,9 +3938,9 @@ function Databases() {
|
|
|
3934
3938
|
return (
|
|
3935
3939
|
<div className="space-y-6">
|
|
3936
3940
|
<PageHead title="Databases">
|
|
3937
|
-
<button onClick={refresh} className="glass-chip-btn"
|
|
3941
|
+
<button onClick={refresh} className="glass-chip-btn"><Icon name="refresh" size={12} /> refresh</button>
|
|
3938
3942
|
</PageHead>
|
|
3939
|
-
<PageHero kicker="DATA FOUNDATION" title={<>Your data, <em>organized.</em></>} desc="Databases and collections backing your project — inspect, create and keep the layer that stores everything sane." glyph="
|
|
3943
|
+
<PageHero kicker="DATA FOUNDATION" title={<>Your data, <em>organized.</em></>} desc="Databases and collections backing your project — inspect, create and keep the layer that stores everything sane." glyph="database" art="cubes" />
|
|
3940
3944
|
|
|
3941
3945
|
<div className="glass-card flex gap-2">
|
|
3942
3946
|
<input className="glass-input max-w-xs" placeholder="new database name" value={newDb} onChange={(e) => setNewDb(e.target.value)} />
|
|
@@ -3948,7 +3952,7 @@ function Databases() {
|
|
|
3948
3952
|
<div key={db.name} className="glass-card">
|
|
3949
3953
|
<div className="flex items-center gap-3">
|
|
3950
3954
|
<button onClick={() => setExpanded(expanded === db.name ? null : db.name)} className="cursor-pointer font-mono text-base font-semibold text-slate-100 transition-colors hover:text-indigo-300">
|
|
3951
|
-
<span className={`mr-1 inline-block transition-transform ${expanded === db.name ? 'rotate-90' : ''}`}
|
|
3955
|
+
<span className={`mr-1 inline-block transition-transform ${expanded === db.name ? 'rotate-90' : ''}`}><Icon name="chevronRight" size={14} /></span>{db.name}
|
|
3952
3956
|
</button>
|
|
3953
3957
|
<span className="text-xs text-slate-500">{(db.sizeOnDisk / 1024).toFixed(1)} KB · {db.collections.length} collections</span>
|
|
3954
3958
|
<button onClick={() => setConfirm({ title: `Drop database “${db.name}�`, message: <>This will permanently delete the database <strong>{db.name}</strong> and all of its collections. This cannot be undone.</>, action: async () => { if (await withOk(() => deleteDatabase(db.name))) await refresh(); } })} className="glass-chip-btn-danger ml-auto">drop</button>
|
|
@@ -4047,7 +4051,7 @@ function Schema() {
|
|
|
4047
4051
|
return (
|
|
4048
4052
|
<div className="space-y-6">
|
|
4049
4053
|
<PageHead title="AI Schema Creation" />
|
|
4050
|
-
<PageHero kicker="AI WORKSPACE" title={<>Schemas, <em>born from language.</em></>} desc="Describe the data you need in plain English and let the model return a validated MongoDB schema you can review before it's live." glyph="
|
|
4054
|
+
<PageHero kicker="AI WORKSPACE" title={<>Schemas, <em>born from language.</em></>} desc="Describe the data you need in plain English and let the model return a validated MongoDB schema you can review before it's live." glyph="sparkles" art="bolt" />
|
|
4051
4055
|
<p className="text-sm text-slate-400">Describe the data in plain English — the AI returns a MongoDB <code className="rounded bg-white/10 px-1 font-mono text-xs">$jsonSchema</code> validator plus a field list, which you review and create as a validator-protected collection.</p>
|
|
4052
4056
|
|
|
4053
4057
|
<section className="glass-card space-y-3">
|
package/src/alertCenter.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Icon, type IconName } from './icons.js';
|
|
2
3
|
|
|
3
4
|
export type AlertKind = 'ok' | 'err' | 'warn';
|
|
4
5
|
|
|
@@ -127,7 +128,7 @@ export function useAdminAlert(source: string): [
|
|
|
127
128
|
return [msg, set];
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
const TOAST_ICONS: Record<AlertKind,
|
|
131
|
+
const TOAST_ICONS: Record<AlertKind, IconName> = { ok: 'check', err: 'x', warn: 'alertCircle' };
|
|
131
132
|
|
|
132
133
|
/** Floating alert popups, top-right, themed via the surrounding admin shell. */
|
|
133
134
|
export function ToastStack() {
|
|
@@ -137,7 +138,7 @@ export function ToastStack() {
|
|
|
137
138
|
<div className="admin-toasts" role="region" aria-label="Notifications">
|
|
138
139
|
{toasts.map((t) => (
|
|
139
140
|
<div key={t.id} className={`admin-toast is-${t.kind}`} role="status">
|
|
140
|
-
<span className="admin-toast-icon"
|
|
141
|
+
<span className="admin-toast-icon"><Icon name={TOAST_ICONS[t.kind]} size={14} /></span>
|
|
141
142
|
<span className="admin-toast-body">
|
|
142
143
|
<b>{t.message}</b>
|
|
143
144
|
<small>{t.source} · {t.at}</small>
|
package/src/api.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Admin API client. Auth/admin/plugin calls go to the backend (proxied by Vite
|
|
2
2
|
// in dev); process-control calls go to the supervisor control API
|
|
3
|
-
// (default :7474, auto-allotted upward when busy
|
|
3
|
+
// (default :7474, auto-allotted upward when busy — the backend reports the real port).
|
|
4
4
|
|
|
5
5
|
const SUPERVISOR_FALLBACK = (import.meta.env.VITE_SUPERVISOR_URL as string | undefined) ?? 'http://localhost:7474';
|
|
6
6
|
|
|
@@ -33,7 +33,7 @@ export function getAccessToken() { return accessToken; }
|
|
|
33
33
|
|
|
34
34
|
async function refreshCsrf(): Promise<void> {
|
|
35
35
|
// The server rotates the CSRF cookie on EVERY safe request, so a cached
|
|
36
|
-
// token goes stale the moment any other GET runs (status polling, tables
|
|
36
|
+
// token goes stale the moment any other GET runs (status polling, tables…).
|
|
37
37
|
// Always re-fetch so the header matches the current cookie.
|
|
38
38
|
const r = await fetch('/csrf-token', { credentials: 'include' });
|
|
39
39
|
csrfToken = (await r.json()).token ?? '';
|
|
@@ -59,15 +59,15 @@ async function authErrorMessage(r: Response, fallback: string): Promise<string>
|
|
|
59
59
|
const body = await r.json();
|
|
60
60
|
const msg = body?.error?.message ?? body?.message ?? body?.detail;
|
|
61
61
|
if (typeof msg === 'string' && msg.trim()) return msg;
|
|
62
|
-
} catch { /* non-JSON body
|
|
62
|
+
} catch { /* non-JSON body — fall through */ }
|
|
63
63
|
switch (r.status) {
|
|
64
|
-
case 400: return 'Invalid request
|
|
64
|
+
case 400: return 'Invalid request — please check the form.';
|
|
65
65
|
case 401: return 'Invalid email or password.';
|
|
66
66
|
case 403: return 'Access denied.';
|
|
67
|
-
case 404: return 'Auth endpoint not found
|
|
67
|
+
case 404: return 'Auth endpoint not found — is the backend running?';
|
|
68
68
|
case 409: return 'A user with that email already exists.';
|
|
69
|
-
case 429: return 'Too many attempts
|
|
70
|
-
case 500: return 'Server error
|
|
69
|
+
case 429: return 'Too many attempts — please wait a moment and retry.';
|
|
70
|
+
case 500: return 'Server error — please try again later.';
|
|
71
71
|
default: return `${fallback} (HTTP ${r.status})`;
|
|
72
72
|
}
|
|
73
73
|
}
|
|
@@ -83,7 +83,7 @@ export async function login(email: string, password: string): Promise<{ user: an
|
|
|
83
83
|
body: JSON.stringify({ email, password }),
|
|
84
84
|
});
|
|
85
85
|
} catch {
|
|
86
|
-
throw new Error('Cannot reach the backend
|
|
86
|
+
throw new Error('Cannot reach the backend — is the server running?');
|
|
87
87
|
}
|
|
88
88
|
if (!r.ok) throw new Error(await authErrorMessage(r, 'Login failed'));
|
|
89
89
|
const data = await r.json();
|
|
@@ -103,7 +103,7 @@ export async function registerAndLogin(email: string, password: string, name: st
|
|
|
103
103
|
body: JSON.stringify({ email, password, name }),
|
|
104
104
|
});
|
|
105
105
|
} catch {
|
|
106
|
-
throw new Error('Cannot reach the backend
|
|
106
|
+
throw new Error('Cannot reach the backend — is the server running?');
|
|
107
107
|
}
|
|
108
108
|
if (!r.ok) throw new Error(await authErrorMessage(r, 'Registration failed'));
|
|
109
109
|
const data = await r.json();
|
|
@@ -118,7 +118,7 @@ let refreshInFlight: Promise<boolean> | null = null;
|
|
|
118
118
|
* Restore the admin session from the HttpOnly refresh cookie (app boot).
|
|
119
119
|
* /auth/refresh returns a fresh access token; the user is then fetched via
|
|
120
120
|
* /auth/me. Single-flight so React StrictMode's double effect-run issues one
|
|
121
|
-
* network request
|
|
121
|
+
* network request — the backend rotates refresh tokens and revokes a session
|
|
122
122
|
* family on reuse, so concurrent refreshes would log the user out.
|
|
123
123
|
*/
|
|
124
124
|
export async function refresh(): Promise<boolean> {
|
|
@@ -394,7 +394,7 @@ export interface AiProviderTestResult {
|
|
|
394
394
|
checkedAt: string;
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
-
/** POST /admin/ai/providers/:id/test
|
|
397
|
+
/** POST /admin/ai/providers/:id/test — probe a single provider (lists models). */
|
|
398
398
|
export async function testAiProvider(id: string): Promise<AiProviderTestResult> {
|
|
399
399
|
await refreshCsrf();
|
|
400
400
|
return adminFetch(`/admin/ai/providers/${encodeURIComponent(id)}/test`, {
|
package/src/icons.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ReactNode, SVGProps } from 'react';
|
|
2
|
+
|
|
3
|
+
export type IconName =
|
|
4
|
+
| 'house' | 'server' | 'terminal' | 'braces' | 'key' | 'puzzle' | 'users' | 'activity'
|
|
5
|
+
| 'dollar' | 'database' | 'sparkles' | 'bot' | 'palette'
|
|
6
|
+
| 'search' | 'bell' | 'settings' | 'logOut' | 'menu' | 'x' | 'check' | 'chevronRight'
|
|
7
|
+
| 'arrowRight' | 'refresh' | 'copy' | 'externalLink' | 'pencil' | 'trash' | 'plus'
|
|
8
|
+
| 'square' | 'chevronDown' | 'chevronUp' | 'zap' | 'warning' | 'lock' | 'download'
|
|
9
|
+
| 'upload' | 'globe' | 'cloud' | 'flame' | 'wind' | 'smile' | 'monitor' | 'image'
|
|
10
|
+
| 'repeat' | 'shield' | 'helpCircle' | 'bookOpen' | 'layers' | 'fileText' | 'message'
|
|
11
|
+
| 'gitFork' | 'grid' | 'info' | 'alertCircle' | 'clock' | 'triangle' | 'more' | 'send';
|
|
12
|
+
|
|
13
|
+
const ICONS: Record<IconName, ReactNode> = {
|
|
14
|
+
house: <><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /><polyline points="9 22 9 12 15 12 15 22" /></>,
|
|
15
|
+
server: <><rect x="2" y="2" width="20" height="8" rx="2" /><rect x="2" y="14" width="20" height="8" rx="2" /><line x1="6" y1="6" x2="6.01" y2="6" /><line x1="6" y1="18" x2="6.01" y2="18" /></>,
|
|
16
|
+
terminal: <><polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" /></>,
|
|
17
|
+
braces: <><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1" /><path d="M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1" /></>,
|
|
18
|
+
key: <><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4" /></>,
|
|
19
|
+
puzzle: <><path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.077.877.528 1.073 1.01a2.5 2.5 0 1 0 3.259-3.259c-.482-.196-.933-.558-1.01-1.073-.05-.336.062-.676.303-.917l1.525-1.525A2.402 2.402 0 0 1 12 1.998c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02z" /></>,
|
|
20
|
+
users: <><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></>,
|
|
21
|
+
activity: <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />,
|
|
22
|
+
dollar: <><line x1="12" y1="1" x2="12" y2="23" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>,
|
|
23
|
+
database: <><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5V19A9 3 0 0 0 21 19V5" /><path d="M3 12A9 3 0 0 0 21 12" /></>,
|
|
24
|
+
sparkles: <><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" /><path d="M5 3v4" /><path d="M19 17v4" /><path d="M3 5h4" /><path d="M17 19h4" /></>,
|
|
25
|
+
bot: <><path d="M12 8V4H8" /><rect width="16" height="12" x="4" y="8" rx="2" /><path d="M2 14h2" /><path d="M20 14h2" /><path d="M15 13v2" /><path d="M9 13v2" /></>,
|
|
26
|
+
palette: <><circle cx="13.5" cy="6.5" r="0.5" fill="currentColor" /><circle cx="17.5" cy="10.5" r="0.5" fill="currentColor" /><circle cx="8.5" cy="7.5" r="0.5" fill="currentColor" /><circle cx="6.5" cy="12.5" r="0.5" fill="currentColor" /><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" /></>,
|
|
27
|
+
search: <><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></>,
|
|
28
|
+
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /></>,
|
|
29
|
+
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" /></>,
|
|
30
|
+
logOut: <><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></>,
|
|
31
|
+
menu: <><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" /></>,
|
|
32
|
+
x: <><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></>,
|
|
33
|
+
check: <polyline points="20 6 9 17 4 12" />,
|
|
34
|
+
chevronRight: <polyline points="9 18 15 12 9 6" />,
|
|
35
|
+
arrowRight: <><line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" /></>,
|
|
36
|
+
refresh: <><polyline points="23 4 23 10 17 10" /><polyline points="1 20 1 14 7 14" /><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" /></>,
|
|
37
|
+
copy: <><rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>,
|
|
38
|
+
externalLink: <><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" /></>,
|
|
39
|
+
pencil: <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />,
|
|
40
|
+
trash: <><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></>,
|
|
41
|
+
plus: <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>,
|
|
42
|
+
square: <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />,
|
|
43
|
+
chevronDown: <polyline points="6 9 12 15 18 9" />,
|
|
44
|
+
chevronUp: <polyline points="18 15 12 9 6 15" />,
|
|
45
|
+
zap: <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />,
|
|
46
|
+
warning: <><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></>,
|
|
47
|
+
lock: <><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></>,
|
|
48
|
+
download: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></>,
|
|
49
|
+
upload: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></>,
|
|
50
|
+
globe: <><circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></>,
|
|
51
|
+
cloud: <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />,
|
|
52
|
+
flame: <path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />,
|
|
53
|
+
wind: <><path d="M9.59 4.59A2 2 0 1 1 11 8H2" /><path d="M12.59 16.59A2 2 0 1 0 14 13H2" /><path d="M17.73 8.73A2.5 2.5 0 1 1 19.5 12H2" /></>,
|
|
54
|
+
smile: <><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></>,
|
|
55
|
+
monitor: <><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></>,
|
|
56
|
+
image: <><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /></>,
|
|
57
|
+
repeat: <><polyline points="17 1 21 5 17 9" /><path d="M3 11V9a4 4 0 0 1 4-4h14" /><polyline points="7 23 3 19 7 15" /><path d="M21 13v2a4 4 0 0 1-4 4H3" /></>,
|
|
58
|
+
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />,
|
|
59
|
+
helpCircle: <><circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><line x1="12" y1="17" x2="12.01" y2="17" /></>,
|
|
60
|
+
bookOpen: <><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" /></>,
|
|
61
|
+
layers: <><polygon points="12 2 2 7 12 12 22 7 12 2" /><polyline points="2 17 12 22 22 17" /><polyline points="2 12 12 17 22 12" /></>,
|
|
62
|
+
fileText: <><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /></>,
|
|
63
|
+
message: <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />,
|
|
64
|
+
gitFork: <><circle cx="12" cy="18" r="3" /><circle cx="6" cy="6" r="3" /><circle cx="18" cy="6" r="3" /><path d="M18 9v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9" /><path d="M12 12v3" /></>,
|
|
65
|
+
grid: <><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /></>,
|
|
66
|
+
info: <><circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" /></>,
|
|
67
|
+
alertCircle: <><circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" /></>,
|
|
68
|
+
clock: <><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></>,
|
|
69
|
+
triangle: <path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 20h16a2 2 0 0 0 1.73-2Z" />,
|
|
70
|
+
more: <><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" /></>,
|
|
71
|
+
send: <><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></>,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const ICON_NAMES = new Set<string>(Object.keys(ICONS));
|
|
75
|
+
|
|
76
|
+
export function isIconName(value: string): value is IconName {
|
|
77
|
+
return ICON_NAMES.has(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface IconProps extends SVGProps<SVGSVGElement> {
|
|
81
|
+
name: IconName;
|
|
82
|
+
size?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function Icon({ name, size = 16, className, style, ...rest }: IconProps) {
|
|
86
|
+
return (
|
|
87
|
+
<svg
|
|
88
|
+
width={size}
|
|
89
|
+
height={size}
|
|
90
|
+
viewBox="0 0 24 24"
|
|
91
|
+
fill="none"
|
|
92
|
+
stroke="currentColor"
|
|
93
|
+
strokeWidth="2"
|
|
94
|
+
strokeLinecap="round"
|
|
95
|
+
strokeLinejoin="round"
|
|
96
|
+
className={className}
|
|
97
|
+
style={{ display: 'block', flexShrink: 0, ...(style as React.CSSProperties) }}
|
|
98
|
+
aria-hidden="true"
|
|
99
|
+
{...rest}
|
|
100
|
+
>
|
|
101
|
+
{ICONS[name]}
|
|
102
|
+
</svg>
|
|
103
|
+
);
|
|
104
|
+
}
|
package/src/index.css
CHANGED
|
@@ -747,6 +747,7 @@ pointer-events: none;
|
|
|
747
747
|
background: transparent;
|
|
748
748
|
text-align: left;
|
|
749
749
|
font-size: 0.7rem;
|
|
750
|
+
line-height: 1;
|
|
750
751
|
transition: 160ms ease;
|
|
751
752
|
}
|
|
752
753
|
|
|
@@ -759,12 +760,24 @@ pointer-events: none;
|
|
|
759
760
|
}
|
|
760
761
|
|
|
761
762
|
.admin-nav-icon {
|
|
762
|
-
display:
|
|
763
|
-
|
|
763
|
+
display: inline-flex;
|
|
764
|
+
align-items: center;
|
|
765
|
+
justify-content: center;
|
|
766
|
+
width: 1.25rem;
|
|
767
|
+
height: 1.25rem;
|
|
768
|
+
flex: 0 0 1.25rem;
|
|
764
769
|
place-items: center;
|
|
765
770
|
color: #62bfff;
|
|
766
|
-
|
|
767
|
-
|
|
771
|
+
line-height: 0;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.admin-search-icon {
|
|
775
|
+
display: inline-flex;
|
|
776
|
+
align-items: center;
|
|
777
|
+
justify-content: center;
|
|
778
|
+
flex: 0 0 auto;
|
|
779
|
+
line-height: 0;
|
|
780
|
+
color: #6e87a5;
|
|
768
781
|
}
|
|
769
782
|
|
|
770
783
|
.admin-nav button b {
|
|
@@ -981,14 +994,17 @@ pointer-events: none;
|
|
|
981
994
|
}
|
|
982
995
|
|
|
983
996
|
.admin-search-result-icon {
|
|
984
|
-
display:
|
|
985
|
-
|
|
997
|
+
display: inline-flex;
|
|
998
|
+
align-items: center;
|
|
999
|
+
justify-content: center;
|
|
986
1000
|
width: 1.4rem;
|
|
987
1001
|
height: 1.4rem;
|
|
1002
|
+
flex: 0 0 1.4rem;
|
|
1003
|
+
place-items: center;
|
|
988
1004
|
border-radius: 0.35rem;
|
|
989
1005
|
color: #8fb8e4;
|
|
990
1006
|
background: rgba(84, 183, 255, 0.1);
|
|
991
|
-
|
|
1007
|
+
line-height: 0;
|
|
992
1008
|
}
|
|
993
1009
|
|
|
994
1010
|
.admin-search-empty {
|
|
@@ -1032,12 +1048,14 @@ pointer-events: none;
|
|
|
1032
1048
|
.admin-icon-button,
|
|
1033
1049
|
.admin-user-avatar {
|
|
1034
1050
|
position: relative;
|
|
1035
|
-
display:
|
|
1036
|
-
|
|
1051
|
+
display: inline-flex;
|
|
1052
|
+
align-items: center;
|
|
1053
|
+
justify-content: center;
|
|
1037
1054
|
border: 1px solid rgba(112, 168, 228, 0.2);
|
|
1038
1055
|
border-radius: 0.5rem;
|
|
1039
1056
|
color: #9bb4d0;
|
|
1040
1057
|
background: rgba(25, 57, 95, 0.34);
|
|
1058
|
+
line-height: 0;
|
|
1041
1059
|
}
|
|
1042
1060
|
|
|
1043
1061
|
.admin-icon-button {
|
|
@@ -1424,7 +1442,7 @@ pointer-events: none;
|
|
|
1424
1442
|
border-radius: 50%;
|
|
1425
1443
|
}
|
|
1426
1444
|
|
|
1427
|
-
/* bars
|
|
1445
|
+
/* bars — three rising service bars */
|
|
1428
1446
|
.hero-art--bars {
|
|
1429
1447
|
color: #5ef0b3;
|
|
1430
1448
|
border: 1px solid rgba(94, 240, 179, 0.32);
|
|
@@ -1435,7 +1453,7 @@ pointer-events: none;
|
|
|
1435
1453
|
.hero-art--bars .ha-b { left: 43%; bottom: 30%; width: 6px; height: 46%; background: rgba(94, 240, 179, 0.7); border-radius: 3px 3px 0 0; }
|
|
1436
1454
|
.hero-art--bars .ha-c { left: 58%; bottom: 30%; width: 6px; height: 26%; background: rgba(94, 240, 179, 0.45); border-radius: 3px 3px 0 0; }
|
|
1437
1455
|
|
|
1438
|
-
/* stack
|
|
1456
|
+
/* stack — stacked plugin tiles */
|
|
1439
1457
|
.hero-art--stack {
|
|
1440
1458
|
color: #ffc46b;
|
|
1441
1459
|
border: 1px solid rgba(255, 196, 107, 0.32);
|
|
@@ -1449,7 +1467,7 @@ pointer-events: none;
|
|
|
1449
1467
|
.hero-art--stack .ha-b { bottom: 45%; left: 21%; width: 50%; background: rgba(255, 196, 107, 0.65); }
|
|
1450
1468
|
.hero-art--stack .ha-c { bottom: 60%; left: 21%; width: 34%; background: rgba(255, 196, 107, 0.9); box-shadow: 0 0 8px rgba(255, 196, 107, 0.6); }
|
|
1451
1469
|
|
|
1452
|
-
/* people
|
|
1470
|
+
/* people — clients ringed around a center */
|
|
1453
1471
|
.hero-art--people {
|
|
1454
1472
|
color: #c8b4ff;
|
|
1455
1473
|
border: 1px solid rgba(200, 180, 255, 0.32);
|
|
@@ -1460,7 +1478,7 @@ pointer-events: none;
|
|
|
1460
1478
|
.hero-art--people .ha-b { top: 66%; left: 22%; width: 12px; height: 12px; background: rgba(160, 130, 240, 0.8); box-shadow: 0 0 8px rgba(160, 130, 240, 0.7); }
|
|
1461
1479
|
.hero-art--people .ha-c { bottom: 22%; right: 24%; width: 8px; height: 8px; background: rgba(120, 200, 255, 0.8); box-shadow: 0 0 8px rgba(120, 200, 255, 0.7); }
|
|
1462
1480
|
|
|
1463
|
-
/* wave
|
|
1481
|
+
/* wave — pulsing heartbeat line */
|
|
1464
1482
|
.hero-art--wave {
|
|
1465
1483
|
color: #6fe8a8;
|
|
1466
1484
|
border: 1px solid rgba(111, 232, 168, 0.32);
|
|
@@ -1472,7 +1490,7 @@ pointer-events: none;
|
|
|
1472
1490
|
.hero-art--wave .ha-b { left: 44%; top: 26%; width: 8px; height: 26%; background: rgba(111, 232, 168, 0.55); transform: rotate(20deg); }
|
|
1473
1491
|
.hero-art--wave .ha-c { left: 44%; bottom: 26%; width: 8px; height: 26%; background: rgba(111, 232, 168, 0.85); box-shadow: 0 0 8px rgba(111, 232, 168, 0.6); transform: rotate(-20deg); }
|
|
1474
1492
|
|
|
1475
|
-
/* coins
|
|
1493
|
+
/* coins — stacked cylinder of payment */
|
|
1476
1494
|
.hero-art--coins {
|
|
1477
1495
|
color: #ffd479;
|
|
1478
1496
|
border: 1px solid rgba(255, 212, 121, 0.32);
|
|
@@ -1486,7 +1504,7 @@ pointer-events: none;
|
|
|
1486
1504
|
.hero-art--coins .ha-b { bottom: 43%; background: rgba(255, 212, 121, 0.75); }
|
|
1487
1505
|
.hero-art--coins .ha-c { bottom: 58%; background: rgba(255, 212, 121, 0.95); box-shadow: 0 0 8px rgba(255, 212, 121, 0.7); }
|
|
1488
1506
|
|
|
1489
|
-
/* cubes
|
|
1507
|
+
/* cubes — stacked database shelves */
|
|
1490
1508
|
.hero-art--cubes {
|
|
1491
1509
|
color: #6fd6ff;
|
|
1492
1510
|
border: 1px solid rgba(111, 214, 255, 0.32);
|
|
@@ -1500,7 +1518,7 @@ pointer-events: none;
|
|
|
1500
1518
|
.hero-art--cubes .ha-b { top: 42%; left: 27%; background: rgba(111, 214, 255, 0.75); transform: skew(-10deg); }
|
|
1501
1519
|
.hero-art--cubes .ha-c { top: 58%; left: 27%; background: rgba(111, 214, 255, 0.95); box-shadow: 0 0 8px rgba(111, 214, 255, 0.7); transform: skew(-10deg); }
|
|
1502
1520
|
|
|
1503
|
-
/* spark
|
|
1521
|
+
/* spark — four-petal bloom around a center */
|
|
1504
1522
|
.hero-art--spark {
|
|
1505
1523
|
color: #ff9fd6;
|
|
1506
1524
|
border: 1px solid rgba(255, 159, 214, 0.32);
|
|
@@ -2456,7 +2474,7 @@ pointer-events: none;
|
|
|
2456
2474
|
.account-divider { height: 1px; margin: 0.55rem 0; background: rgba(112, 168, 228, 0.14); }
|
|
2457
2475
|
.account-item { display: flex; align-items: center; gap: 0.6rem; width: 100%; padding: 0.5rem 0.5rem; border: 1px solid transparent; border-radius: 0.5rem; color: #9ab2ce; background: transparent; text-align: left; }
|
|
2458
2476
|
.account-item:hover { border-color: rgba(84, 183, 255, 0.26); color: #edf8ff; background: rgba(61, 139, 220, 0.14); }
|
|
2459
|
-
.account-item-icon { display:
|
|
2477
|
+
.account-item-icon { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; border-radius: 0.4rem; color: #61c7ff; background: rgba(40, 110, 178, 0.25); line-height: 0; }
|
|
2460
2478
|
.account-item > span:last-child { display: flex; flex-direction: column; gap: 0.1rem; }
|
|
2461
2479
|
.account-item b { color: inherit; font-size: 0.62rem; font-weight: 600; }
|
|
2462
2480
|
.account-item small { color: #6f89a7; font-size: 0.5rem; }
|
|
@@ -2487,7 +2505,7 @@ pointer-events: none;
|
|
|
2487
2505
|
}
|
|
2488
2506
|
|
|
2489
2507
|
.admin-tool-rail { position: fixed; top: 50%; right: 0.8rem; z-index: 4; display: flex; flex-direction: column; gap: 0.45rem; padding: 0.55rem 0.4rem; transform: translateY(-50%); border: 1px solid rgba(83, 177, 255, 0.35); border-radius: 999px; background: rgba(6, 19, 39, 0.78); box-shadow: 0 0 28px rgba(45, 125, 221, 0.18); backdrop-filter: blur(18px); }
|
|
2490
|
-
.admin-tool-rail button { position: relative; display:
|
|
2508
|
+
.admin-tool-rail button { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 50%; color: #9fc7ec; background: transparent; line-height: 0; cursor: pointer; }
|
|
2491
2509
|
.admin-tool-rail button:hover { color: white; background: rgba(71, 161, 247, 0.2); }
|
|
2492
2510
|
.admin-tool-rail button.is-active { color: white; background: linear-gradient(135deg, rgba(38, 130, 225, 0.55), rgba(90, 67, 183, 0.45)); box-shadow: 0 0 12px rgba(45, 125, 221, 0.3); }
|
|
2493
2511
|
.admin-tool-rail button[data-tip]::after {
|
|
@@ -2533,7 +2551,7 @@ pointer-events: none;
|
|
|
2533
2551
|
.rail-list--nav li { padding: 0; }
|
|
2534
2552
|
.rail-list--nav li button { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.4rem 0.45rem; border: 0; border-radius: 0.4rem; color: #9ab2ce; background: transparent; text-align: left; cursor: pointer; }
|
|
2535
2553
|
.rail-list--nav li button:hover, .rail-list--nav li button.is-on { color: #edf8ff; background: rgba(61, 139, 220, 0.14); }
|
|
2536
|
-
.rail-fav-icon {
|
|
2554
|
+
.rail-fav-icon { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex: 0 0 20px; line-height: 0; }
|
|
2537
2555
|
.rail-fav-label { flex: 1; font-size: 0.66rem; }
|
|
2538
2556
|
.rail-fav-check { color: #61c7ff; font-size: 0.66rem; }
|
|
2539
2557
|
.rail-appearance { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.4rem; }
|
|
@@ -3477,5 +3495,45 @@ pointer-events: none;
|
|
|
3477
3495
|
}
|
|
3478
3496
|
.safe-goto-link:hover { color: #bfdbfe; text-decoration: underline; }
|
|
3479
3497
|
|
|
3498
|
+
/* ===== Inline SVG icon system (see src/icons.tsx) ===== */
|
|
3499
|
+
.admin-shell svg,
|
|
3500
|
+
.admin-login svg {
|
|
3501
|
+
flex-shrink: 0;
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
.admin-nav-icon svg,
|
|
3505
|
+
.admin-search-result-icon svg,
|
|
3506
|
+
.rail-fav-icon svg,
|
|
3507
|
+
.account-item-icon svg,
|
|
3508
|
+
.notify-head-icon svg,
|
|
3509
|
+
.notify-empty-icon svg,
|
|
3510
|
+
.log-ai-title svg,
|
|
3511
|
+
.provider-avatar svg {
|
|
3512
|
+
display: block;
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
.hero-art strong svg,
|
|
3516
|
+
.hero-orbit-art strong svg,
|
|
3517
|
+
.env-lock-glyph svg {
|
|
3518
|
+
display: block;
|
|
3519
|
+
filter: drop-shadow(0 0 8px currentColor);
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
.surface-icon svg {
|
|
3523
|
+
width: 20px;
|
|
3524
|
+
height: 20px;
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
.log-empty-glyph svg {
|
|
3528
|
+
display: block;
|
|
3529
|
+
opacity: 0.85;
|
|
3530
|
+
color: var(--admin-muted, #98aec9);
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
.admin-topbar .admin-icon-button svg,
|
|
3534
|
+
.admin-tool-rail button svg {
|
|
3535
|
+
display: block;
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3480
3538
|
|
|
3481
3539
|
|