create-ekka-desktop-app 0.2.2
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/README.md +137 -0
- package/bin/cli.js +72 -0
- package/package.json +23 -0
- package/template/branding/app.json +6 -0
- package/template/branding/icon.icns +0 -0
- package/template/eslint.config.js +98 -0
- package/template/index.html +29 -0
- package/template/package.json +40 -0
- package/template/src/app/App.tsx +24 -0
- package/template/src/demo/DemoApp.tsx +260 -0
- package/template/src/demo/components/Banner.tsx +82 -0
- package/template/src/demo/components/EmptyState.tsx +61 -0
- package/template/src/demo/components/InfoPopover.tsx +171 -0
- package/template/src/demo/components/InfoTooltip.tsx +76 -0
- package/template/src/demo/components/LearnMore.tsx +98 -0
- package/template/src/demo/components/NodeCredentialsOnboarding.tsx +219 -0
- package/template/src/demo/components/SetupWizard.tsx +48 -0
- package/template/src/demo/components/StatusBadge.tsx +83 -0
- package/template/src/demo/components/index.ts +10 -0
- package/template/src/demo/hooks/index.ts +6 -0
- package/template/src/demo/hooks/useAuditEvents.ts +30 -0
- package/template/src/demo/layout/Shell.tsx +110 -0
- package/template/src/demo/layout/Sidebar.tsx +192 -0
- package/template/src/demo/pages/AuditLogPage.tsx +235 -0
- package/template/src/demo/pages/DocGenPage.tsx +874 -0
- package/template/src/demo/pages/HomeSetupPage.tsx +182 -0
- package/template/src/demo/pages/LoginPage.tsx +192 -0
- package/template/src/demo/pages/PathPermissionsPage.tsx +873 -0
- package/template/src/demo/pages/RunnerPage.tsx +445 -0
- package/template/src/demo/pages/SystemPage.tsx +557 -0
- package/template/src/demo/pages/VaultPage.tsx +805 -0
- package/template/src/ekka/__tests__/demo-backend.test.ts +187 -0
- package/template/src/ekka/audit/index.ts +7 -0
- package/template/src/ekka/audit/store.ts +68 -0
- package/template/src/ekka/audit/types.ts +22 -0
- package/template/src/ekka/auth/client.ts +212 -0
- package/template/src/ekka/auth/index.ts +30 -0
- package/template/src/ekka/auth/storage.ts +114 -0
- package/template/src/ekka/auth/types.ts +67 -0
- package/template/src/ekka/backend/demo.ts +151 -0
- package/template/src/ekka/backend/interface.ts +36 -0
- package/template/src/ekka/config.ts +48 -0
- package/template/src/ekka/constants.ts +143 -0
- package/template/src/ekka/errors.ts +54 -0
- package/template/src/ekka/index.ts +516 -0
- package/template/src/ekka/internal/backend.ts +156 -0
- package/template/src/ekka/internal/index.ts +7 -0
- package/template/src/ekka/ops/auth.ts +29 -0
- package/template/src/ekka/ops/debug.ts +68 -0
- package/template/src/ekka/ops/home.ts +101 -0
- package/template/src/ekka/ops/index.ts +16 -0
- package/template/src/ekka/ops/nodeCredentials.ts +131 -0
- package/template/src/ekka/ops/nodeSession.ts +145 -0
- package/template/src/ekka/ops/paths.ts +183 -0
- package/template/src/ekka/ops/runner.ts +86 -0
- package/template/src/ekka/ops/runtime.ts +31 -0
- package/template/src/ekka/ops/setup.ts +47 -0
- package/template/src/ekka/ops/vault.ts +459 -0
- package/template/src/ekka/ops/workflowRuns.ts +116 -0
- package/template/src/ekka/types.ts +82 -0
- package/template/src/ekka/utils/idempotency.ts +14 -0
- package/template/src/ekka/utils/index.ts +7 -0
- package/template/src/ekka/utils/time.ts +77 -0
- package/template/src/main.tsx +12 -0
- package/template/src/vite-env.d.ts +12 -0
- package/template/src-tauri/Cargo.toml +41 -0
- package/template/src-tauri/build.rs +3 -0
- package/template/src-tauri/capabilities/default.json +11 -0
- package/template/src-tauri/icons/icon.icns +0 -0
- package/template/src-tauri/icons/icon.png +0 -0
- package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
- package/template/src-tauri/src/bootstrap.rs +37 -0
- package/template/src-tauri/src/commands.rs +1215 -0
- package/template/src-tauri/src/device_secret.rs +111 -0
- package/template/src-tauri/src/engine_process.rs +538 -0
- package/template/src-tauri/src/grants.rs +129 -0
- package/template/src-tauri/src/handlers/home.rs +65 -0
- package/template/src-tauri/src/handlers/mod.rs +7 -0
- package/template/src-tauri/src/handlers/paths.rs +128 -0
- package/template/src-tauri/src/handlers/vault.rs +680 -0
- package/template/src-tauri/src/main.rs +243 -0
- package/template/src-tauri/src/node_auth.rs +858 -0
- package/template/src-tauri/src/node_credentials.rs +541 -0
- package/template/src-tauri/src/node_runner.rs +882 -0
- package/template/src-tauri/src/node_vault_crypto.rs +113 -0
- package/template/src-tauri/src/node_vault_store.rs +267 -0
- package/template/src-tauri/src/ops/auth.rs +50 -0
- package/template/src-tauri/src/ops/home.rs +251 -0
- package/template/src-tauri/src/ops/mod.rs +7 -0
- package/template/src-tauri/src/ops/runtime.rs +21 -0
- package/template/src-tauri/src/state.rs +639 -0
- package/template/src-tauri/src/types.rs +84 -0
- package/template/src-tauri/tauri.conf.json +41 -0
- package/template/tsconfig.json +26 -0
- package/template/tsconfig.tsbuildinfo +1 -0
- package/template/vite.config.ts +34 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EKKA Demo App
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Check setup status (pre-login) - only node credentials
|
|
6
|
+
* 2. If setup incomplete (no node credentials), show SetupWizard
|
|
7
|
+
* 3. Connect to engine
|
|
8
|
+
* 4. Check auth - if not logged in, show LoginPage
|
|
9
|
+
* 5. After login, check home.status for HOME grant
|
|
10
|
+
* 6. If not HOME_GRANTED, show HomeSetupPage (requests grant from engine)
|
|
11
|
+
* 7. Show main app when HOME_GRANTED
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState, useEffect, type ReactElement, type CSSProperties } from 'react';
|
|
15
|
+
import { ekka, advanced, EkkaError, addAuditEvent, type HomeStatus, type SetupStatus } from '../ekka';
|
|
16
|
+
import { Shell } from './layout/Shell';
|
|
17
|
+
import { type Page } from './layout/Sidebar';
|
|
18
|
+
import { SystemPage } from './pages/SystemPage';
|
|
19
|
+
import { AuditLogPage } from './pages/AuditLogPage';
|
|
20
|
+
import { PathPermissionsPage } from './pages/PathPermissionsPage';
|
|
21
|
+
import { VaultPage } from './pages/VaultPage';
|
|
22
|
+
import { DocGenPage } from './pages/DocGenPage';
|
|
23
|
+
import { RunnerPage } from './pages/RunnerPage';
|
|
24
|
+
import { LoginPage } from './pages/LoginPage';
|
|
25
|
+
import { HomeSetupPage } from './pages/HomeSetupPage';
|
|
26
|
+
import { SetupWizard } from './components/SetupWizard';
|
|
27
|
+
|
|
28
|
+
type AppState = 'loading' | 'setup' | 'login' | 'home-setup' | 'ready';
|
|
29
|
+
|
|
30
|
+
interface DemoState {
|
|
31
|
+
appState: AppState;
|
|
32
|
+
setupStatus: SetupStatus | null;
|
|
33
|
+
homeStatus: HomeStatus | null;
|
|
34
|
+
connected: boolean;
|
|
35
|
+
error: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function DemoApp(): ReactElement {
|
|
39
|
+
const [selectedPage, setSelectedPage] = useState<Page>('path-permissions');
|
|
40
|
+
const [darkMode, setDarkMode] = useState<boolean>(() => {
|
|
41
|
+
if (typeof window !== 'undefined') {
|
|
42
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
});
|
|
46
|
+
const [state, setState] = useState<DemoState>({
|
|
47
|
+
appState: 'loading',
|
|
48
|
+
setupStatus: null,
|
|
49
|
+
homeStatus: null,
|
|
50
|
+
connected: false,
|
|
51
|
+
error: null,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
void initializeApp();
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
async function initializeApp(): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
// Step 1: Check setup status BEFORE connect (pre-login)
|
|
61
|
+
const setupStatus = await ekka.setup.status();
|
|
62
|
+
setState((s) => ({ ...s, setupStatus }));
|
|
63
|
+
|
|
64
|
+
// Log setup gate status
|
|
65
|
+
console.log(`[ekka] op=desktop.setup.gate setupComplete=${setupStatus.setupComplete}`);
|
|
66
|
+
|
|
67
|
+
// HARD GATE: If setup is incomplete, show wizard - NO exceptions
|
|
68
|
+
if (!setupStatus.setupComplete) {
|
|
69
|
+
setState((s) => ({ ...s, appState: 'setup' }));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Step 2: Connect to engine (REQUIRED before login)
|
|
74
|
+
await ekka.connect();
|
|
75
|
+
setState((s) => ({ ...s, connected: true }));
|
|
76
|
+
|
|
77
|
+
console.log('[ekka] op=desktop.connect.success');
|
|
78
|
+
|
|
79
|
+
addAuditEvent({
|
|
80
|
+
type: 'connection.established',
|
|
81
|
+
description: 'Connected to EKKA backend',
|
|
82
|
+
technical: { mode: advanced.internal.mode() },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Step 3: Check auth
|
|
86
|
+
if (!ekka.auth.isLoggedIn()) {
|
|
87
|
+
setState((s) => ({ ...s, appState: 'login' }));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const user = ekka.auth.user();
|
|
92
|
+
if (user) {
|
|
93
|
+
const tenantId = user.company?.id || 'default';
|
|
94
|
+
await advanced.auth.setContext({ tenantId, sub: user.id, jwt: '' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await checkHomeStatus();
|
|
98
|
+
} catch (err: unknown) {
|
|
99
|
+
const message = err instanceof EkkaError ? err.message : 'Connection failed';
|
|
100
|
+
|
|
101
|
+
// Check if setup is incomplete - if so, stay in setup mode
|
|
102
|
+
try {
|
|
103
|
+
const setupStatus = await ekka.setup.status();
|
|
104
|
+
if (!setupStatus.setupComplete) {
|
|
105
|
+
setState((s) => ({ ...s, setupStatus, appState: 'setup', error: message }));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// If we can't check setup status, stay in loading with error
|
|
110
|
+
setState((s) => ({ ...s, error: message }));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only go to login if setup IS complete but connection failed
|
|
115
|
+
setState((s) => ({ ...s, error: message, appState: 'login' }));
|
|
116
|
+
|
|
117
|
+
addAuditEvent({
|
|
118
|
+
type: 'connection.failed',
|
|
119
|
+
description: 'Failed to connect to EKKA backend',
|
|
120
|
+
technical: { error: message },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function checkHomeStatus(): Promise<void> {
|
|
126
|
+
try {
|
|
127
|
+
const status = await advanced.home.status();
|
|
128
|
+
setState((s) => ({ ...s, homeStatus: status }));
|
|
129
|
+
|
|
130
|
+
if (status.state === 'HOME_GRANTED') {
|
|
131
|
+
setState((s) => ({ ...s, appState: 'ready' }));
|
|
132
|
+
} else if (status.state === 'AUTHENTICATED_NO_HOME_GRANT') {
|
|
133
|
+
setState((s) => ({ ...s, appState: 'home-setup' }));
|
|
134
|
+
} else {
|
|
135
|
+
setState((s) => ({ ...s, appState: 'login' }));
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const message = err instanceof Error ? err.message : 'Failed to check home status';
|
|
139
|
+
setState((s) => ({ ...s, error: message }));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function handleSetupComplete(): Promise<void> {
|
|
144
|
+
// After setup wizard completes:
|
|
145
|
+
// Wizard already verified credentials saved - proceed directly to login
|
|
146
|
+
// (Don't re-verify via setup.status - keychain read-after-write has race condition)
|
|
147
|
+
setState((s) => ({ ...s, appState: 'loading' }));
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// Ensure connected (wizard should have called connect, but verify)
|
|
151
|
+
if (!ekka.isConnected()) {
|
|
152
|
+
await ekka.connect();
|
|
153
|
+
console.log('[ekka] op=desktop.connect.success (post-setup)');
|
|
154
|
+
}
|
|
155
|
+
setState((s) => ({ ...s, connected: true }));
|
|
156
|
+
|
|
157
|
+
// Now proceed to login
|
|
158
|
+
if (!ekka.auth.isLoggedIn()) {
|
|
159
|
+
setState((s) => ({ ...s, appState: 'login' }));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Already logged in - check home status
|
|
164
|
+
await checkHomeStatus();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const message = err instanceof Error ? err.message : 'Setup verification failed';
|
|
167
|
+
setState((s) => ({ ...s, error: message, appState: 'setup' }));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function handleLoginSuccess(): Promise<void> {
|
|
172
|
+
// Belt + suspenders: ensure connected before any engine ops
|
|
173
|
+
if (!state.connected) {
|
|
174
|
+
try {
|
|
175
|
+
await ekka.connect();
|
|
176
|
+
setState((s) => ({ ...s, connected: true }));
|
|
177
|
+
console.log('[ekka] op=desktop.connect.success (post-login)');
|
|
178
|
+
} catch (err) {
|
|
179
|
+
const message = err instanceof Error ? err.message : 'Failed to connect';
|
|
180
|
+
setState((s) => ({ ...s, error: message }));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
await checkHomeStatus();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function handleHomeGranted(): void {
|
|
188
|
+
setState((s) => ({ ...s, appState: 'ready' }));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Loading
|
|
192
|
+
if (state.appState === 'loading') {
|
|
193
|
+
const style: CSSProperties = {
|
|
194
|
+
display: 'flex',
|
|
195
|
+
alignItems: 'center',
|
|
196
|
+
justifyContent: 'center',
|
|
197
|
+
minHeight: '100vh',
|
|
198
|
+
background: darkMode ? '#1c1c1e' : '#ffffff',
|
|
199
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif',
|
|
200
|
+
color: darkMode ? '#98989d' : '#86868b',
|
|
201
|
+
fontSize: '14px',
|
|
202
|
+
};
|
|
203
|
+
return <div style={style}>Connecting...</div>;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Setup wizard (pre-login)
|
|
207
|
+
if (state.appState === 'setup' && state.setupStatus) {
|
|
208
|
+
return (
|
|
209
|
+
<SetupWizard
|
|
210
|
+
initialStatus={state.setupStatus}
|
|
211
|
+
onComplete={handleSetupComplete}
|
|
212
|
+
darkMode={darkMode}
|
|
213
|
+
/>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Login
|
|
218
|
+
if (state.appState === 'login') {
|
|
219
|
+
return <LoginPage onLoginSuccess={handleLoginSuccess} darkMode={darkMode} />;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Home setup
|
|
223
|
+
if (state.appState === 'home-setup' && state.homeStatus) {
|
|
224
|
+
return (
|
|
225
|
+
<HomeSetupPage
|
|
226
|
+
homeStatus={state.homeStatus}
|
|
227
|
+
onGranted={handleHomeGranted}
|
|
228
|
+
darkMode={darkMode}
|
|
229
|
+
/>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Main app
|
|
234
|
+
const errorStyle: CSSProperties = {
|
|
235
|
+
marginBottom: '20px',
|
|
236
|
+
padding: '12px 14px',
|
|
237
|
+
background: darkMode ? '#3c1618' : '#fef2f2',
|
|
238
|
+
border: `1px solid ${darkMode ? '#7f1d1d' : '#fecaca'}`,
|
|
239
|
+
borderRadius: '6px',
|
|
240
|
+
fontSize: '13px',
|
|
241
|
+
color: darkMode ? '#fca5a5' : '#991b1b',
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<Shell
|
|
246
|
+
selectedPage={selectedPage}
|
|
247
|
+
onNavigate={setSelectedPage}
|
|
248
|
+
darkMode={darkMode}
|
|
249
|
+
onToggleDarkMode={() => setDarkMode((prev) => !prev)}
|
|
250
|
+
>
|
|
251
|
+
{state.error && <div style={errorStyle}>{state.error}</div>}
|
|
252
|
+
{selectedPage === 'path-permissions' && <PathPermissionsPage darkMode={darkMode} />}
|
|
253
|
+
{selectedPage === 'vault' && <VaultPage darkMode={darkMode} />}
|
|
254
|
+
{selectedPage === 'doc-gen' && <DocGenPage darkMode={darkMode} />}
|
|
255
|
+
{selectedPage === 'runner' && <RunnerPage darkMode={darkMode} />}
|
|
256
|
+
{selectedPage === 'audit-log' && <AuditLogPage darkMode={darkMode} />}
|
|
257
|
+
{selectedPage === 'system' && <SystemPage darkMode={darkMode} />}
|
|
258
|
+
</Shell>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Banner Component
|
|
3
|
+
* Informational banner for warnings, info, and errors.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type CSSProperties, type ReactElement } from 'react';
|
|
7
|
+
|
|
8
|
+
type BannerType = 'warning' | 'info' | 'error';
|
|
9
|
+
|
|
10
|
+
interface BannerProps {
|
|
11
|
+
type: BannerType;
|
|
12
|
+
message: string;
|
|
13
|
+
darkMode?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Banner({ type, message, darkMode = false }: BannerProps): ReactElement {
|
|
17
|
+
const colorMap: Record<BannerType, { bg: string; border: string; text: string; icon: string }> = {
|
|
18
|
+
warning: {
|
|
19
|
+
bg: darkMode ? '#422006' : '#fffbeb',
|
|
20
|
+
border: darkMode ? '#854d0e' : '#fcd34d',
|
|
21
|
+
text: darkMode ? '#fcd34d' : '#92400e',
|
|
22
|
+
icon: darkMode ? '#fbbf24' : '#f59e0b',
|
|
23
|
+
},
|
|
24
|
+
info: {
|
|
25
|
+
bg: darkMode ? '#1e3a5f' : '#eff6ff',
|
|
26
|
+
border: darkMode ? '#1d4ed8' : '#93c5fd',
|
|
27
|
+
text: darkMode ? '#93c5fd' : '#1e40af',
|
|
28
|
+
icon: darkMode ? '#60a5fa' : '#3b82f6',
|
|
29
|
+
},
|
|
30
|
+
error: {
|
|
31
|
+
bg: darkMode ? '#3c1618' : '#fef2f2',
|
|
32
|
+
border: darkMode ? '#7f1d1d' : '#fecaca',
|
|
33
|
+
text: darkMode ? '#fca5a5' : '#991b1b',
|
|
34
|
+
icon: darkMode ? '#f87171' : '#ef4444',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const colors = colorMap[type];
|
|
39
|
+
|
|
40
|
+
const styles: Record<string, CSSProperties> = {
|
|
41
|
+
banner: {
|
|
42
|
+
display: 'flex',
|
|
43
|
+
alignItems: 'flex-start',
|
|
44
|
+
gap: '12px',
|
|
45
|
+
padding: '12px 14px',
|
|
46
|
+
background: colors.bg,
|
|
47
|
+
border: `1px solid ${colors.border}`,
|
|
48
|
+
borderRadius: '6px',
|
|
49
|
+
fontSize: '13px',
|
|
50
|
+
lineHeight: 1.5,
|
|
51
|
+
color: colors.text,
|
|
52
|
+
},
|
|
53
|
+
icon: {
|
|
54
|
+
width: '16px',
|
|
55
|
+
height: '16px',
|
|
56
|
+
flexShrink: 0,
|
|
57
|
+
marginTop: '1px',
|
|
58
|
+
color: colors.icon,
|
|
59
|
+
},
|
|
60
|
+
message: {
|
|
61
|
+
flex: 1,
|
|
62
|
+
margin: 0,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const iconPaths: Record<BannerType, string> = {
|
|
67
|
+
warning:
|
|
68
|
+
'M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z',
|
|
69
|
+
info: 'M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z',
|
|
70
|
+
error:
|
|
71
|
+
'M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div style={styles.banner}>
|
|
76
|
+
<svg style={styles.icon} viewBox="0 0 16 16" fill="currentColor">
|
|
77
|
+
<path d={iconPaths[type]} />
|
|
78
|
+
</svg>
|
|
79
|
+
<p style={styles.message}>{message}</p>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Empty State Component
|
|
3
|
+
* Placeholder UI for empty lists or sections.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type CSSProperties, type ReactElement, type ReactNode } from 'react';
|
|
7
|
+
|
|
8
|
+
interface EmptyStateProps {
|
|
9
|
+
icon: ReactNode;
|
|
10
|
+
message: string;
|
|
11
|
+
hint?: string;
|
|
12
|
+
darkMode?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function EmptyState({
|
|
16
|
+
icon,
|
|
17
|
+
message,
|
|
18
|
+
hint,
|
|
19
|
+
darkMode = false,
|
|
20
|
+
}: EmptyStateProps): ReactElement {
|
|
21
|
+
const colors = {
|
|
22
|
+
text: darkMode ? '#ffffff' : '#1d1d1f',
|
|
23
|
+
textMuted: darkMode ? '#98989d' : '#6e6e73',
|
|
24
|
+
iconColor: darkMode ? '#48484a' : '#d2d2d7',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const styles: Record<string, CSSProperties> = {
|
|
28
|
+
container: {
|
|
29
|
+
display: 'flex',
|
|
30
|
+
flexDirection: 'column',
|
|
31
|
+
alignItems: 'center',
|
|
32
|
+
justifyContent: 'center',
|
|
33
|
+
padding: '48px 24px',
|
|
34
|
+
textAlign: 'center',
|
|
35
|
+
},
|
|
36
|
+
iconWrapper: {
|
|
37
|
+
marginBottom: '16px',
|
|
38
|
+
color: colors.iconColor,
|
|
39
|
+
},
|
|
40
|
+
message: {
|
|
41
|
+
fontSize: '14px',
|
|
42
|
+
fontWeight: 500,
|
|
43
|
+
color: colors.text,
|
|
44
|
+
marginBottom: hint ? '8px' : '0',
|
|
45
|
+
},
|
|
46
|
+
hint: {
|
|
47
|
+
fontSize: '13px',
|
|
48
|
+
color: colors.textMuted,
|
|
49
|
+
maxWidth: '280px',
|
|
50
|
+
lineHeight: 1.5,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div style={styles.container}>
|
|
56
|
+
<div style={styles.iconWrapper}>{icon}</div>
|
|
57
|
+
<p style={styles.message}>{message}</p>
|
|
58
|
+
{hint && <p style={styles.hint}>{hint}</p>}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Info Popover Component
|
|
3
|
+
* Shows an info icon that displays detailed info on click.
|
|
4
|
+
* Uses position: fixed to avoid being clipped by parent overflow.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useRef, useEffect, type CSSProperties, type ReactElement, type ReactNode } from 'react';
|
|
8
|
+
|
|
9
|
+
interface InfoItem {
|
|
10
|
+
label: string;
|
|
11
|
+
value: string | ReactNode;
|
|
12
|
+
mono?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface InfoPopoverProps {
|
|
16
|
+
items: InfoItem[];
|
|
17
|
+
darkMode?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function InfoPopover({ items, darkMode = false }: InfoPopoverProps): ReactElement {
|
|
21
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
22
|
+
const [position, setPosition] = useState({ top: 0, right: 0 });
|
|
23
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
24
|
+
|
|
25
|
+
const colors = {
|
|
26
|
+
icon: darkMode ? '#98989d' : '#86868b',
|
|
27
|
+
iconHover: darkMode ? '#0a84ff' : '#007aff',
|
|
28
|
+
bg: darkMode ? '#2c2c2e' : '#ffffff',
|
|
29
|
+
border: darkMode ? '#3a3a3c' : '#e5e5e5',
|
|
30
|
+
text: darkMode ? '#ffffff' : '#1d1d1f',
|
|
31
|
+
textMuted: darkMode ? '#98989d' : '#86868b',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const styles: Record<string, CSSProperties> = {
|
|
35
|
+
container: {
|
|
36
|
+
position: 'relative',
|
|
37
|
+
display: 'inline-flex',
|
|
38
|
+
alignItems: 'center',
|
|
39
|
+
},
|
|
40
|
+
button: {
|
|
41
|
+
display: 'flex',
|
|
42
|
+
alignItems: 'center',
|
|
43
|
+
justifyContent: 'center',
|
|
44
|
+
width: '24px',
|
|
45
|
+
height: '24px',
|
|
46
|
+
padding: 0,
|
|
47
|
+
background: 'transparent',
|
|
48
|
+
border: 'none',
|
|
49
|
+
borderRadius: '4px',
|
|
50
|
+
cursor: 'pointer',
|
|
51
|
+
color: isOpen ? colors.iconHover : colors.icon,
|
|
52
|
+
transition: 'color 0.15s ease',
|
|
53
|
+
},
|
|
54
|
+
popover: {
|
|
55
|
+
position: 'fixed',
|
|
56
|
+
top: position.top,
|
|
57
|
+
right: position.right,
|
|
58
|
+
padding: '12px',
|
|
59
|
+
background: colors.bg,
|
|
60
|
+
border: `1px solid ${colors.border}`,
|
|
61
|
+
borderRadius: '8px',
|
|
62
|
+
boxShadow: darkMode
|
|
63
|
+
? '0 4px 16px rgba(0, 0, 0, 0.4)'
|
|
64
|
+
: '0 4px 16px rgba(0, 0, 0, 0.12)',
|
|
65
|
+
zIndex: 9999,
|
|
66
|
+
minWidth: '260px',
|
|
67
|
+
maxWidth: '360px',
|
|
68
|
+
maxHeight: '400px',
|
|
69
|
+
overflowY: 'auto',
|
|
70
|
+
},
|
|
71
|
+
row: {
|
|
72
|
+
display: 'flex',
|
|
73
|
+
flexDirection: 'column',
|
|
74
|
+
gap: '2px',
|
|
75
|
+
padding: '6px 0',
|
|
76
|
+
borderBottom: `1px solid ${colors.border}`,
|
|
77
|
+
},
|
|
78
|
+
rowLast: {
|
|
79
|
+
borderBottom: 'none',
|
|
80
|
+
},
|
|
81
|
+
label: {
|
|
82
|
+
fontSize: '10px',
|
|
83
|
+
fontWeight: 600,
|
|
84
|
+
color: colors.textMuted,
|
|
85
|
+
textTransform: 'uppercase',
|
|
86
|
+
letterSpacing: '0.04em',
|
|
87
|
+
},
|
|
88
|
+
value: {
|
|
89
|
+
fontSize: '12px',
|
|
90
|
+
color: colors.text,
|
|
91
|
+
wordBreak: 'break-all',
|
|
92
|
+
},
|
|
93
|
+
valueMono: {
|
|
94
|
+
fontFamily: 'SF Mono, Monaco, Consolas, monospace',
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Update position when opening
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (isOpen && buttonRef.current) {
|
|
101
|
+
const rect = buttonRef.current.getBoundingClientRect();
|
|
102
|
+
setPosition({
|
|
103
|
+
top: rect.bottom + 8,
|
|
104
|
+
right: window.innerWidth - rect.right,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}, [isOpen]);
|
|
108
|
+
|
|
109
|
+
// Close on click outside or scroll
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!isOpen) return;
|
|
112
|
+
|
|
113
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
114
|
+
if (buttonRef.current && !buttonRef.current.contains(e.target as Node)) {
|
|
115
|
+
setIsOpen(false);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleScroll = () => setIsOpen(false);
|
|
120
|
+
|
|
121
|
+
document.addEventListener('click', handleClickOutside);
|
|
122
|
+
document.addEventListener('scroll', handleScroll, true);
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
document.removeEventListener('click', handleClickOutside);
|
|
126
|
+
document.removeEventListener('scroll', handleScroll, true);
|
|
127
|
+
};
|
|
128
|
+
}, [isOpen]);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div style={styles.container}>
|
|
132
|
+
<button
|
|
133
|
+
ref={buttonRef}
|
|
134
|
+
style={styles.button}
|
|
135
|
+
onClick={(e) => {
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
setIsOpen(!isOpen);
|
|
138
|
+
}}
|
|
139
|
+
title="View details"
|
|
140
|
+
>
|
|
141
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
142
|
+
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
|
|
143
|
+
<path
|
|
144
|
+
d="M8 7v4M8 5.5v.01"
|
|
145
|
+
stroke="currentColor"
|
|
146
|
+
strokeWidth="1.5"
|
|
147
|
+
strokeLinecap="round"
|
|
148
|
+
/>
|
|
149
|
+
</svg>
|
|
150
|
+
</button>
|
|
151
|
+
{isOpen && (
|
|
152
|
+
<div style={styles.popover} onClick={(e) => e.stopPropagation()}>
|
|
153
|
+
{items.map((item, idx) => (
|
|
154
|
+
<div
|
|
155
|
+
key={item.label}
|
|
156
|
+
style={{
|
|
157
|
+
...styles.row,
|
|
158
|
+
...(idx === items.length - 1 ? styles.rowLast : {}),
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<span style={styles.label}>{item.label}</span>
|
|
162
|
+
<span style={{ ...styles.value, ...(item.mono ? styles.valueMono : {}) }}>
|
|
163
|
+
{item.value}
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Info Tooltip Component
|
|
3
|
+
* Shows an info icon that displays tooltip text on hover.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, type CSSProperties, type ReactElement } from 'react';
|
|
7
|
+
|
|
8
|
+
interface InfoTooltipProps {
|
|
9
|
+
text: string;
|
|
10
|
+
darkMode?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function InfoTooltip({ text, darkMode = false }: InfoTooltipProps): ReactElement {
|
|
14
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
15
|
+
|
|
16
|
+
const colors = {
|
|
17
|
+
icon: darkMode ? '#98989d' : '#86868b',
|
|
18
|
+
iconHover: darkMode ? '#ffffff' : '#1d1d1f',
|
|
19
|
+
tooltipBg: darkMode ? '#3a3a3c' : '#1d1d1f',
|
|
20
|
+
tooltipText: '#ffffff',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const styles: Record<string, CSSProperties> = {
|
|
24
|
+
container: {
|
|
25
|
+
position: 'relative',
|
|
26
|
+
display: 'inline-flex',
|
|
27
|
+
alignItems: 'center',
|
|
28
|
+
},
|
|
29
|
+
icon: {
|
|
30
|
+
width: '14px',
|
|
31
|
+
height: '14px',
|
|
32
|
+
cursor: 'help',
|
|
33
|
+
color: isVisible ? colors.iconHover : colors.icon,
|
|
34
|
+
transition: 'color 0.15s ease',
|
|
35
|
+
},
|
|
36
|
+
tooltip: {
|
|
37
|
+
position: 'absolute',
|
|
38
|
+
bottom: '100%',
|
|
39
|
+
left: '50%',
|
|
40
|
+
transform: 'translateX(-50%)',
|
|
41
|
+
marginBottom: '8px',
|
|
42
|
+
padding: '8px 12px',
|
|
43
|
+
background: colors.tooltipBg,
|
|
44
|
+
color: colors.tooltipText,
|
|
45
|
+
fontSize: '12px',
|
|
46
|
+
lineHeight: 1.4,
|
|
47
|
+
borderRadius: '6px',
|
|
48
|
+
whiteSpace: 'nowrap',
|
|
49
|
+
maxWidth: '280px',
|
|
50
|
+
zIndex: 1000,
|
|
51
|
+
opacity: isVisible ? 1 : 0,
|
|
52
|
+
visibility: isVisible ? 'visible' : 'hidden',
|
|
53
|
+
transition: 'opacity 0.15s ease, visibility 0.15s ease',
|
|
54
|
+
pointerEvents: 'none',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<span
|
|
60
|
+
style={styles.container}
|
|
61
|
+
onMouseEnter={() => setIsVisible(true)}
|
|
62
|
+
onMouseLeave={() => setIsVisible(false)}
|
|
63
|
+
>
|
|
64
|
+
<svg style={styles.icon} viewBox="0 0 16 16" fill="none">
|
|
65
|
+
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
|
|
66
|
+
<path
|
|
67
|
+
d="M8 7v4M8 5.5v.01"
|
|
68
|
+
stroke="currentColor"
|
|
69
|
+
strokeWidth="1.5"
|
|
70
|
+
strokeLinecap="round"
|
|
71
|
+
/>
|
|
72
|
+
</svg>
|
|
73
|
+
<span style={styles.tooltip}>{text}</span>
|
|
74
|
+
</span>
|
|
75
|
+
);
|
|
76
|
+
}
|