@bhooai/nexus-cli 2.0.6 → 2.0.7
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/commands/add.ts +8 -3
- package/src/commands/dev.ts +18 -2
- package/src/commands/init.ts +3 -0
- package/src/devPanel.ts +138 -11
- package/src/devServiceManager.ts +210 -11
- package/src/index.ts +19 -0
- package/templates/base/apps/admin/package.json.ejs +1 -0
- package/templates/base/apps/admin/src/App.tsx +38 -4127
- package/templates/base/apps/admin/src/alertCenter.tsx +3 -2
- package/templates/base/apps/admin/src/api.ts +11 -11
- package/templates/base/apps/admin/src/components/ThemeCentre.tsx +201 -0
- package/templates/base/apps/admin/src/components/shell/AppearancePanel.tsx +37 -0
- package/templates/base/apps/admin/src/components/shell/Dashboard.tsx +441 -0
- package/templates/base/apps/admin/src/components/shell/Login.tsx +69 -0
- package/templates/base/apps/admin/src/components/shell/NotifyPanel.tsx +102 -0
- package/templates/base/apps/admin/src/components/shell/RailPanel.tsx +133 -0
- package/templates/base/apps/admin/src/components/shell/SettingsDialog.tsx +217 -0
- package/templates/base/apps/admin/src/components/ui/ConfirmDialog.tsx +38 -0
- package/templates/base/apps/admin/src/components/ui/LintChecks.tsx +54 -0
- package/templates/base/apps/admin/src/components/ui/PageHead.tsx +13 -0
- package/templates/base/apps/admin/src/components/ui/PageHero.tsx +15 -0
- package/templates/base/apps/admin/src/icons.tsx +104 -0
- package/templates/base/apps/admin/src/index.css +77 -19
- package/templates/base/apps/admin/src/lib/constants.ts +94 -0
- package/templates/base/apps/admin/src/lib/theme.ts +80 -0
- package/templates/base/apps/admin/src/lib/types.ts +39 -0
- package/templates/base/apps/admin/src/lib/utils.ts +147 -0
- package/templates/base/apps/admin/src/main.tsx.ejs +3 -2
- package/templates/base/apps/admin/src/pages/Config.tsx +108 -0
- package/templates/base/apps/admin/src/pages/Databases.tsx +122 -0
- package/templates/base/apps/admin/src/pages/Environment.tsx +69 -0
- package/templates/base/apps/admin/src/pages/Logs.tsx +270 -0
- package/templates/base/apps/admin/src/pages/Monitoring.tsx +88 -0
- package/templates/base/apps/admin/src/pages/Overview.tsx +108 -0
- package/templates/base/apps/admin/src/pages/Payments.tsx +68 -0
- package/templates/base/apps/admin/src/pages/Plugins.tsx +36 -0
- package/templates/base/apps/admin/src/pages/Processes.tsx +67 -0
- package/templates/base/apps/admin/src/pages/Schema.tsx +130 -0
- package/templates/base/apps/admin/src/pages/Users.tsx +144 -0
- package/templates/base/apps/admin/src/pages/ai/AddProviderDialog.tsx +150 -0
- package/templates/base/apps/admin/src/pages/ai/AiAgents.tsx +31 -0
- package/templates/base/apps/admin/src/pages/ai/AiChatPlayground.tsx +208 -0
- package/templates/base/apps/admin/src/pages/ai/AiProviders.tsx +276 -0
- package/templates/base/apps/admin/src/pages/ai/AiSettings.tsx +84 -0
- package/templates/base/apps/admin/src/pages/ai/ProviderCard.tsx +145 -0
- package/templates/base/apps/admin/src/pages/ai/ProviderGroup.tsx +50 -0
- package/templates/base/apps/admin/src/pages/ai/ProviderKeyDialog.tsx +66 -0
- package/templates/base/apps/admin/src/pages/monitoring/RequestSeriesChart.tsx +70 -0
- package/templates/base/apps/admin/src/pages/overview/TrafficChart.tsx +32 -0
- package/templates/base/apps/admin/src/pages/payments/OrdersTable.tsx +27 -0
- package/templates/base/apps/admin/src/pages/payments/PaymentKeysDialog.tsx +74 -0
- package/templates/base/apps/admin/src/pages/payments/TestConsole.tsx +173 -0
- package/templates/base/apps/admin/src/pages/payments/TransactionsTable.tsx +26 -0
- package/templates/base/apps/admin/src/style.css +6517 -0
- package/templates/base/apps/admin/vite.config.ts.ejs +13 -8
- package/templates/base/apps/ai-server/main.py.ejs +2 -2
- package/templates/base/apps/backend/src/routes/index.ts.ejs +1 -1
- package/templates/base/apps/frontend/src/App.tsx.ejs +10 -0
- package/templates/base/apps/frontend/src/main.tsx.ejs +2 -10
- package/templates/base/apps/frontend/vite.config.ts.ejs +9 -4
|
@@ -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>
|
|
@@ -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`, {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import type { AdminTheme, UiMode, CustomTheme, ParticleConfig } from '../lib/types.js';
|
|
4
|
+
import { SWATCH_CPLAETTE, THEME_PRESETS, DEFAULT_PARTICLES, CUSTOM_THEME_KEY, PARTICLES_KEY, TOGGLE_STYLES } from '../lib/constants.js';
|
|
5
|
+
import { randomAccentPalette, randomParticleColors } from '../lib/theme.js';
|
|
6
|
+
import { Icon } from '../icons.js';
|
|
7
|
+
import { PageHead } from './ui/PageHead.js';
|
|
8
|
+
import { PageHero } from './ui/PageHero.js';
|
|
9
|
+
|
|
10
|
+
export function ThemeCentre({
|
|
11
|
+
theme,
|
|
12
|
+
onThemeChange,
|
|
13
|
+
uiMode,
|
|
14
|
+
onUiModeChange,
|
|
15
|
+
custom,
|
|
16
|
+
onCustom,
|
|
17
|
+
particles,
|
|
18
|
+
onParticles,
|
|
19
|
+
}: {
|
|
20
|
+
theme: AdminTheme;
|
|
21
|
+
onThemeChange: (theme: AdminTheme) => void;
|
|
22
|
+
uiMode: UiMode;
|
|
23
|
+
onUiModeChange: (mode: UiMode) => void;
|
|
24
|
+
custom: CustomTheme;
|
|
25
|
+
onCustom: (partial: Partial<CustomTheme>) => void;
|
|
26
|
+
particles: ParticleConfig;
|
|
27
|
+
onParticles: (partial: Partial<ParticleConfig>) => void;
|
|
28
|
+
}) {
|
|
29
|
+
const [copied, setCopied] = useState(false);
|
|
30
|
+
const colors: { key: keyof Omit<CustomTheme, 'active'>; label: string; detail: string }[] = [
|
|
31
|
+
{ key: 'blue', label: 'Primary blue', detail: 'Buttons, focus rings & links' },
|
|
32
|
+
{ key: 'violet', label: 'Accent violet', detail: 'Highlights & gradients' },
|
|
33
|
+
{ key: 'pink', label: 'Accent pink', detail: 'Special surfaces & marks' },
|
|
34
|
+
{ key: 'bg', label: 'Background', detail: 'Base canvas of the shell' },
|
|
35
|
+
{ key: 'ink', label: 'Foreground text', detail: 'Headings & body ink' },
|
|
36
|
+
{ key: 'muted', label: 'Muted text', detail: 'Captions & secondary labels' },
|
|
37
|
+
];
|
|
38
|
+
const setColor = (key: keyof Omit<CustomTheme, 'active'>, value: string) => onCustom({ [key]: value });
|
|
39
|
+
const setParticleColor = (index: number, value: string) => {
|
|
40
|
+
const next = [...particles.colors];
|
|
41
|
+
next[index] = value;
|
|
42
|
+
onParticles({ colors: next });
|
|
43
|
+
};
|
|
44
|
+
const addParticleColor = () => {
|
|
45
|
+
const next = [...particles.colors, SWATCH_CPLAETTE[particles.colors.length % SWATCH_CPLAETTE.length]];
|
|
46
|
+
onParticles({ colors: next });
|
|
47
|
+
};
|
|
48
|
+
const removeParticleColor = (index: number) => {
|
|
49
|
+
if (particles.colors.length <= 1) return;
|
|
50
|
+
onParticles({ colors: particles.colors.filter((_, i) => i !== index) });
|
|
51
|
+
};
|
|
52
|
+
const exportJson = () => {
|
|
53
|
+
const payload = JSON.stringify({ version: 1, theme, uiMode, custom, particles }, null, 2);
|
|
54
|
+
const write = () => { navigator.clipboard.writeText(payload).then(() => setCopied(true)).catch(() => fallback(payload)); };
|
|
55
|
+
const fallback = (text: string) => {
|
|
56
|
+
const el = document.createElement('textarea');
|
|
57
|
+
el.value = text; document.body.appendChild(el); el.select();
|
|
58
|
+
try { document.execCommand('copy'); setCopied(true); } catch { /* ignore */ }
|
|
59
|
+
document.body.removeChild(el);
|
|
60
|
+
};
|
|
61
|
+
write();
|
|
62
|
+
window.setTimeout(() => setCopied(false), 1600);
|
|
63
|
+
};
|
|
64
|
+
const importJson = async () => {
|
|
65
|
+
let text = '';
|
|
66
|
+
try { text = await navigator.clipboard.readText(); } catch { text = window.prompt('Paste theme JSON to import:') ?? ''; }
|
|
67
|
+
if (!text) return;
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(text) as { theme?: AdminTheme; uiMode?: UiMode; custom?: Partial<CustomTheme>; particles?: Partial<ParticleConfig> };
|
|
70
|
+
if (parsed.theme && ['aurora', 'midnight', 'violet'].includes(parsed.theme)) onThemeChange(parsed.theme);
|
|
71
|
+
if (parsed.uiMode && ['workspace', 'compact', 'focus'].includes(parsed.uiMode)) onUiModeChange(parsed.uiMode);
|
|
72
|
+
if (parsed.custom) onCustom(parsed.custom);
|
|
73
|
+
if (parsed.particles) onParticles(parsed.particles);
|
|
74
|
+
} catch { /* ignore bad import */ }
|
|
75
|
+
};
|
|
76
|
+
const resetAll = () => {
|
|
77
|
+
onCustom({ ...THEME_PRESETS[theme], active: false });
|
|
78
|
+
onParticles({ ...DEFAULT_PARTICLES });
|
|
79
|
+
try { localStorage.removeItem(CUSTOM_THEME_KEY); localStorage.removeItem(PARTICLES_KEY); } catch { /* ignore */ }
|
|
80
|
+
};
|
|
81
|
+
const slider = (label: string, detail: string, value: number, min: number, max: number, step: number, display: string, onChange: (v: number) => void) => (
|
|
82
|
+
<label className="surface-row">
|
|
83
|
+
<span className="surface-meta"><b>{label}</b><small>{detail}</small></span>
|
|
84
|
+
<input type="range" className="surface-range" min={min} max={max} step={step} value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
|
85
|
+
<em className="surface-value">{display}</em>
|
|
86
|
+
</label>
|
|
87
|
+
);
|
|
88
|
+
return (
|
|
89
|
+
<div className="space-y-6">
|
|
90
|
+
<PageHead title="Theme Centre">
|
|
91
|
+
<div className="heading-actions">
|
|
92
|
+
<button type="button" className="workspace-action" onClick={() => { void importJson(); }}><Icon name="download" size={12} /> Import</button>
|
|
93
|
+
<button type="button" className="workspace-action" onClick={exportJson}>{copied ? 'Copied' : <><Icon name="upload" size={12} /> Export</>}</button>
|
|
94
|
+
<button type="button" className="workspace-action" onClick={resetAll}><Icon name="refresh" size={12} /> Reset all</button>
|
|
95
|
+
</div>
|
|
96
|
+
</PageHead>
|
|
97
|
+
<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" />
|
|
98
|
+
<section className="glass-card space-y-4">
|
|
99
|
+
<div className="flex items-center justify-between">
|
|
100
|
+
<div><span className="admin-overline">CURATED THEMES</span><h3 className="glass-h3">Start from a surface</h3></div>
|
|
101
|
+
<span className="live-badge"><i className="status-dot good" /> LIVE</span>
|
|
102
|
+
</div>
|
|
103
|
+
<div className="theme-gallery">
|
|
104
|
+
{([['aurora', 'Aurora', 'Electric blue glow'], ['midnight', 'Midnight', 'Deep navy calm'], ['violet', 'Violet', 'Indigo shimmer']] as const).map(([id, label, detail]) => {
|
|
105
|
+
const preset = THEME_PRESETS[id];
|
|
106
|
+
const isActive = !custom.active && theme === id;
|
|
107
|
+
return (
|
|
108
|
+
<button key={id} type="button" className={`theme-card${isActive ? ' is-active' : ''}`} onClick={() => { onThemeChange(id); onCustom({ ...preset, active: false }); }}>
|
|
109
|
+
<span className={`theme-preview ${id}`} />
|
|
110
|
+
<span className="theme-card-body"><b>{label}</b><small>{detail}</small></span>
|
|
111
|
+
{isActive && <i className="theme-card-check">✓</i>}
|
|
112
|
+
</button>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</div>
|
|
116
|
+
<div className="ui-mode-options ui-mode-row">
|
|
117
|
+
{([['workspace', 'Workspace', 'Inspector + tools'], ['compact', 'Compact', 'More content'], ['focus', 'Focus', 'Canvas first']] as const).map(([id, label, detail]) => (
|
|
118
|
+
<button key={id} onClick={() => onUiModeChange(id)} className={uiMode === id ? 'is-selected' : ''}><span>{label}</span><small>{detail}</small></button>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
</section>
|
|
122
|
+
<section className="glass-card space-y-4">
|
|
123
|
+
<div className="flex items-center justify-between">
|
|
124
|
+
<div><span className="admin-overline">CUSTOM THEME</span><h3 className="glass-h3">Your own palette</h3></div>
|
|
125
|
+
<div className="flex items-center gap-2">
|
|
126
|
+
<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>
|
|
127
|
+
<button type="button" className={`custom-toggle${custom.active ? ' is-active' : ''}`} onClick={() => onCustom({ active: !custom.active })}>{custom.active ? 'Applied · live' : 'Apply custom'}</button>
|
|
128
|
+
<button type="button" className="glass-chip-btn" onClick={() => onCustom({ ...THEME_PRESETS[theme], active: true })}>Seed from {theme}</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
<p className="text-sm text-slate-400">Tune each color below — the workspace re-themes instantly while custom is applied. Stored locally in your browser.</p>
|
|
132
|
+
<div className="custom-grid">
|
|
133
|
+
{colors.map((c) => (
|
|
134
|
+
<label key={c.key} className="color-row">
|
|
135
|
+
<span className="color-swatch"><input type="color" value={custom[c.key]} disabled={!custom.active} onChange={(e) => setColor(c.key, e.target.value)} /></span>
|
|
136
|
+
<span className="color-meta"><b>{c.label}</b><small>{c.detail}</small></span>
|
|
137
|
+
<input className="glass-input color-hex" value={custom[c.key]} disabled={!custom.active} onChange={(e) => { const v = e.target.value.trim(); if (/^#[0-9a-fA-F]{3,8}$/.test(v)) setColor(c.key, v); }} />
|
|
138
|
+
</label>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
<div className="theme-preview-tile" style={custom.active ? { ['--p-blue' as string]: custom.blue, ['--p-violet' as string]: custom.violet, ['--p-pink' as string]: custom.pink, ['--p-bg' as string]: custom.bg } : undefined}>
|
|
142
|
+
<div className="theme-preview-glow" />
|
|
143
|
+
<div className="theme-preview-card">
|
|
144
|
+
<span className="theme-preview-overline">LIVE PREVIEW</span>
|
|
145
|
+
<b className="theme-preview-title">Surface preview</b>
|
|
146
|
+
<div className="theme-preview-actions"><button className="glass-chip-btn-primary flex-1 justify-center">Primary</button><button className="glass-chip-btn flex-1 justify-center">chip</button></div>
|
|
147
|
+
<div className="theme-preview-dots"><i /><i /><i /></div>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</section>
|
|
151
|
+
<section className="glass-card space-y-4">
|
|
152
|
+
<div className="flex items-center justify-between">
|
|
153
|
+
<div><span className="admin-overline">SURFACE TUNING</span><h3 className="glass-h3">Glow, glass & shape</h3></div>
|
|
154
|
+
<span className="live-badge"><i className="status-dot good" /> LIVE</span>
|
|
155
|
+
</div>
|
|
156
|
+
{slider('Ambient glow', 'Mesh & aura strength behind the shell', custom.mesh, 0, 1, 0.02, `${Math.round(custom.mesh * 100)}%`, (v) => onCustom({ mesh: v }))}
|
|
157
|
+
{slider('Panel glass', 'Frosted blur behind cards & panels', custom.glass, 0, 1, 0.02, `${Math.round(custom.glass * 100)}%`, (v) => onCustom({ glass: v }))}
|
|
158
|
+
{slider('Corner radius', 'Roundness of cards, panels & buttons', custom.radius, 0, 24, 1, `${custom.radius}px`, (v) => onCustom({ radius: v }))}
|
|
159
|
+
<div>
|
|
160
|
+
<span className="admin-overline">TOGGLE STYLE</span>
|
|
161
|
+
<div className="toggle-style-row">
|
|
162
|
+
{TOGGLE_STYLES.map((t) => (
|
|
163
|
+
<button
|
|
164
|
+
key={t.id}
|
|
165
|
+
type="button"
|
|
166
|
+
className={`toggle-style-btn${custom.toggle === t.id ? ' is-selected' : ''}`}
|
|
167
|
+
onClick={() => onCustom({ toggle: t.id })}
|
|
168
|
+
>
|
|
169
|
+
<span className={`ai-toggle tg-${t.id} is-on toggle-style-preview`}><span className="ai-toggle-knob" /></span>
|
|
170
|
+
<span className="toggle-style-meta"><b>{t.label}</b><small>{t.hint}</small></span>
|
|
171
|
+
{custom.toggle === t.id && <i className="toggle-style-check">✓</i>}
|
|
172
|
+
</button>
|
|
173
|
+
))}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</section>
|
|
177
|
+
<section className="glass-card space-y-4">
|
|
178
|
+
<div className="flex items-center justify-between">
|
|
179
|
+
<div><span className="admin-overline">PARTICLE FIELD</span><h3 className="glass-h3">Floating background dust</h3></div>
|
|
180
|
+
<div className="flex items-center gap-2">
|
|
181
|
+
<button type="button" className="glass-chip-btn" onClick={() => onParticles({ colors: randomParticleColors() })}><Icon name="sparkles" size={12} /> Randomize</button>
|
|
182
|
+
<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>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
<p className="text-sm text-slate-400">Control how many particles drift behind the workspace, how fast they rise, and build a custom color list for them.</p>
|
|
186
|
+
{slider('Particle amount', 'Count of dots in the field', particles.amount, 0, 48, 1, String(particles.amount), (v) => onParticles({ amount: v }))}
|
|
187
|
+
{slider('Particle speed', 'Rise speed multiplier', particles.speed, 0.2, 3, 0.05, `${particles.speed.toFixed(2)}×`, (v) => onParticles({ speed: v }))}
|
|
188
|
+
<div className="palette-list">
|
|
189
|
+
{particles.colors.map((c, i) => (
|
|
190
|
+
<div className="palette-chip" key={i}>
|
|
191
|
+
<span className="color-swatch"><input type="color" value={c} onChange={(e) => setParticleColor(i, e.target.value)} /></span>
|
|
192
|
+
<input className="glass-input color-hex" value={c} onChange={(e) => { const v = e.target.value.trim(); if (/^#[0-9a-fA-F]{3,8}$/.test(v)) setParticleColor(i, v); }} />
|
|
193
|
+
<button type="button" className="row-delete" onClick={() => removeParticleColor(i)} aria-label="Remove color" disabled={particles.colors.length <= 1}>×</button>
|
|
194
|
+
</div>
|
|
195
|
+
))}
|
|
196
|
+
<button type="button" className="add-color" onClick={addParticleColor}>+ Add color</button>
|
|
197
|
+
</div>
|
|
198
|
+
</section>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { AdminTheme, UiMode } from '../../lib/types.js';
|
|
3
|
+
|
|
4
|
+
export function AppearancePanel({
|
|
5
|
+
theme,
|
|
6
|
+
uiMode,
|
|
7
|
+
onThemeChange,
|
|
8
|
+
onUiModeChange,
|
|
9
|
+
}: {
|
|
10
|
+
theme: AdminTheme;
|
|
11
|
+
uiMode: UiMode;
|
|
12
|
+
onThemeChange: (theme: AdminTheme) => void;
|
|
13
|
+
onUiModeChange: (mode: UiMode) => void;
|
|
14
|
+
}) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="appearance-panel">
|
|
17
|
+
<div className="appearance-heading"><span className="admin-overline">APPEARANCE</span><span>LOCAL</span></div>
|
|
18
|
+
<strong>Choose your surface</strong>
|
|
19
|
+
<div className="appearance-options">
|
|
20
|
+
{([['aurora', 'Aurora', 'Electric blue'], ['midnight', 'Midnight', 'Deep navy'], ['violet', 'Violet', 'Indigo glow']] as const).map(([id, label, detail]) => (
|
|
21
|
+
<button key={id} onClick={() => onThemeChange(id)} className={theme === id ? 'is-selected' : ''}>
|
|
22
|
+
<span className={`theme-preview ${id}`} />
|
|
23
|
+
<span><b>{label}</b><small>{detail}</small></span>
|
|
24
|
+
{theme === id && <i>✓</i>}
|
|
25
|
+
</button>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
<div className="appearance-divider" />
|
|
29
|
+
<span className="admin-overline">INTERFACE MODE</span>
|
|
30
|
+
<div className="ui-mode-options">
|
|
31
|
+
{([['workspace', 'Workspace', 'Inspector + tools'], ['compact', 'Compact', 'More content'], ['focus', 'Focus', 'Canvas first']] as const).map(([id, label, detail]) => (
|
|
32
|
+
<button key={id} onClick={() => onUiModeChange(id)} className={uiMode === id ? 'is-selected' : ''}><span>{label}</span><small>{detail}</small></button>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|