@myvillage/cli 1.3.0 → 1.5.1
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 +14 -199
- package/package.json +11 -6
- package/src/agent-runtime/context.js +99 -0
- package/src/agent-runtime/daemon-entry.js +66 -0
- package/src/agent-runtime/daemon.js +65 -0
- package/src/agent-runtime/loop.js +281 -0
- package/src/agent-runtime/mcp-client.js +93 -0
- package/src/agent-runtime/scheduler.js +53 -0
- package/src/commands/agent-local.js +624 -0
- package/src/commands/agent.js +274 -42
- package/src/commands/bizreqs.js +965 -0
- package/src/commands/comment.js +5 -4
- package/src/commands/community.js +13 -12
- package/src/commands/create-app.js +253 -0
- package/src/commands/create-game.js +9 -8
- package/src/commands/deploy.js +101 -23
- package/src/commands/feed.js +4 -3
- package/src/commands/login.js +164 -76
- package/src/commands/logout.js +45 -7
- package/src/commands/post.js +14 -13
- package/src/commands/profile.js +4 -3
- package/src/commands/search.js +3 -2
- package/src/commands/soulprint.js +1379 -0
- package/src/commands/status.js +64 -28
- package/src/commands/vote.js +46 -18
- package/src/index.js +244 -1
- package/src/utils/agent-scaffolder.js +165 -0
- package/src/utils/api.js +135 -14
- package/src/utils/app-templates.js +2983 -0
- package/src/utils/brand.js +107 -0
- package/src/utils/config.js +17 -1
- package/src/utils/formatters.js +351 -18
- package/src/utils/local-agent.js +168 -0
- package/src/utils/soulprint-api.js +136 -0
- package/src/utils/soulprint-workspace.js +158 -0
|
@@ -0,0 +1,2983 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
// MyVillage brand colors
|
|
5
|
+
const BRAND = {
|
|
6
|
+
gold: '#FFD700',
|
|
7
|
+
brown: '#8B4513',
|
|
8
|
+
green: '#228B22',
|
|
9
|
+
primary: '#B07C00',
|
|
10
|
+
secondary: '#E4DCCB',
|
|
11
|
+
darkBrown: '#302017',
|
|
12
|
+
deepGreen: '#043922',
|
|
13
|
+
teal: '#799C9F',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// OAuth constants
|
|
17
|
+
const OAUTH_BASE_URL = 'https://portal.myvillageproject.ai/api/oauth';
|
|
18
|
+
const OAUTH_REDIRECT_URI = 'http://localhost:5173/callback';
|
|
19
|
+
const OAUTH_SCOPES = 'openid profile email villager';
|
|
20
|
+
|
|
21
|
+
// ─── Portal App Template ────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export function createPortalProject(targetDir, options) {
|
|
24
|
+
const { name, description, features, oauthCredentials } = options;
|
|
25
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
26
|
+
const hasAuth = features.includes('auth');
|
|
27
|
+
const hasDashboard = features.includes('dashboard');
|
|
28
|
+
const hasSettings = features.includes('settings');
|
|
29
|
+
const hasUsers = features.includes('users');
|
|
30
|
+
const hasNotifications = features.includes('notifications');
|
|
31
|
+
|
|
32
|
+
// Create directory structure
|
|
33
|
+
const dirs = [
|
|
34
|
+
'',
|
|
35
|
+
'public',
|
|
36
|
+
'src',
|
|
37
|
+
'src/components',
|
|
38
|
+
'src/pages',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
if (hasAuth) {
|
|
42
|
+
dirs.push('src/auth');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
mkdirSync(join(targetDir, dir), { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Write root files
|
|
50
|
+
writeFileSync(join(targetDir, 'package.json'), generatePortalPackageJson(slug, description, features, oauthCredentials));
|
|
51
|
+
writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
|
|
52
|
+
writeFileSync(join(targetDir, 'index.html'), generatePortalIndexHtml(name));
|
|
53
|
+
writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
|
|
54
|
+
writeFileSync(join(targetDir, 'README.md'), generatePortalReadme(name, description, features));
|
|
55
|
+
|
|
56
|
+
// Env files
|
|
57
|
+
if (hasAuth) {
|
|
58
|
+
writeFileSync(join(targetDir, '.env'), generateEnv(oauthCredentials));
|
|
59
|
+
writeFileSync(join(targetDir, '.env.example'), generateEnvExample());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Source files
|
|
63
|
+
writeFileSync(join(targetDir, 'src/main.jsx'), generateMainJsx());
|
|
64
|
+
writeFileSync(join(targetDir, 'src/App.jsx'), generatePortalApp(features));
|
|
65
|
+
writeFileSync(join(targetDir, 'src/App.css'), generatePortalAppCss());
|
|
66
|
+
writeFileSync(join(targetDir, 'src/index.css'), generateIndexCss());
|
|
67
|
+
|
|
68
|
+
// Components
|
|
69
|
+
writeFileSync(join(targetDir, 'src/components/Layout.jsx'), generatePortalLayout());
|
|
70
|
+
writeFileSync(join(targetDir, 'src/components/Sidebar.jsx'), generatePortalSidebar(features));
|
|
71
|
+
writeFileSync(join(targetDir, 'src/components/Header.jsx'), generatePortalHeader(hasAuth));
|
|
72
|
+
|
|
73
|
+
if (hasAuth) {
|
|
74
|
+
writeFileSync(join(targetDir, 'src/components/ProtectedRoute.jsx'), generateProtectedRoute());
|
|
75
|
+
writeFileSync(join(targetDir, 'src/auth/oauth.js'), generateOAuthModule());
|
|
76
|
+
writeFileSync(join(targetDir, 'src/auth/AuthProvider.jsx'), generateAuthProvider());
|
|
77
|
+
writeFileSync(join(targetDir, 'src/pages/Login.jsx'), generateLoginPage());
|
|
78
|
+
writeFileSync(join(targetDir, 'src/pages/Callback.jsx'), generateCallbackPage());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Pages
|
|
82
|
+
if (hasDashboard) {
|
|
83
|
+
writeFileSync(join(targetDir, 'src/pages/Dashboard.jsx'), generateDashboardPage());
|
|
84
|
+
}
|
|
85
|
+
if (hasSettings) {
|
|
86
|
+
writeFileSync(join(targetDir, 'src/pages/Settings.jsx'), generateSettingsPage());
|
|
87
|
+
}
|
|
88
|
+
if (hasUsers) {
|
|
89
|
+
writeFileSync(join(targetDir, 'src/pages/Users.jsx'), generateUsersPage());
|
|
90
|
+
}
|
|
91
|
+
if (hasNotifications) {
|
|
92
|
+
writeFileSync(join(targetDir, 'src/pages/Notifications.jsx'), generateNotificationsPage());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
writeFileSync(join(targetDir, 'src/pages/NotFound.jsx'), generateNotFoundPage());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Data Labeling Template ─────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export function createDataLabelingProject(targetDir, options) {
|
|
101
|
+
const { name, description, dataTypes, includeAuth, oauthCredentials } = options;
|
|
102
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
103
|
+
|
|
104
|
+
// Create directory structure
|
|
105
|
+
const dirs = [
|
|
106
|
+
'',
|
|
107
|
+
'public',
|
|
108
|
+
'src',
|
|
109
|
+
'src/components',
|
|
110
|
+
'src/labelers',
|
|
111
|
+
'src/context',
|
|
112
|
+
'src/pages',
|
|
113
|
+
'src/utils',
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
if (includeAuth) {
|
|
117
|
+
dirs.push('src/auth');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const dir of dirs) {
|
|
121
|
+
mkdirSync(join(targetDir, dir), { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Write root files
|
|
125
|
+
writeFileSync(join(targetDir, 'package.json'), generateLabelingPackageJson(slug, description, dataTypes, oauthCredentials));
|
|
126
|
+
writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
|
|
127
|
+
writeFileSync(join(targetDir, 'index.html'), generateLabelingIndexHtml(name));
|
|
128
|
+
writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
|
|
129
|
+
writeFileSync(join(targetDir, 'README.md'), generateLabelingReadme(name, description, dataTypes));
|
|
130
|
+
|
|
131
|
+
// Env files
|
|
132
|
+
if (includeAuth) {
|
|
133
|
+
writeFileSync(join(targetDir, '.env'), generateEnv(oauthCredentials));
|
|
134
|
+
writeFileSync(join(targetDir, '.env.example'), generateEnvExample());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Source files
|
|
138
|
+
writeFileSync(join(targetDir, 'src/main.jsx'), generateMainJsx());
|
|
139
|
+
writeFileSync(join(targetDir, 'src/App.jsx'), generateLabelingApp(dataTypes, includeAuth));
|
|
140
|
+
writeFileSync(join(targetDir, 'src/App.css'), generateLabelingAppCss());
|
|
141
|
+
writeFileSync(join(targetDir, 'src/index.css'), generateIndexCss());
|
|
142
|
+
|
|
143
|
+
// Components
|
|
144
|
+
writeFileSync(join(targetDir, 'src/components/Layout.jsx'), generateLabelingLayout());
|
|
145
|
+
writeFileSync(join(targetDir, 'src/components/Toolbar.jsx'), generateToolbar());
|
|
146
|
+
writeFileSync(join(targetDir, 'src/components/DataTypeSelector.jsx'), generateDataTypeSelector(dataTypes));
|
|
147
|
+
writeFileSync(join(targetDir, 'src/components/ProgressBar.jsx'), generateProgressBar());
|
|
148
|
+
writeFileSync(join(targetDir, 'src/components/KeyboardShortcuts.jsx'), generateKeyboardShortcuts());
|
|
149
|
+
|
|
150
|
+
if (includeAuth) {
|
|
151
|
+
writeFileSync(join(targetDir, 'src/components/ProtectedRoute.jsx'), generateProtectedRoute());
|
|
152
|
+
writeFileSync(join(targetDir, 'src/auth/oauth.js'), generateOAuthModule());
|
|
153
|
+
writeFileSync(join(targetDir, 'src/auth/AuthProvider.jsx'), generateAuthProvider());
|
|
154
|
+
writeFileSync(join(targetDir, 'src/pages/Login.jsx'), generateLoginPage());
|
|
155
|
+
writeFileSync(join(targetDir, 'src/pages/Callback.jsx'), generateCallbackPage());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Context
|
|
159
|
+
writeFileSync(join(targetDir, 'src/context/ProjectContext.jsx'), generateProjectContext());
|
|
160
|
+
|
|
161
|
+
// Labelers (conditional per data type)
|
|
162
|
+
if (dataTypes.includes('image')) {
|
|
163
|
+
writeFileSync(join(targetDir, 'src/labelers/ImageLabeler.jsx'), generateImageLabeler());
|
|
164
|
+
}
|
|
165
|
+
if (dataTypes.includes('text')) {
|
|
166
|
+
writeFileSync(join(targetDir, 'src/labelers/TextLabeler.jsx'), generateTextLabeler());
|
|
167
|
+
}
|
|
168
|
+
if (dataTypes.includes('audio')) {
|
|
169
|
+
writeFileSync(join(targetDir, 'src/labelers/AudioLabeler.jsx'), generateAudioLabeler());
|
|
170
|
+
}
|
|
171
|
+
if (dataTypes.includes('video')) {
|
|
172
|
+
writeFileSync(join(targetDir, 'src/labelers/VideoLabeler.jsx'), generateVideoLabeler());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Pages
|
|
176
|
+
writeFileSync(join(targetDir, 'src/pages/Workspace.jsx'), generateWorkspacePage(dataTypes));
|
|
177
|
+
writeFileSync(join(targetDir, 'src/pages/DatasetManager.jsx'), generateDatasetManagerPage());
|
|
178
|
+
writeFileSync(join(targetDir, 'src/pages/LabelSchema.jsx'), generateLabelSchemaPage());
|
|
179
|
+
writeFileSync(join(targetDir, 'src/pages/Export.jsx'), generateExportPage());
|
|
180
|
+
|
|
181
|
+
// Utils
|
|
182
|
+
writeFileSync(join(targetDir, 'src/utils/shortcuts.js'), generateShortcuts(dataTypes));
|
|
183
|
+
writeFileSync(join(targetDir, 'src/utils/labelFormats.js'), generateLabelFormats());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Shared Helpers ─────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function generateViteConfig() {
|
|
189
|
+
return `import { defineConfig } from 'vite';
|
|
190
|
+
import react from '@vitejs/plugin-react';
|
|
191
|
+
|
|
192
|
+
export default defineConfig({
|
|
193
|
+
plugins: [react()],
|
|
194
|
+
server: { port: 5173, open: true },
|
|
195
|
+
build: { outDir: 'dist' },
|
|
196
|
+
});
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function generateGitignore() {
|
|
201
|
+
return `node_modules/
|
|
202
|
+
dist/
|
|
203
|
+
.DS_Store
|
|
204
|
+
*.log
|
|
205
|
+
.env
|
|
206
|
+
`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function generateEnv(oauthCredentials) {
|
|
210
|
+
const clientId = oauthCredentials?.clientId || '';
|
|
211
|
+
const clientSecret = oauthCredentials?.clientSecret || '';
|
|
212
|
+
return `VITE_OAUTH_CLIENT_ID=${clientId}
|
|
213
|
+
VITE_OAUTH_CLIENT_SECRET=${clientSecret}
|
|
214
|
+
VITE_OAUTH_BASE_URL=${OAUTH_BASE_URL}
|
|
215
|
+
VITE_OAUTH_REDIRECT_URI=${OAUTH_REDIRECT_URI}
|
|
216
|
+
`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function generateEnvExample() {
|
|
220
|
+
return `VITE_OAUTH_CLIENT_ID=
|
|
221
|
+
VITE_OAUTH_CLIENT_SECRET=
|
|
222
|
+
VITE_OAUTH_BASE_URL=${OAUTH_BASE_URL}
|
|
223
|
+
VITE_OAUTH_REDIRECT_URI=${OAUTH_REDIRECT_URI}
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function generateMainJsx() {
|
|
228
|
+
return `import React from 'react';
|
|
229
|
+
import ReactDOM from 'react-dom/client';
|
|
230
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
231
|
+
import App from './App.jsx';
|
|
232
|
+
import './index.css';
|
|
233
|
+
|
|
234
|
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
235
|
+
<React.StrictMode>
|
|
236
|
+
<BrowserRouter>
|
|
237
|
+
<App />
|
|
238
|
+
</BrowserRouter>
|
|
239
|
+
</React.StrictMode>
|
|
240
|
+
);
|
|
241
|
+
`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function generateIndexCss() {
|
|
245
|
+
return `/* Reset and base styles */
|
|
246
|
+
*,
|
|
247
|
+
*::before,
|
|
248
|
+
*::after {
|
|
249
|
+
box-sizing: border-box;
|
|
250
|
+
margin: 0;
|
|
251
|
+
padding: 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
html, body {
|
|
255
|
+
height: 100%;
|
|
256
|
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
257
|
+
background: ${BRAND.secondary};
|
|
258
|
+
color: ${BRAND.darkBrown};
|
|
259
|
+
line-height: 1.6;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#root {
|
|
263
|
+
height: 100%;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
a {
|
|
267
|
+
color: ${BRAND.primary};
|
|
268
|
+
text-decoration: none;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
a:hover {
|
|
272
|
+
text-decoration: underline;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
button {
|
|
276
|
+
cursor: pointer;
|
|
277
|
+
font-family: inherit;
|
|
278
|
+
}
|
|
279
|
+
`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ─── Shared OAuth Modules ───────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
function generateOAuthModule() {
|
|
285
|
+
return `// Browser-side OAuth 2.0 + PKCE implementation
|
|
286
|
+
|
|
287
|
+
function base64UrlEncode(buffer) {
|
|
288
|
+
const bytes = new Uint8Array(buffer);
|
|
289
|
+
let str = '';
|
|
290
|
+
for (const b of bytes) str += String.fromCharCode(b);
|
|
291
|
+
return btoa(str).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function generateCodeVerifier() {
|
|
295
|
+
const array = new Uint8Array(64);
|
|
296
|
+
crypto.getRandomValues(array);
|
|
297
|
+
return base64UrlEncode(array);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function generateCodeChallenge(verifier) {
|
|
301
|
+
const encoder = new TextEncoder();
|
|
302
|
+
const data = encoder.encode(verifier);
|
|
303
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
304
|
+
return base64UrlEncode(digest);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function startLogin() {
|
|
308
|
+
const verifier = generateCodeVerifier();
|
|
309
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
310
|
+
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
311
|
+
|
|
312
|
+
sessionStorage.setItem('oauth_verifier', verifier);
|
|
313
|
+
sessionStorage.setItem('oauth_state', state);
|
|
314
|
+
|
|
315
|
+
const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;
|
|
316
|
+
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
|
|
317
|
+
const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI;
|
|
318
|
+
|
|
319
|
+
const params = new URLSearchParams({
|
|
320
|
+
client_id: clientId,
|
|
321
|
+
redirect_uri: redirectUri,
|
|
322
|
+
response_type: 'code',
|
|
323
|
+
scope: '${OAUTH_SCOPES}',
|
|
324
|
+
state,
|
|
325
|
+
code_challenge: challenge,
|
|
326
|
+
code_challenge_method: 'S256',
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
window.location.href = \`\${baseUrl}/authorize?\${params.toString()}\`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function handleCallback(code, state) {
|
|
333
|
+
const savedState = sessionStorage.getItem('oauth_state');
|
|
334
|
+
const verifier = sessionStorage.getItem('oauth_verifier');
|
|
335
|
+
|
|
336
|
+
if (state !== savedState) {
|
|
337
|
+
throw new Error('OAuth state mismatch');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;
|
|
341
|
+
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
|
|
342
|
+
const clientSecret = import.meta.env.VITE_OAUTH_CLIENT_SECRET;
|
|
343
|
+
const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI;
|
|
344
|
+
|
|
345
|
+
const body = new URLSearchParams({
|
|
346
|
+
grant_type: 'authorization_code',
|
|
347
|
+
code,
|
|
348
|
+
redirect_uri: redirectUri,
|
|
349
|
+
client_id: clientId,
|
|
350
|
+
client_secret: clientSecret,
|
|
351
|
+
code_verifier: verifier,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const response = await fetch(\`\${baseUrl}/token\`, {
|
|
355
|
+
method: 'POST',
|
|
356
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
357
|
+
body: body.toString(),
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
if (!response.ok) {
|
|
361
|
+
throw new Error('Token exchange failed');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const tokens = await response.json();
|
|
365
|
+
storeTokens(tokens);
|
|
366
|
+
|
|
367
|
+
sessionStorage.removeItem('oauth_verifier');
|
|
368
|
+
sessionStorage.removeItem('oauth_state');
|
|
369
|
+
|
|
370
|
+
return tokens;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function refreshToken(token) {
|
|
374
|
+
const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;
|
|
375
|
+
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
|
|
376
|
+
const clientSecret = import.meta.env.VITE_OAUTH_CLIENT_SECRET;
|
|
377
|
+
|
|
378
|
+
const body = new URLSearchParams({
|
|
379
|
+
grant_type: 'refresh_token',
|
|
380
|
+
refresh_token: token,
|
|
381
|
+
client_id: clientId,
|
|
382
|
+
client_secret: clientSecret,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const response = await fetch(\`\${baseUrl}/token\`, {
|
|
386
|
+
method: 'POST',
|
|
387
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
388
|
+
body: body.toString(),
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
if (!response.ok) {
|
|
392
|
+
throw new Error('Token refresh failed');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const tokens = await response.json();
|
|
396
|
+
storeTokens(tokens);
|
|
397
|
+
return tokens;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function getStoredTokens() {
|
|
401
|
+
const raw = localStorage.getItem('myvillage_tokens');
|
|
402
|
+
return raw ? JSON.parse(raw) : null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function storeTokens(tokens) {
|
|
406
|
+
localStorage.setItem('myvillage_tokens', JSON.stringify(tokens));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function clearTokens() {
|
|
410
|
+
localStorage.removeItem('myvillage_tokens');
|
|
411
|
+
}
|
|
412
|
+
`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function generateAuthProvider() {
|
|
416
|
+
return `import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
417
|
+
import { useNavigate } from 'react-router-dom';
|
|
418
|
+
import { startLogin, getStoredTokens, clearTokens, refreshToken } from './oauth.js';
|
|
419
|
+
|
|
420
|
+
const AuthContext = createContext(null);
|
|
421
|
+
|
|
422
|
+
export function useAuth() {
|
|
423
|
+
const ctx = useContext(AuthContext);
|
|
424
|
+
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
|
425
|
+
return ctx;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function AuthProvider({ children }) {
|
|
429
|
+
const [user, setUser] = useState(null);
|
|
430
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
431
|
+
const navigate = useNavigate();
|
|
432
|
+
|
|
433
|
+
const fetchUser = useCallback(async (accessToken) => {
|
|
434
|
+
const baseUrl = import.meta.env.VITE_OAUTH_BASE_URL;
|
|
435
|
+
const res = await fetch(\`\${baseUrl}/userinfo\`, {
|
|
436
|
+
headers: { Authorization: \`Bearer \${accessToken}\` },
|
|
437
|
+
});
|
|
438
|
+
if (res.ok) {
|
|
439
|
+
const data = await res.json();
|
|
440
|
+
setUser(data);
|
|
441
|
+
} else {
|
|
442
|
+
clearTokens();
|
|
443
|
+
setUser(null);
|
|
444
|
+
}
|
|
445
|
+
}, []);
|
|
446
|
+
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
const tokens = getStoredTokens();
|
|
449
|
+
if (tokens?.access_token) {
|
|
450
|
+
fetchUser(tokens.access_token).finally(() => setIsLoading(false));
|
|
451
|
+
} else {
|
|
452
|
+
setIsLoading(false);
|
|
453
|
+
}
|
|
454
|
+
}, [fetchUser]);
|
|
455
|
+
|
|
456
|
+
// Auto-refresh before expiry
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
const tokens = getStoredTokens();
|
|
459
|
+
if (!tokens?.expires_in || !tokens?.refresh_token) return;
|
|
460
|
+
|
|
461
|
+
const refreshMs = (tokens.expires_in - 60) * 1000;
|
|
462
|
+
if (refreshMs <= 0) return;
|
|
463
|
+
|
|
464
|
+
const timer = setTimeout(async () => {
|
|
465
|
+
try {
|
|
466
|
+
const newTokens = await refreshToken(tokens.refresh_token);
|
|
467
|
+
await fetchUser(newTokens.access_token);
|
|
468
|
+
} catch {
|
|
469
|
+
clearTokens();
|
|
470
|
+
setUser(null);
|
|
471
|
+
}
|
|
472
|
+
}, refreshMs);
|
|
473
|
+
|
|
474
|
+
return () => clearTimeout(timer);
|
|
475
|
+
}, [user, fetchUser]);
|
|
476
|
+
|
|
477
|
+
const login = () => startLogin();
|
|
478
|
+
|
|
479
|
+
const logout = () => {
|
|
480
|
+
clearTokens();
|
|
481
|
+
setUser(null);
|
|
482
|
+
navigate('/login');
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const isAuthenticated = !!user;
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<AuthContext.Provider value={{ user, login, logout, isAuthenticated, isLoading }}>
|
|
489
|
+
{children}
|
|
490
|
+
</AuthContext.Provider>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function generateProtectedRoute() {
|
|
497
|
+
return `import { Navigate } from 'react-router-dom';
|
|
498
|
+
import { useAuth } from '../auth/AuthProvider.jsx';
|
|
499
|
+
|
|
500
|
+
export default function ProtectedRoute({ children }) {
|
|
501
|
+
const { isAuthenticated, isLoading } = useAuth();
|
|
502
|
+
|
|
503
|
+
if (isLoading) {
|
|
504
|
+
return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>Loading...</div>;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (!isAuthenticated) {
|
|
508
|
+
return <Navigate to="/login" replace />;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return children;
|
|
512
|
+
}
|
|
513
|
+
`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function generateLoginPage() {
|
|
517
|
+
return `import { useAuth } from '../auth/AuthProvider.jsx';
|
|
518
|
+
|
|
519
|
+
export default function Login() {
|
|
520
|
+
const { login } = useAuth();
|
|
521
|
+
|
|
522
|
+
return (
|
|
523
|
+
<div className="login-page">
|
|
524
|
+
<div className="login-card">
|
|
525
|
+
<div className="login-logo">
|
|
526
|
+
<h1>MyVillageOS</h1>
|
|
527
|
+
</div>
|
|
528
|
+
<p>Sign in to access your portal</p>
|
|
529
|
+
<button className="login-btn" onClick={login}>
|
|
530
|
+
Sign in with MyVillageOS
|
|
531
|
+
</button>
|
|
532
|
+
</div>
|
|
533
|
+
</div>
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function generateCallbackPage() {
|
|
540
|
+
return `import { useEffect, useState } from 'react';
|
|
541
|
+
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
542
|
+
import { handleCallback } from '../auth/oauth.js';
|
|
543
|
+
|
|
544
|
+
export default function Callback() {
|
|
545
|
+
const [searchParams] = useSearchParams();
|
|
546
|
+
const navigate = useNavigate();
|
|
547
|
+
const [error, setError] = useState(null);
|
|
548
|
+
|
|
549
|
+
useEffect(() => {
|
|
550
|
+
const code = searchParams.get('code');
|
|
551
|
+
const state = searchParams.get('state');
|
|
552
|
+
|
|
553
|
+
if (!code || !state) {
|
|
554
|
+
setError('Missing authorization code or state');
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
handleCallback(code, state)
|
|
559
|
+
.then(() => navigate('/', { replace: true }))
|
|
560
|
+
.catch((err) => setError(err.message));
|
|
561
|
+
}, [searchParams, navigate]);
|
|
562
|
+
|
|
563
|
+
if (error) {
|
|
564
|
+
return (
|
|
565
|
+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', flexDirection: 'column' }}>
|
|
566
|
+
<h2>Authentication Error</h2>
|
|
567
|
+
<p>{error}</p>
|
|
568
|
+
<a href="/login">Try again</a>
|
|
569
|
+
</div>
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return (
|
|
574
|
+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
|
575
|
+
<p>Completing sign in...</p>
|
|
576
|
+
</div>
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ─── Portal-Specific Generators ─────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
function generatePortalPackageJson(slug, description, features, oauthCredentials) {
|
|
585
|
+
return JSON.stringify({
|
|
586
|
+
name: slug,
|
|
587
|
+
version: '1.0.0',
|
|
588
|
+
description,
|
|
589
|
+
private: true,
|
|
590
|
+
type: 'module',
|
|
591
|
+
scripts: {
|
|
592
|
+
dev: 'vite',
|
|
593
|
+
build: 'vite build',
|
|
594
|
+
preview: 'vite preview',
|
|
595
|
+
},
|
|
596
|
+
dependencies: {
|
|
597
|
+
'react': '^18.3.0',
|
|
598
|
+
'react-dom': '^18.3.0',
|
|
599
|
+
'react-router-dom': '^6.22.0',
|
|
600
|
+
},
|
|
601
|
+
devDependencies: {
|
|
602
|
+
'vite': '^5.0.0',
|
|
603
|
+
'@vitejs/plugin-react': '^4.2.0',
|
|
604
|
+
},
|
|
605
|
+
myvillage: {
|
|
606
|
+
appType: 'portal',
|
|
607
|
+
features,
|
|
608
|
+
oauthClientId: oauthCredentials?.clientId || null,
|
|
609
|
+
createdAt: new Date().toISOString(),
|
|
610
|
+
},
|
|
611
|
+
}, null, 2) + '\n';
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function generatePortalIndexHtml(name) {
|
|
615
|
+
return `<!DOCTYPE html>
|
|
616
|
+
<html lang="en">
|
|
617
|
+
<head>
|
|
618
|
+
<meta charset="UTF-8" />
|
|
619
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
620
|
+
<title>${name} - MyVillageOS</title>
|
|
621
|
+
</head>
|
|
622
|
+
<body>
|
|
623
|
+
<div id="root"></div>
|
|
624
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
625
|
+
</body>
|
|
626
|
+
</html>
|
|
627
|
+
`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function generatePortalReadme(name, description, features) {
|
|
631
|
+
return `# ${name}
|
|
632
|
+
|
|
633
|
+
${description}
|
|
634
|
+
|
|
635
|
+
- **App Type**: Portal
|
|
636
|
+
- **Features**: ${features.join(', ')}
|
|
637
|
+
- **Built with**: MyVillageOS CLI
|
|
638
|
+
|
|
639
|
+
## Getting Started
|
|
640
|
+
|
|
641
|
+
\`\`\`bash
|
|
642
|
+
# Install dependencies
|
|
643
|
+
npm install
|
|
644
|
+
|
|
645
|
+
# Start development server
|
|
646
|
+
npm run dev
|
|
647
|
+
|
|
648
|
+
# Build for production
|
|
649
|
+
npm run build
|
|
650
|
+
|
|
651
|
+
# Preview production build
|
|
652
|
+
npm run preview
|
|
653
|
+
\`\`\`
|
|
654
|
+
|
|
655
|
+
## Project Structure
|
|
656
|
+
|
|
657
|
+
\`\`\`
|
|
658
|
+
src/
|
|
659
|
+
App.jsx - Root component with routing
|
|
660
|
+
components/
|
|
661
|
+
Layout.jsx - App shell with sidebar and header
|
|
662
|
+
Sidebar.jsx - Navigation sidebar
|
|
663
|
+
Header.jsx - Top header bar
|
|
664
|
+
pages/ - Route page components
|
|
665
|
+
auth/ - OAuth PKCE authentication (if enabled)
|
|
666
|
+
\`\`\`
|
|
667
|
+
|
|
668
|
+
## Learn More
|
|
669
|
+
|
|
670
|
+
- [React Documentation](https://react.dev/)
|
|
671
|
+
- [Vite Documentation](https://vitejs.dev/)
|
|
672
|
+
- [MyVillageOS Developer Guide](https://portal.myvillageproject.ai)
|
|
673
|
+
`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function generatePortalApp(features) {
|
|
677
|
+
const hasAuth = features.includes('auth');
|
|
678
|
+
const hasDashboard = features.includes('dashboard');
|
|
679
|
+
const hasSettings = features.includes('settings');
|
|
680
|
+
const hasUsers = features.includes('users');
|
|
681
|
+
const hasNotifications = features.includes('notifications');
|
|
682
|
+
|
|
683
|
+
const imports = [`import { Routes, Route } from 'react-router-dom';`];
|
|
684
|
+
imports.push(`import Layout from './components/Layout.jsx';`);
|
|
685
|
+
imports.push(`import NotFound from './pages/NotFound.jsx';`);
|
|
686
|
+
imports.push(`import './App.css';`);
|
|
687
|
+
|
|
688
|
+
if (hasAuth) {
|
|
689
|
+
imports.push(`import { AuthProvider } from './auth/AuthProvider.jsx';`);
|
|
690
|
+
imports.push(`import ProtectedRoute from './components/ProtectedRoute.jsx';`);
|
|
691
|
+
imports.push(`import Login from './pages/Login.jsx';`);
|
|
692
|
+
imports.push(`import Callback from './pages/Callback.jsx';`);
|
|
693
|
+
}
|
|
694
|
+
if (hasDashboard) {
|
|
695
|
+
imports.push(`import Dashboard from './pages/Dashboard.jsx';`);
|
|
696
|
+
}
|
|
697
|
+
if (hasSettings) {
|
|
698
|
+
imports.push(`import Settings from './pages/Settings.jsx';`);
|
|
699
|
+
}
|
|
700
|
+
if (hasUsers) {
|
|
701
|
+
imports.push(`import Users from './pages/Users.jsx';`);
|
|
702
|
+
}
|
|
703
|
+
if (hasNotifications) {
|
|
704
|
+
imports.push(`import Notifications from './pages/Notifications.jsx';`);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Build route elements
|
|
708
|
+
const routes = [];
|
|
709
|
+
const homeElement = hasDashboard ? '<Dashboard />' : '<div className="welcome"><h1>Welcome</h1><p>Select a page from the sidebar to get started.</p></div>';
|
|
710
|
+
|
|
711
|
+
if (hasAuth) {
|
|
712
|
+
routes.push(` <Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>`);
|
|
713
|
+
} else {
|
|
714
|
+
routes.push(` <Route path="/" element={<Layout />}>`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
routes.push(` <Route index element={${homeElement}} />`);
|
|
718
|
+
|
|
719
|
+
if (hasDashboard) {
|
|
720
|
+
routes.push(` <Route path="dashboard" element={<Dashboard />} />`);
|
|
721
|
+
}
|
|
722
|
+
if (hasSettings) {
|
|
723
|
+
routes.push(` <Route path="settings" element={<Settings />} />`);
|
|
724
|
+
}
|
|
725
|
+
if (hasUsers) {
|
|
726
|
+
routes.push(` <Route path="users" element={<Users />} />`);
|
|
727
|
+
}
|
|
728
|
+
if (hasNotifications) {
|
|
729
|
+
routes.push(` <Route path="notifications" element={<Notifications />} />`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
routes.push(` <Route path="*" element={<NotFound />} />`);
|
|
733
|
+
routes.push(` </Route>`);
|
|
734
|
+
|
|
735
|
+
if (hasAuth) {
|
|
736
|
+
routes.push(` <Route path="/login" element={<Login />} />`);
|
|
737
|
+
routes.push(` <Route path="/callback" element={<Callback />} />`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
let body;
|
|
741
|
+
if (hasAuth) {
|
|
742
|
+
body = ` <AuthProvider>
|
|
743
|
+
<Routes>
|
|
744
|
+
${routes.join('\n')}
|
|
745
|
+
</Routes>
|
|
746
|
+
</AuthProvider>`;
|
|
747
|
+
} else {
|
|
748
|
+
body = ` <Routes>
|
|
749
|
+
${routes.join('\n')}
|
|
750
|
+
</Routes>`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return `${imports.join('\n')}
|
|
754
|
+
|
|
755
|
+
export default function App() {
|
|
756
|
+
return (
|
|
757
|
+
${body}
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function generatePortalAppCss() {
|
|
764
|
+
return `/* CSS Variables - MyVillage Brand */
|
|
765
|
+
:root {
|
|
766
|
+
--brand-gold: ${BRAND.gold};
|
|
767
|
+
--brand-brown: ${BRAND.brown};
|
|
768
|
+
--brand-green: ${BRAND.green};
|
|
769
|
+
--brand-primary: ${BRAND.primary};
|
|
770
|
+
--brand-secondary: ${BRAND.secondary};
|
|
771
|
+
--brand-dark-brown: ${BRAND.darkBrown};
|
|
772
|
+
--brand-deep-green: ${BRAND.deepGreen};
|
|
773
|
+
--brand-teal: ${BRAND.teal};
|
|
774
|
+
|
|
775
|
+
--sidebar-width: 240px;
|
|
776
|
+
--header-height: 56px;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/* Layout */
|
|
780
|
+
.layout {
|
|
781
|
+
display: flex;
|
|
782
|
+
height: 100vh;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
.layout-main {
|
|
786
|
+
flex: 1;
|
|
787
|
+
display: flex;
|
|
788
|
+
flex-direction: column;
|
|
789
|
+
overflow: hidden;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
.layout-content {
|
|
793
|
+
flex: 1;
|
|
794
|
+
overflow-y: auto;
|
|
795
|
+
padding: 24px;
|
|
796
|
+
background: #f5f3ef;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/* Sidebar */
|
|
800
|
+
.sidebar {
|
|
801
|
+
width: var(--sidebar-width);
|
|
802
|
+
background: var(--brand-dark-brown);
|
|
803
|
+
color: var(--brand-secondary);
|
|
804
|
+
display: flex;
|
|
805
|
+
flex-direction: column;
|
|
806
|
+
transition: transform 0.3s;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
.sidebar-brand {
|
|
810
|
+
padding: 16px 20px;
|
|
811
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
.sidebar-brand h2 {
|
|
815
|
+
color: var(--brand-gold);
|
|
816
|
+
font-size: 18px;
|
|
817
|
+
margin: 0;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
.sidebar-nav {
|
|
821
|
+
flex: 1;
|
|
822
|
+
padding: 12px 0;
|
|
823
|
+
list-style: none;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
.sidebar-nav a {
|
|
827
|
+
display: block;
|
|
828
|
+
padding: 10px 20px;
|
|
829
|
+
color: var(--brand-secondary);
|
|
830
|
+
text-decoration: none;
|
|
831
|
+
font-size: 14px;
|
|
832
|
+
transition: background 0.2s;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
.sidebar-nav a:hover,
|
|
836
|
+
.sidebar-nav a.active {
|
|
837
|
+
background: rgba(255, 215, 0, 0.1);
|
|
838
|
+
color: var(--brand-gold);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/* Header */
|
|
842
|
+
.header {
|
|
843
|
+
height: var(--header-height);
|
|
844
|
+
background: white;
|
|
845
|
+
border-bottom: 1px solid #e0ddd7;
|
|
846
|
+
display: flex;
|
|
847
|
+
align-items: center;
|
|
848
|
+
justify-content: space-between;
|
|
849
|
+
padding: 0 24px;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.header-title {
|
|
853
|
+
font-size: 16px;
|
|
854
|
+
font-weight: 600;
|
|
855
|
+
color: var(--brand-dark-brown);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
.header-user {
|
|
859
|
+
display: flex;
|
|
860
|
+
align-items: center;
|
|
861
|
+
gap: 12px;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
.header-user button {
|
|
865
|
+
padding: 6px 14px;
|
|
866
|
+
background: var(--brand-primary);
|
|
867
|
+
color: white;
|
|
868
|
+
border: none;
|
|
869
|
+
border-radius: 6px;
|
|
870
|
+
font-size: 13px;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/* Cards */
|
|
874
|
+
.stat-cards {
|
|
875
|
+
display: grid;
|
|
876
|
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
877
|
+
gap: 16px;
|
|
878
|
+
margin-bottom: 24px;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
.stat-card {
|
|
882
|
+
background: white;
|
|
883
|
+
border-radius: 12px;
|
|
884
|
+
padding: 20px;
|
|
885
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
.stat-card h3 {
|
|
889
|
+
font-size: 13px;
|
|
890
|
+
color: #888;
|
|
891
|
+
margin-bottom: 8px;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
.stat-card .value {
|
|
895
|
+
font-size: 28px;
|
|
896
|
+
font-weight: 700;
|
|
897
|
+
color: var(--brand-dark-brown);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/* Tables */
|
|
901
|
+
.data-table {
|
|
902
|
+
width: 100%;
|
|
903
|
+
border-collapse: collapse;
|
|
904
|
+
background: white;
|
|
905
|
+
border-radius: 12px;
|
|
906
|
+
overflow: hidden;
|
|
907
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
.data-table th,
|
|
911
|
+
.data-table td {
|
|
912
|
+
padding: 12px 16px;
|
|
913
|
+
text-align: left;
|
|
914
|
+
border-bottom: 1px solid #eee;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
.data-table th {
|
|
918
|
+
background: #fafaf8;
|
|
919
|
+
font-size: 12px;
|
|
920
|
+
text-transform: uppercase;
|
|
921
|
+
color: #888;
|
|
922
|
+
font-weight: 600;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/* Badges */
|
|
926
|
+
.badge {
|
|
927
|
+
display: inline-block;
|
|
928
|
+
padding: 2px 10px;
|
|
929
|
+
border-radius: 12px;
|
|
930
|
+
font-size: 12px;
|
|
931
|
+
font-weight: 600;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
.badge-admin {
|
|
935
|
+
background: rgba(176, 124, 0, 0.1);
|
|
936
|
+
color: var(--brand-primary);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.badge-member {
|
|
940
|
+
background: rgba(34, 139, 34, 0.1);
|
|
941
|
+
color: var(--brand-green);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/* Forms */
|
|
945
|
+
.form-group {
|
|
946
|
+
margin-bottom: 16px;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
.form-group label {
|
|
950
|
+
display: block;
|
|
951
|
+
margin-bottom: 4px;
|
|
952
|
+
font-size: 13px;
|
|
953
|
+
font-weight: 600;
|
|
954
|
+
color: #555;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.form-group input,
|
|
958
|
+
.form-group textarea {
|
|
959
|
+
width: 100%;
|
|
960
|
+
padding: 8px 12px;
|
|
961
|
+
border: 1px solid #ddd;
|
|
962
|
+
border-radius: 6px;
|
|
963
|
+
font-size: 14px;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
.form-group textarea {
|
|
967
|
+
resize: vertical;
|
|
968
|
+
min-height: 80px;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.btn {
|
|
972
|
+
padding: 8px 20px;
|
|
973
|
+
border: none;
|
|
974
|
+
border-radius: 6px;
|
|
975
|
+
font-size: 14px;
|
|
976
|
+
font-weight: 600;
|
|
977
|
+
cursor: pointer;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
.btn-primary {
|
|
981
|
+
background: var(--brand-primary);
|
|
982
|
+
color: white;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
.btn-secondary {
|
|
986
|
+
background: #eee;
|
|
987
|
+
color: #333;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/* Login */
|
|
991
|
+
.login-page {
|
|
992
|
+
display: flex;
|
|
993
|
+
align-items: center;
|
|
994
|
+
justify-content: center;
|
|
995
|
+
height: 100vh;
|
|
996
|
+
background: var(--brand-dark-brown);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
.login-card {
|
|
1000
|
+
background: white;
|
|
1001
|
+
padding: 40px;
|
|
1002
|
+
border-radius: 16px;
|
|
1003
|
+
text-align: center;
|
|
1004
|
+
max-width: 400px;
|
|
1005
|
+
width: 100%;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
.login-logo h1 {
|
|
1009
|
+
color: var(--brand-primary);
|
|
1010
|
+
margin-bottom: 8px;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
.login-card p {
|
|
1014
|
+
color: #666;
|
|
1015
|
+
margin-bottom: 24px;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
.login-btn {
|
|
1019
|
+
width: 100%;
|
|
1020
|
+
padding: 12px;
|
|
1021
|
+
background: var(--brand-primary);
|
|
1022
|
+
color: white;
|
|
1023
|
+
border: none;
|
|
1024
|
+
border-radius: 8px;
|
|
1025
|
+
font-size: 16px;
|
|
1026
|
+
font-weight: 600;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/* Welcome */
|
|
1030
|
+
.welcome {
|
|
1031
|
+
display: flex;
|
|
1032
|
+
flex-direction: column;
|
|
1033
|
+
align-items: center;
|
|
1034
|
+
justify-content: center;
|
|
1035
|
+
height: 60vh;
|
|
1036
|
+
text-align: center;
|
|
1037
|
+
color: #888;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
.welcome h1 {
|
|
1041
|
+
color: var(--brand-dark-brown);
|
|
1042
|
+
margin-bottom: 8px;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/* Notifications */
|
|
1046
|
+
.notification-list {
|
|
1047
|
+
display: flex;
|
|
1048
|
+
flex-direction: column;
|
|
1049
|
+
gap: 8px;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
.notification-item {
|
|
1053
|
+
background: white;
|
|
1054
|
+
padding: 16px;
|
|
1055
|
+
border-radius: 8px;
|
|
1056
|
+
border-left: 3px solid var(--brand-primary);
|
|
1057
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
.notification-item.unread {
|
|
1061
|
+
border-left-color: var(--brand-gold);
|
|
1062
|
+
background: #fffdf5;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
.notification-filters {
|
|
1066
|
+
display: flex;
|
|
1067
|
+
gap: 8px;
|
|
1068
|
+
margin-bottom: 16px;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
.notification-filters button {
|
|
1072
|
+
padding: 6px 14px;
|
|
1073
|
+
border: 1px solid #ddd;
|
|
1074
|
+
border-radius: 6px;
|
|
1075
|
+
background: white;
|
|
1076
|
+
font-size: 13px;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
.notification-filters button.active {
|
|
1080
|
+
background: var(--brand-primary);
|
|
1081
|
+
color: white;
|
|
1082
|
+
border-color: var(--brand-primary);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/* Toggle switches */
|
|
1086
|
+
.toggle-row {
|
|
1087
|
+
display: flex;
|
|
1088
|
+
justify-content: space-between;
|
|
1089
|
+
align-items: center;
|
|
1090
|
+
padding: 12px 0;
|
|
1091
|
+
border-bottom: 1px solid #eee;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
.toggle-switch {
|
|
1095
|
+
width: 44px;
|
|
1096
|
+
height: 24px;
|
|
1097
|
+
background: #ccc;
|
|
1098
|
+
border-radius: 12px;
|
|
1099
|
+
position: relative;
|
|
1100
|
+
cursor: pointer;
|
|
1101
|
+
border: none;
|
|
1102
|
+
transition: background 0.2s;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
.toggle-switch.on {
|
|
1106
|
+
background: var(--brand-green);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
.toggle-switch::after {
|
|
1110
|
+
content: '';
|
|
1111
|
+
position: absolute;
|
|
1112
|
+
top: 2px;
|
|
1113
|
+
left: 2px;
|
|
1114
|
+
width: 20px;
|
|
1115
|
+
height: 20px;
|
|
1116
|
+
background: white;
|
|
1117
|
+
border-radius: 50%;
|
|
1118
|
+
transition: transform 0.2s;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
.toggle-switch.on::after {
|
|
1122
|
+
transform: translateX(20px);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/* Responsive */
|
|
1126
|
+
@media (max-width: 768px) {
|
|
1127
|
+
.sidebar {
|
|
1128
|
+
position: fixed;
|
|
1129
|
+
z-index: 100;
|
|
1130
|
+
height: 100vh;
|
|
1131
|
+
transform: translateX(-100%);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
.sidebar.open {
|
|
1135
|
+
transform: translateX(0);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
.header-hamburger {
|
|
1139
|
+
display: block;
|
|
1140
|
+
background: none;
|
|
1141
|
+
border: none;
|
|
1142
|
+
font-size: 24px;
|
|
1143
|
+
cursor: pointer;
|
|
1144
|
+
color: var(--brand-dark-brown);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
.stat-cards {
|
|
1148
|
+
grid-template-columns: 1fr;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
@media (min-width: 769px) {
|
|
1153
|
+
.header-hamburger {
|
|
1154
|
+
display: none;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
`;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function generatePortalLayout() {
|
|
1161
|
+
return `import { useState } from 'react';
|
|
1162
|
+
import { Outlet } from 'react-router-dom';
|
|
1163
|
+
import Sidebar from './Sidebar.jsx';
|
|
1164
|
+
import Header from './Header.jsx';
|
|
1165
|
+
|
|
1166
|
+
export default function Layout() {
|
|
1167
|
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
1168
|
+
|
|
1169
|
+
return (
|
|
1170
|
+
<div className="layout">
|
|
1171
|
+
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
|
1172
|
+
<div className="layout-main">
|
|
1173
|
+
<Header onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
|
1174
|
+
<main className="layout-content">
|
|
1175
|
+
<Outlet />
|
|
1176
|
+
</main>
|
|
1177
|
+
</div>
|
|
1178
|
+
</div>
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
`;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function generatePortalSidebar(features) {
|
|
1185
|
+
const hasAuth = features.includes('auth');
|
|
1186
|
+
const hasDashboard = features.includes('dashboard');
|
|
1187
|
+
const hasSettings = features.includes('settings');
|
|
1188
|
+
const hasUsers = features.includes('users');
|
|
1189
|
+
const hasNotifications = features.includes('notifications');
|
|
1190
|
+
|
|
1191
|
+
const links = [` <li><NavLink to="/" end>Home</NavLink></li>`];
|
|
1192
|
+
if (hasDashboard) {
|
|
1193
|
+
links.push(` <li><NavLink to="/dashboard">Dashboard</NavLink></li>`);
|
|
1194
|
+
}
|
|
1195
|
+
if (hasUsers) {
|
|
1196
|
+
links.push(` <li><NavLink to="/users">Users</NavLink></li>`);
|
|
1197
|
+
}
|
|
1198
|
+
if (hasNotifications) {
|
|
1199
|
+
links.push(` <li><NavLink to="/notifications">Notifications</NavLink></li>`);
|
|
1200
|
+
}
|
|
1201
|
+
if (hasSettings) {
|
|
1202
|
+
links.push(` <li><NavLink to="/settings">Settings</NavLink></li>`);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return `import { NavLink } from 'react-router-dom';
|
|
1206
|
+
|
|
1207
|
+
export default function Sidebar({ isOpen, onClose }) {
|
|
1208
|
+
return (
|
|
1209
|
+
<aside className={\`sidebar\${isOpen ? ' open' : ''}\`}>
|
|
1210
|
+
<div className="sidebar-brand">
|
|
1211
|
+
<h2>MyVillageOS</h2>
|
|
1212
|
+
</div>
|
|
1213
|
+
<ul className="sidebar-nav">
|
|
1214
|
+
${links.join('\n')}
|
|
1215
|
+
</ul>
|
|
1216
|
+
</aside>
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
`;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function generatePortalHeader(hasAuth) {
|
|
1223
|
+
if (hasAuth) {
|
|
1224
|
+
return `import { useAuth } from '../auth/AuthProvider.jsx';
|
|
1225
|
+
|
|
1226
|
+
export default function Header({ onMenuClick }) {
|
|
1227
|
+
const { user, logout } = useAuth();
|
|
1228
|
+
|
|
1229
|
+
return (
|
|
1230
|
+
<header className="header">
|
|
1231
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
1232
|
+
<button className="header-hamburger" onClick={onMenuClick}>☰</button>
|
|
1233
|
+
<span className="header-title">Portal</span>
|
|
1234
|
+
</div>
|
|
1235
|
+
<div className="header-user">
|
|
1236
|
+
{user && <span>{user.name || user.email}</span>}
|
|
1237
|
+
<button onClick={logout}>Logout</button>
|
|
1238
|
+
</div>
|
|
1239
|
+
</header>
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
`;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
return `export default function Header({ onMenuClick }) {
|
|
1246
|
+
return (
|
|
1247
|
+
<header className="header">
|
|
1248
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
1249
|
+
<button className="header-hamburger" onClick={onMenuClick}>☰</button>
|
|
1250
|
+
<span className="header-title">Portal</span>
|
|
1251
|
+
</div>
|
|
1252
|
+
</header>
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
`;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function generateDashboardPage() {
|
|
1259
|
+
return `export default function Dashboard() {
|
|
1260
|
+
return (
|
|
1261
|
+
<div>
|
|
1262
|
+
<h2 style={{ marginBottom: '20px' }}>Dashboard</h2>
|
|
1263
|
+
|
|
1264
|
+
<div className="stat-cards">
|
|
1265
|
+
<div className="stat-card">
|
|
1266
|
+
<h3>Total Users</h3>
|
|
1267
|
+
<div className="value">1,248</div>
|
|
1268
|
+
</div>
|
|
1269
|
+
<div className="stat-card">
|
|
1270
|
+
<h3>Active Projects</h3>
|
|
1271
|
+
<div className="value">36</div>
|
|
1272
|
+
</div>
|
|
1273
|
+
<div className="stat-card">
|
|
1274
|
+
<h3>Revenue</h3>
|
|
1275
|
+
<div className="value">$12,480</div>
|
|
1276
|
+
</div>
|
|
1277
|
+
<div className="stat-card">
|
|
1278
|
+
<h3>Growth</h3>
|
|
1279
|
+
<div className="value">+24%</div>
|
|
1280
|
+
</div>
|
|
1281
|
+
</div>
|
|
1282
|
+
|
|
1283
|
+
<div style={{ background: 'white', borderRadius: '12px', padding: '20px', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
|
|
1284
|
+
<h3 style={{ marginBottom: '16px' }}>Recent Activity</h3>
|
|
1285
|
+
<ul style={{ listStyle: 'none', padding: 0 }}>
|
|
1286
|
+
<li style={{ padding: '10px 0', borderBottom: '1px solid #eee' }}>New user registered - 2 minutes ago</li>
|
|
1287
|
+
<li style={{ padding: '10px 0', borderBottom: '1px solid #eee' }}>Project "Alpha" deployed - 15 minutes ago</li>
|
|
1288
|
+
<li style={{ padding: '10px 0', borderBottom: '1px solid #eee' }}>Settings updated - 1 hour ago</li>
|
|
1289
|
+
<li style={{ padding: '10px 0' }}>New community created - 3 hours ago</li>
|
|
1290
|
+
</ul>
|
|
1291
|
+
</div>
|
|
1292
|
+
|
|
1293
|
+
<div style={{ marginTop: '24px' }}>
|
|
1294
|
+
<h3 style={{ marginBottom: '12px' }}>Quick Actions</h3>
|
|
1295
|
+
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
|
1296
|
+
<button className="btn btn-primary">Create Project</button>
|
|
1297
|
+
<button className="btn btn-secondary">Invite User</button>
|
|
1298
|
+
<button className="btn btn-secondary">View Reports</button>
|
|
1299
|
+
</div>
|
|
1300
|
+
</div>
|
|
1301
|
+
</div>
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
`;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function generateSettingsPage() {
|
|
1308
|
+
return `import { useState } from 'react';
|
|
1309
|
+
|
|
1310
|
+
export default function Settings() {
|
|
1311
|
+
const [name, setName] = useState('');
|
|
1312
|
+
const [email, setEmail] = useState('');
|
|
1313
|
+
const [bio, setBio] = useState('');
|
|
1314
|
+
const [emailNotifs, setEmailNotifs] = useState(true);
|
|
1315
|
+
const [pushNotifs, setPushNotifs] = useState(false);
|
|
1316
|
+
|
|
1317
|
+
const handleSave = (e) => {
|
|
1318
|
+
e.preventDefault();
|
|
1319
|
+
alert('Settings saved (placeholder)');
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
return (
|
|
1323
|
+
<div>
|
|
1324
|
+
<h2 style={{ marginBottom: '20px' }}>Settings</h2>
|
|
1325
|
+
|
|
1326
|
+
<div style={{ background: 'white', borderRadius: '12px', padding: '24px', marginBottom: '24px', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
|
|
1327
|
+
<h3 style={{ marginBottom: '16px' }}>Profile</h3>
|
|
1328
|
+
<form onSubmit={handleSave}>
|
|
1329
|
+
<div className="form-group">
|
|
1330
|
+
<label>Name</label>
|
|
1331
|
+
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" />
|
|
1332
|
+
</div>
|
|
1333
|
+
<div className="form-group">
|
|
1334
|
+
<label>Email</label>
|
|
1335
|
+
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" />
|
|
1336
|
+
</div>
|
|
1337
|
+
<div className="form-group">
|
|
1338
|
+
<label>Bio</label>
|
|
1339
|
+
<textarea value={bio} onChange={(e) => setBio(e.target.value)} placeholder="Tell us about yourself" />
|
|
1340
|
+
</div>
|
|
1341
|
+
<button className="btn btn-primary" type="submit">Save Changes</button>
|
|
1342
|
+
</form>
|
|
1343
|
+
</div>
|
|
1344
|
+
|
|
1345
|
+
<div style={{ background: 'white', borderRadius: '12px', padding: '24px', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
|
|
1346
|
+
<h3 style={{ marginBottom: '16px' }}>Notification Preferences</h3>
|
|
1347
|
+
<div className="toggle-row">
|
|
1348
|
+
<span>Email notifications</span>
|
|
1349
|
+
<button className={\`toggle-switch\${emailNotifs ? ' on' : ''}\`} onClick={() => setEmailNotifs(!emailNotifs)} />
|
|
1350
|
+
</div>
|
|
1351
|
+
<div className="toggle-row">
|
|
1352
|
+
<span>Push notifications</span>
|
|
1353
|
+
<button className={\`toggle-switch\${pushNotifs ? ' on' : ''}\`} onClick={() => setPushNotifs(!pushNotifs)} />
|
|
1354
|
+
</div>
|
|
1355
|
+
</div>
|
|
1356
|
+
</div>
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
`;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function generateUsersPage() {
|
|
1363
|
+
return `import { useState } from 'react';
|
|
1364
|
+
|
|
1365
|
+
const SAMPLE_USERS = [
|
|
1366
|
+
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'admin', status: 'Active' },
|
|
1367
|
+
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'member', status: 'Active' },
|
|
1368
|
+
{ id: 3, name: 'Carol Williams', email: 'carol@example.com', role: 'member', status: 'Active' },
|
|
1369
|
+
{ id: 4, name: 'Dave Brown', email: 'dave@example.com', role: 'member', status: 'Inactive' },
|
|
1370
|
+
];
|
|
1371
|
+
|
|
1372
|
+
export default function Users() {
|
|
1373
|
+
const [search, setSearch] = useState('');
|
|
1374
|
+
const filtered = SAMPLE_USERS.filter(
|
|
1375
|
+
(u) => u.name.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase())
|
|
1376
|
+
);
|
|
1377
|
+
|
|
1378
|
+
return (
|
|
1379
|
+
<div>
|
|
1380
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
|
1381
|
+
<h2>Users</h2>
|
|
1382
|
+
<button className="btn btn-primary">Invite User</button>
|
|
1383
|
+
</div>
|
|
1384
|
+
|
|
1385
|
+
<div style={{ marginBottom: '16px' }}>
|
|
1386
|
+
<input
|
|
1387
|
+
type="text"
|
|
1388
|
+
placeholder="Search users..."
|
|
1389
|
+
value={search}
|
|
1390
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
1391
|
+
style={{ padding: '8px 12px', border: '1px solid #ddd', borderRadius: '6px', width: '300px', fontSize: '14px' }}
|
|
1392
|
+
/>
|
|
1393
|
+
</div>
|
|
1394
|
+
|
|
1395
|
+
<table className="data-table">
|
|
1396
|
+
<thead>
|
|
1397
|
+
<tr>
|
|
1398
|
+
<th>Name</th>
|
|
1399
|
+
<th>Email</th>
|
|
1400
|
+
<th>Role</th>
|
|
1401
|
+
<th>Status</th>
|
|
1402
|
+
</tr>
|
|
1403
|
+
</thead>
|
|
1404
|
+
<tbody>
|
|
1405
|
+
{filtered.map((user) => (
|
|
1406
|
+
<tr key={user.id}>
|
|
1407
|
+
<td>{user.name}</td>
|
|
1408
|
+
<td>{user.email}</td>
|
|
1409
|
+
<td><span className={\`badge badge-\${user.role}\`}>{user.role}</span></td>
|
|
1410
|
+
<td>{user.status}</td>
|
|
1411
|
+
</tr>
|
|
1412
|
+
))}
|
|
1413
|
+
</tbody>
|
|
1414
|
+
</table>
|
|
1415
|
+
</div>
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
`;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function generateNotificationsPage() {
|
|
1422
|
+
return `import { useState } from 'react';
|
|
1423
|
+
|
|
1424
|
+
const SAMPLE_NOTIFICATIONS = [
|
|
1425
|
+
{ id: 1, type: 'mentions', message: 'Alice mentioned you in a comment', time: '2 minutes ago', read: false },
|
|
1426
|
+
{ id: 2, type: 'updates', message: 'Project "Beta" was deployed successfully', time: '1 hour ago', read: false },
|
|
1427
|
+
{ id: 3, type: 'alerts', message: 'API rate limit reached 80%', time: '3 hours ago', read: true },
|
|
1428
|
+
{ id: 4, type: 'mentions', message: 'Bob replied to your post', time: '1 day ago', read: true },
|
|
1429
|
+
];
|
|
1430
|
+
|
|
1431
|
+
export default function Notifications() {
|
|
1432
|
+
const [filter, setFilter] = useState('all');
|
|
1433
|
+
const [notifications, setNotifications] = useState(SAMPLE_NOTIFICATIONS);
|
|
1434
|
+
|
|
1435
|
+
const filtered = filter === 'all' ? notifications : notifications.filter((n) => n.type === filter);
|
|
1436
|
+
|
|
1437
|
+
const markAsRead = (id) => {
|
|
1438
|
+
setNotifications(notifications.map((n) => (n.id === id ? { ...n, read: true } : n)));
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
return (
|
|
1442
|
+
<div>
|
|
1443
|
+
<h2 style={{ marginBottom: '20px' }}>Notifications</h2>
|
|
1444
|
+
|
|
1445
|
+
<div className="notification-filters">
|
|
1446
|
+
{['all', 'mentions', 'updates', 'alerts'].map((type) => (
|
|
1447
|
+
<button
|
|
1448
|
+
key={type}
|
|
1449
|
+
className={filter === type ? 'active' : ''}
|
|
1450
|
+
onClick={() => setFilter(type)}
|
|
1451
|
+
>
|
|
1452
|
+
{type.charAt(0).toUpperCase() + type.slice(1)}
|
|
1453
|
+
</button>
|
|
1454
|
+
))}
|
|
1455
|
+
</div>
|
|
1456
|
+
|
|
1457
|
+
<div className="notification-list">
|
|
1458
|
+
{filtered.map((n) => (
|
|
1459
|
+
<div key={n.id} className={\`notification-item\${n.read ? '' : ' unread'}\`}>
|
|
1460
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
|
1461
|
+
<div>
|
|
1462
|
+
<p style={{ fontWeight: n.read ? 'normal' : '600' }}>{n.message}</p>
|
|
1463
|
+
<small style={{ color: '#888' }}>{n.time}</small>
|
|
1464
|
+
</div>
|
|
1465
|
+
{!n.read && (
|
|
1466
|
+
<button className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 10px' }} onClick={() => markAsRead(n.id)}>
|
|
1467
|
+
Mark as read
|
|
1468
|
+
</button>
|
|
1469
|
+
)}
|
|
1470
|
+
</div>
|
|
1471
|
+
</div>
|
|
1472
|
+
))}
|
|
1473
|
+
</div>
|
|
1474
|
+
</div>
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
`;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function generateNotFoundPage() {
|
|
1481
|
+
return `import { Link } from 'react-router-dom';
|
|
1482
|
+
|
|
1483
|
+
export default function NotFound() {
|
|
1484
|
+
return (
|
|
1485
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', textAlign: 'center' }}>
|
|
1486
|
+
<h1 style={{ fontSize: '64px', color: '#ccc', marginBottom: '8px' }}>404</h1>
|
|
1487
|
+
<p style={{ fontSize: '18px', color: '#888', marginBottom: '24px' }}>Page not found</p>
|
|
1488
|
+
<Link to="/" className="btn btn-primary" style={{ textDecoration: 'none', padding: '10px 24px', background: '#B07C00', color: 'white', borderRadius: '8px' }}>
|
|
1489
|
+
Go Home
|
|
1490
|
+
</Link>
|
|
1491
|
+
</div>
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
`;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// ─── Data Labeling Generators ───────────────────────────────────────────────
|
|
1498
|
+
|
|
1499
|
+
function generateLabelingPackageJson(slug, description, dataTypes, oauthCredentials) {
|
|
1500
|
+
return JSON.stringify({
|
|
1501
|
+
name: slug,
|
|
1502
|
+
version: '1.0.0',
|
|
1503
|
+
description,
|
|
1504
|
+
private: true,
|
|
1505
|
+
type: 'module',
|
|
1506
|
+
scripts: {
|
|
1507
|
+
dev: 'vite',
|
|
1508
|
+
build: 'vite build',
|
|
1509
|
+
preview: 'vite preview',
|
|
1510
|
+
},
|
|
1511
|
+
dependencies: {
|
|
1512
|
+
'react': '^18.3.0',
|
|
1513
|
+
'react-dom': '^18.3.0',
|
|
1514
|
+
'react-router-dom': '^6.22.0',
|
|
1515
|
+
},
|
|
1516
|
+
devDependencies: {
|
|
1517
|
+
'vite': '^5.0.0',
|
|
1518
|
+
'@vitejs/plugin-react': '^4.2.0',
|
|
1519
|
+
},
|
|
1520
|
+
myvillage: {
|
|
1521
|
+
appType: 'data-labeling',
|
|
1522
|
+
dataTypes,
|
|
1523
|
+
oauthClientId: oauthCredentials?.clientId || null,
|
|
1524
|
+
createdAt: new Date().toISOString(),
|
|
1525
|
+
},
|
|
1526
|
+
}, null, 2) + '\n';
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function generateLabelingIndexHtml(name) {
|
|
1530
|
+
return `<!DOCTYPE html>
|
|
1531
|
+
<html lang="en">
|
|
1532
|
+
<head>
|
|
1533
|
+
<meta charset="UTF-8" />
|
|
1534
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
1535
|
+
<title>${name} - MyVillageOS</title>
|
|
1536
|
+
</head>
|
|
1537
|
+
<body>
|
|
1538
|
+
<div id="root"></div>
|
|
1539
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
1540
|
+
</body>
|
|
1541
|
+
</html>
|
|
1542
|
+
`;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function generateLabelingReadme(name, description, dataTypes) {
|
|
1546
|
+
return `# ${name}
|
|
1547
|
+
|
|
1548
|
+
${description}
|
|
1549
|
+
|
|
1550
|
+
- **App Type**: Data Labeling
|
|
1551
|
+
- **Data Types**: ${dataTypes.join(', ')}
|
|
1552
|
+
- **Built with**: MyVillageOS CLI
|
|
1553
|
+
|
|
1554
|
+
## Getting Started
|
|
1555
|
+
|
|
1556
|
+
\`\`\`bash
|
|
1557
|
+
# Install dependencies
|
|
1558
|
+
npm install
|
|
1559
|
+
|
|
1560
|
+
# Start development server
|
|
1561
|
+
npm run dev
|
|
1562
|
+
|
|
1563
|
+
# Build for production
|
|
1564
|
+
npm run build
|
|
1565
|
+
|
|
1566
|
+
# Preview production build
|
|
1567
|
+
npm run preview
|
|
1568
|
+
\`\`\`
|
|
1569
|
+
|
|
1570
|
+
## Project Structure
|
|
1571
|
+
|
|
1572
|
+
\`\`\`
|
|
1573
|
+
src/
|
|
1574
|
+
App.jsx - Root component with routing
|
|
1575
|
+
components/
|
|
1576
|
+
Layout.jsx - App shell with toolbar
|
|
1577
|
+
Toolbar.jsx - Tool selection bar
|
|
1578
|
+
DataTypeSelector.jsx - Switch between data types
|
|
1579
|
+
ProgressBar.jsx - Labeling progress indicator
|
|
1580
|
+
labelers/ - Data-type-specific annotation components
|
|
1581
|
+
context/
|
|
1582
|
+
ProjectContext.jsx - Project state management
|
|
1583
|
+
pages/ - Route page components
|
|
1584
|
+
utils/ - Keyboard shortcuts and export formats
|
|
1585
|
+
\`\`\`
|
|
1586
|
+
|
|
1587
|
+
## Learn More
|
|
1588
|
+
|
|
1589
|
+
- [React Documentation](https://react.dev/)
|
|
1590
|
+
- [Vite Documentation](https://vitejs.dev/)
|
|
1591
|
+
- [MyVillageOS Developer Guide](https://portal.myvillageproject.ai)
|
|
1592
|
+
`;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function generateLabelingApp(dataTypes, includeAuth) {
|
|
1596
|
+
const imports = [`import { Routes, Route } from 'react-router-dom';`];
|
|
1597
|
+
imports.push(`import { ProjectProvider } from './context/ProjectContext.jsx';`);
|
|
1598
|
+
imports.push(`import Layout from './components/Layout.jsx';`);
|
|
1599
|
+
imports.push(`import Workspace from './pages/Workspace.jsx';`);
|
|
1600
|
+
imports.push(`import DatasetManager from './pages/DatasetManager.jsx';`);
|
|
1601
|
+
imports.push(`import LabelSchema from './pages/LabelSchema.jsx';`);
|
|
1602
|
+
imports.push(`import Export from './pages/Export.jsx';`);
|
|
1603
|
+
imports.push(`import './App.css';`);
|
|
1604
|
+
|
|
1605
|
+
if (includeAuth) {
|
|
1606
|
+
imports.push(`import { AuthProvider } from './auth/AuthProvider.jsx';`);
|
|
1607
|
+
imports.push(`import ProtectedRoute from './components/ProtectedRoute.jsx';`);
|
|
1608
|
+
imports.push(`import Login from './pages/Login.jsx';`);
|
|
1609
|
+
imports.push(`import Callback from './pages/Callback.jsx';`);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
const layoutRoute = includeAuth
|
|
1613
|
+
? ` <Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>`
|
|
1614
|
+
: ` <Route path="/" element={<Layout />}>`;
|
|
1615
|
+
|
|
1616
|
+
const authRoutes = includeAuth
|
|
1617
|
+
? `\n <Route path="/login" element={<Login />} />\n <Route path="/callback" element={<Callback />} />`
|
|
1618
|
+
: '';
|
|
1619
|
+
|
|
1620
|
+
const wrapStart = includeAuth ? ` <AuthProvider>\n <ProjectProvider>` : ` <ProjectProvider>`;
|
|
1621
|
+
const wrapEnd = includeAuth ? ` </ProjectProvider>\n </AuthProvider>` : ` </ProjectProvider>`;
|
|
1622
|
+
|
|
1623
|
+
return `${imports.join('\n')}
|
|
1624
|
+
|
|
1625
|
+
export default function App() {
|
|
1626
|
+
return (
|
|
1627
|
+
${wrapStart}
|
|
1628
|
+
<Routes>
|
|
1629
|
+
${layoutRoute}
|
|
1630
|
+
<Route index element={<Workspace />} />
|
|
1631
|
+
<Route path="datasets" element={<DatasetManager />} />
|
|
1632
|
+
<Route path="schema" element={<LabelSchema />} />
|
|
1633
|
+
<Route path="export" element={<Export />} />
|
|
1634
|
+
</Route>${authRoutes}
|
|
1635
|
+
</Routes>
|
|
1636
|
+
${wrapEnd}
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
`;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function generateLabelingAppCss() {
|
|
1643
|
+
return `/* CSS Variables - MyVillage Brand */
|
|
1644
|
+
:root {
|
|
1645
|
+
--brand-gold: ${BRAND.gold};
|
|
1646
|
+
--brand-brown: ${BRAND.brown};
|
|
1647
|
+
--brand-green: ${BRAND.green};
|
|
1648
|
+
--brand-primary: ${BRAND.primary};
|
|
1649
|
+
--brand-secondary: ${BRAND.secondary};
|
|
1650
|
+
--brand-dark-brown: ${BRAND.darkBrown};
|
|
1651
|
+
--brand-deep-green: ${BRAND.deepGreen};
|
|
1652
|
+
--brand-teal: ${BRAND.teal};
|
|
1653
|
+
|
|
1654
|
+
--toolbar-height: 48px;
|
|
1655
|
+
--nav-height: 44px;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/* Layout */
|
|
1659
|
+
.labeling-layout {
|
|
1660
|
+
display: flex;
|
|
1661
|
+
flex-direction: column;
|
|
1662
|
+
height: 100vh;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
.labeling-nav {
|
|
1666
|
+
height: var(--nav-height);
|
|
1667
|
+
background: var(--brand-dark-brown);
|
|
1668
|
+
display: flex;
|
|
1669
|
+
align-items: center;
|
|
1670
|
+
padding: 0 16px;
|
|
1671
|
+
gap: 8px;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
.labeling-nav a {
|
|
1675
|
+
color: var(--brand-secondary);
|
|
1676
|
+
text-decoration: none;
|
|
1677
|
+
padding: 6px 14px;
|
|
1678
|
+
border-radius: 6px;
|
|
1679
|
+
font-size: 13px;
|
|
1680
|
+
transition: background 0.2s;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
.labeling-nav a:hover,
|
|
1684
|
+
.labeling-nav a.active {
|
|
1685
|
+
background: rgba(255, 215, 0, 0.15);
|
|
1686
|
+
color: var(--brand-gold);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
.labeling-nav .nav-brand {
|
|
1690
|
+
color: var(--brand-gold);
|
|
1691
|
+
font-weight: 700;
|
|
1692
|
+
font-size: 14px;
|
|
1693
|
+
margin-right: 16px;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
.labeling-content {
|
|
1697
|
+
flex: 1;
|
|
1698
|
+
overflow-y: auto;
|
|
1699
|
+
background: #f5f3ef;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/* Toolbar */
|
|
1703
|
+
.toolbar {
|
|
1704
|
+
height: var(--toolbar-height);
|
|
1705
|
+
background: white;
|
|
1706
|
+
border-bottom: 1px solid #e0ddd7;
|
|
1707
|
+
display: flex;
|
|
1708
|
+
align-items: center;
|
|
1709
|
+
padding: 0 16px;
|
|
1710
|
+
gap: 4px;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
.toolbar-btn {
|
|
1714
|
+
padding: 6px 12px;
|
|
1715
|
+
background: none;
|
|
1716
|
+
border: 1px solid transparent;
|
|
1717
|
+
border-radius: 6px;
|
|
1718
|
+
font-size: 13px;
|
|
1719
|
+
color: #555;
|
|
1720
|
+
cursor: pointer;
|
|
1721
|
+
transition: all 0.2s;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
.toolbar-btn:hover {
|
|
1725
|
+
background: #f0eee8;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
.toolbar-btn.active {
|
|
1729
|
+
background: var(--brand-primary);
|
|
1730
|
+
color: white;
|
|
1731
|
+
border-color: var(--brand-primary);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
.toolbar-separator {
|
|
1735
|
+
width: 1px;
|
|
1736
|
+
height: 24px;
|
|
1737
|
+
background: #ddd;
|
|
1738
|
+
margin: 0 8px;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/* Data type selector */
|
|
1742
|
+
.data-type-tabs {
|
|
1743
|
+
display: flex;
|
|
1744
|
+
gap: 4px;
|
|
1745
|
+
padding: 8px 16px;
|
|
1746
|
+
background: white;
|
|
1747
|
+
border-bottom: 1px solid #e0ddd7;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
.data-type-tab {
|
|
1751
|
+
padding: 6px 16px;
|
|
1752
|
+
border: 1px solid #ddd;
|
|
1753
|
+
border-radius: 6px;
|
|
1754
|
+
background: white;
|
|
1755
|
+
font-size: 13px;
|
|
1756
|
+
cursor: pointer;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
.data-type-tab.active {
|
|
1760
|
+
background: var(--brand-deep-green);
|
|
1761
|
+
color: white;
|
|
1762
|
+
border-color: var(--brand-deep-green);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/* Progress bar */
|
|
1766
|
+
.progress-bar-container {
|
|
1767
|
+
padding: 8px 16px;
|
|
1768
|
+
background: white;
|
|
1769
|
+
border-top: 1px solid #e0ddd7;
|
|
1770
|
+
display: flex;
|
|
1771
|
+
align-items: center;
|
|
1772
|
+
gap: 12px;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
.progress-track {
|
|
1776
|
+
flex: 1;
|
|
1777
|
+
height: 6px;
|
|
1778
|
+
background: #e0ddd7;
|
|
1779
|
+
border-radius: 3px;
|
|
1780
|
+
overflow: hidden;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
.progress-fill {
|
|
1784
|
+
height: 100%;
|
|
1785
|
+
background: var(--brand-green);
|
|
1786
|
+
border-radius: 3px;
|
|
1787
|
+
transition: width 0.3s;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
.progress-text {
|
|
1791
|
+
font-size: 12px;
|
|
1792
|
+
color: #888;
|
|
1793
|
+
white-space: nowrap;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/* Workspace */
|
|
1797
|
+
.workspace {
|
|
1798
|
+
display: flex;
|
|
1799
|
+
flex-direction: column;
|
|
1800
|
+
height: 100%;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
.workspace-main {
|
|
1804
|
+
flex: 1;
|
|
1805
|
+
padding: 16px;
|
|
1806
|
+
overflow: auto;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/* Labeler areas */
|
|
1810
|
+
.labeler-canvas {
|
|
1811
|
+
background: white;
|
|
1812
|
+
border: 2px dashed #ddd;
|
|
1813
|
+
border-radius: 8px;
|
|
1814
|
+
min-height: 400px;
|
|
1815
|
+
display: flex;
|
|
1816
|
+
align-items: center;
|
|
1817
|
+
justify-content: center;
|
|
1818
|
+
color: #888;
|
|
1819
|
+
position: relative;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
.labeler-sidebar {
|
|
1823
|
+
width: 240px;
|
|
1824
|
+
background: white;
|
|
1825
|
+
border-left: 1px solid #e0ddd7;
|
|
1826
|
+
padding: 16px;
|
|
1827
|
+
overflow-y: auto;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/* Dataset manager */
|
|
1831
|
+
.upload-zone {
|
|
1832
|
+
border: 2px dashed #ccc;
|
|
1833
|
+
border-radius: 12px;
|
|
1834
|
+
padding: 40px;
|
|
1835
|
+
text-align: center;
|
|
1836
|
+
color: #888;
|
|
1837
|
+
cursor: pointer;
|
|
1838
|
+
transition: all 0.2s;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
.upload-zone:hover {
|
|
1842
|
+
border-color: var(--brand-primary);
|
|
1843
|
+
color: var(--brand-primary);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
.dataset-list {
|
|
1847
|
+
margin-top: 24px;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
.dataset-item {
|
|
1851
|
+
background: white;
|
|
1852
|
+
padding: 16px;
|
|
1853
|
+
border-radius: 8px;
|
|
1854
|
+
margin-bottom: 8px;
|
|
1855
|
+
display: flex;
|
|
1856
|
+
justify-content: space-between;
|
|
1857
|
+
align-items: center;
|
|
1858
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/* Label schema */
|
|
1862
|
+
.schema-list {
|
|
1863
|
+
display: flex;
|
|
1864
|
+
flex-direction: column;
|
|
1865
|
+
gap: 8px;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
.schema-item {
|
|
1869
|
+
background: white;
|
|
1870
|
+
padding: 12px 16px;
|
|
1871
|
+
border-radius: 8px;
|
|
1872
|
+
display: flex;
|
|
1873
|
+
align-items: center;
|
|
1874
|
+
gap: 12px;
|
|
1875
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
.schema-color {
|
|
1879
|
+
width: 24px;
|
|
1880
|
+
height: 24px;
|
|
1881
|
+
border-radius: 4px;
|
|
1882
|
+
border: 1px solid #ddd;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
.schema-shortcut {
|
|
1886
|
+
font-size: 12px;
|
|
1887
|
+
background: #f0eee8;
|
|
1888
|
+
padding: 2px 8px;
|
|
1889
|
+
border-radius: 4px;
|
|
1890
|
+
font-family: monospace;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/* Export */
|
|
1894
|
+
.export-options {
|
|
1895
|
+
display: flex;
|
|
1896
|
+
gap: 12px;
|
|
1897
|
+
margin-bottom: 24px;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
.export-option {
|
|
1901
|
+
flex: 1;
|
|
1902
|
+
background: white;
|
|
1903
|
+
padding: 20px;
|
|
1904
|
+
border-radius: 12px;
|
|
1905
|
+
border: 2px solid transparent;
|
|
1906
|
+
cursor: pointer;
|
|
1907
|
+
text-align: center;
|
|
1908
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
1909
|
+
transition: border-color 0.2s;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
.export-option.selected {
|
|
1913
|
+
border-color: var(--brand-primary);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
.export-option h3 {
|
|
1917
|
+
margin-bottom: 4px;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
.export-option p {
|
|
1921
|
+
font-size: 13px;
|
|
1922
|
+
color: #888;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/* Keyboard shortcuts overlay */
|
|
1926
|
+
.shortcuts-overlay {
|
|
1927
|
+
position: fixed;
|
|
1928
|
+
inset: 0;
|
|
1929
|
+
background: rgba(0, 0, 0, 0.5);
|
|
1930
|
+
display: flex;
|
|
1931
|
+
align-items: center;
|
|
1932
|
+
justify-content: center;
|
|
1933
|
+
z-index: 200;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
.shortcuts-panel {
|
|
1937
|
+
background: white;
|
|
1938
|
+
border-radius: 12px;
|
|
1939
|
+
padding: 24px;
|
|
1940
|
+
max-width: 480px;
|
|
1941
|
+
width: 100%;
|
|
1942
|
+
max-height: 80vh;
|
|
1943
|
+
overflow-y: auto;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
.shortcut-row {
|
|
1947
|
+
display: flex;
|
|
1948
|
+
justify-content: space-between;
|
|
1949
|
+
padding: 6px 0;
|
|
1950
|
+
border-bottom: 1px solid #eee;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
.shortcut-key {
|
|
1954
|
+
font-family: monospace;
|
|
1955
|
+
background: #f0eee8;
|
|
1956
|
+
padding: 2px 8px;
|
|
1957
|
+
border-radius: 4px;
|
|
1958
|
+
font-size: 13px;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/* Login (shared) */
|
|
1962
|
+
.login-page {
|
|
1963
|
+
display: flex;
|
|
1964
|
+
align-items: center;
|
|
1965
|
+
justify-content: center;
|
|
1966
|
+
height: 100vh;
|
|
1967
|
+
background: var(--brand-dark-brown);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
.login-card {
|
|
1971
|
+
background: white;
|
|
1972
|
+
padding: 40px;
|
|
1973
|
+
border-radius: 16px;
|
|
1974
|
+
text-align: center;
|
|
1975
|
+
max-width: 400px;
|
|
1976
|
+
width: 100%;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
.login-logo h1 {
|
|
1980
|
+
color: var(--brand-primary);
|
|
1981
|
+
margin-bottom: 8px;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
.login-card p {
|
|
1985
|
+
color: #666;
|
|
1986
|
+
margin-bottom: 24px;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
.login-btn {
|
|
1990
|
+
width: 100%;
|
|
1991
|
+
padding: 12px;
|
|
1992
|
+
background: var(--brand-primary);
|
|
1993
|
+
color: white;
|
|
1994
|
+
border: none;
|
|
1995
|
+
border-radius: 8px;
|
|
1996
|
+
font-size: 16px;
|
|
1997
|
+
font-weight: 600;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/* Buttons shared */
|
|
2001
|
+
.btn {
|
|
2002
|
+
padding: 8px 20px;
|
|
2003
|
+
border: none;
|
|
2004
|
+
border-radius: 6px;
|
|
2005
|
+
font-size: 14px;
|
|
2006
|
+
font-weight: 600;
|
|
2007
|
+
cursor: pointer;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
.btn-primary {
|
|
2011
|
+
background: var(--brand-primary);
|
|
2012
|
+
color: white;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
.btn-secondary {
|
|
2016
|
+
background: #eee;
|
|
2017
|
+
color: #333;
|
|
2018
|
+
}
|
|
2019
|
+
`;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
function generateLabelingLayout() {
|
|
2023
|
+
return `import { NavLink, Outlet } from 'react-router-dom';
|
|
2024
|
+
|
|
2025
|
+
export default function Layout() {
|
|
2026
|
+
return (
|
|
2027
|
+
<div className="labeling-layout">
|
|
2028
|
+
<nav className="labeling-nav">
|
|
2029
|
+
<span className="nav-brand">MyVillageOS</span>
|
|
2030
|
+
<NavLink to="/" end>Workspace</NavLink>
|
|
2031
|
+
<NavLink to="/datasets">Datasets</NavLink>
|
|
2032
|
+
<NavLink to="/schema">Label Schema</NavLink>
|
|
2033
|
+
<NavLink to="/export">Export</NavLink>
|
|
2034
|
+
</nav>
|
|
2035
|
+
<div className="labeling-content">
|
|
2036
|
+
<Outlet />
|
|
2037
|
+
</div>
|
|
2038
|
+
</div>
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2041
|
+
`;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function generateToolbar() {
|
|
2045
|
+
return `import { useState } from 'react';
|
|
2046
|
+
|
|
2047
|
+
const TOOLS = [
|
|
2048
|
+
{ id: 'select', label: 'Select' },
|
|
2049
|
+
{ id: 'zoom', label: 'Zoom' },
|
|
2050
|
+
{ id: 'pan', label: 'Pan' },
|
|
2051
|
+
];
|
|
2052
|
+
|
|
2053
|
+
export default function Toolbar({ extraTools = [], activeTool, onToolChange }) {
|
|
2054
|
+
const allTools = [...TOOLS, ...extraTools];
|
|
2055
|
+
|
|
2056
|
+
return (
|
|
2057
|
+
<div className="toolbar">
|
|
2058
|
+
{allTools.map((tool, i) => (
|
|
2059
|
+
<span key={tool.id}>
|
|
2060
|
+
{i > 0 && tool.id === extraTools[0]?.id && <span className="toolbar-separator" />}
|
|
2061
|
+
<button
|
|
2062
|
+
className={\`toolbar-btn\${activeTool === tool.id ? ' active' : ''}\`}
|
|
2063
|
+
onClick={() => onToolChange(tool.id)}
|
|
2064
|
+
>
|
|
2065
|
+
{tool.label}
|
|
2066
|
+
</button>
|
|
2067
|
+
</span>
|
|
2068
|
+
))}
|
|
2069
|
+
</div>
|
|
2070
|
+
);
|
|
2071
|
+
}
|
|
2072
|
+
`;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
function generateDataTypeSelector(dataTypes) {
|
|
2076
|
+
const tabsArray = dataTypes.map((dt) => `'${dt}'`).join(', ');
|
|
2077
|
+
|
|
2078
|
+
return `const DATA_TYPES = [${tabsArray}];
|
|
2079
|
+
|
|
2080
|
+
export default function DataTypeSelector({ activeType, onTypeChange }) {
|
|
2081
|
+
return (
|
|
2082
|
+
<div className="data-type-tabs">
|
|
2083
|
+
{DATA_TYPES.map((type) => (
|
|
2084
|
+
<button
|
|
2085
|
+
key={type}
|
|
2086
|
+
className={\`data-type-tab\${activeType === type ? ' active' : ''}\`}
|
|
2087
|
+
onClick={() => onTypeChange(type)}
|
|
2088
|
+
>
|
|
2089
|
+
{type.charAt(0).toUpperCase() + type.slice(1)}
|
|
2090
|
+
</button>
|
|
2091
|
+
))}
|
|
2092
|
+
</div>
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
`;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function generateProgressBar() {
|
|
2099
|
+
return `export default function ProgressBar({ labeled, total }) {
|
|
2100
|
+
const pct = total > 0 ? Math.round((labeled / total) * 100) : 0;
|
|
2101
|
+
|
|
2102
|
+
return (
|
|
2103
|
+
<div className="progress-bar-container">
|
|
2104
|
+
<div className="progress-track">
|
|
2105
|
+
<div className="progress-fill" style={{ width: \`\${pct}%\` }} />
|
|
2106
|
+
</div>
|
|
2107
|
+
<span className="progress-text">{labeled} / {total} labeled ({pct}%)</span>
|
|
2108
|
+
</div>
|
|
2109
|
+
);
|
|
2110
|
+
}
|
|
2111
|
+
`;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function generateKeyboardShortcuts() {
|
|
2115
|
+
return `import { useEffect } from 'react';
|
|
2116
|
+
|
|
2117
|
+
export default function KeyboardShortcuts({ shortcuts, isOpen, onClose }) {
|
|
2118
|
+
useEffect(() => {
|
|
2119
|
+
const handler = (e) => {
|
|
2120
|
+
if (e.key === 'Escape') onClose();
|
|
2121
|
+
};
|
|
2122
|
+
if (isOpen) window.addEventListener('keydown', handler);
|
|
2123
|
+
return () => window.removeEventListener('keydown', handler);
|
|
2124
|
+
}, [isOpen, onClose]);
|
|
2125
|
+
|
|
2126
|
+
if (!isOpen) return null;
|
|
2127
|
+
|
|
2128
|
+
return (
|
|
2129
|
+
<div className="shortcuts-overlay" onClick={onClose}>
|
|
2130
|
+
<div className="shortcuts-panel" onClick={(e) => e.stopPropagation()}>
|
|
2131
|
+
<h2 style={{ marginBottom: '16px' }}>Keyboard Shortcuts</h2>
|
|
2132
|
+
{shortcuts.map((s) => (
|
|
2133
|
+
<div className="shortcut-row" key={s.key}>
|
|
2134
|
+
<span>{s.description}</span>
|
|
2135
|
+
<span className="shortcut-key">{s.key}</span>
|
|
2136
|
+
</div>
|
|
2137
|
+
))}
|
|
2138
|
+
<p style={{ marginTop: '16px', fontSize: '13px', color: '#888', textAlign: 'center' }}>
|
|
2139
|
+
Press <strong>?</strong> to toggle this panel
|
|
2140
|
+
</p>
|
|
2141
|
+
</div>
|
|
2142
|
+
</div>
|
|
2143
|
+
);
|
|
2144
|
+
}
|
|
2145
|
+
`;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function generateProjectContext() {
|
|
2149
|
+
return `import { createContext, useContext, useReducer } from 'react';
|
|
2150
|
+
|
|
2151
|
+
const ProjectContext = createContext(null);
|
|
2152
|
+
|
|
2153
|
+
const initialState = {
|
|
2154
|
+
dataset: [],
|
|
2155
|
+
currentIndex: 0,
|
|
2156
|
+
labels: {},
|
|
2157
|
+
schema: [
|
|
2158
|
+
{ id: '1', name: 'Object', color: '#FFD700', shortcut: '1' },
|
|
2159
|
+
{ id: '2', name: 'Background', color: '#228B22', shortcut: '2' },
|
|
2160
|
+
],
|
|
2161
|
+
progress: { labeled: 0, total: 0 },
|
|
2162
|
+
};
|
|
2163
|
+
|
|
2164
|
+
function reducer(state, action) {
|
|
2165
|
+
switch (action.type) {
|
|
2166
|
+
case 'NEXT_ITEM':
|
|
2167
|
+
return { ...state, currentIndex: Math.min(state.currentIndex + 1, state.dataset.length - 1) };
|
|
2168
|
+
case 'PREV_ITEM':
|
|
2169
|
+
return { ...state, currentIndex: Math.max(state.currentIndex - 1, 0) };
|
|
2170
|
+
case 'ADD_LABEL': {
|
|
2171
|
+
const itemId = state.dataset[state.currentIndex]?.id;
|
|
2172
|
+
if (!itemId) return state;
|
|
2173
|
+
const existing = state.labels[itemId] || [];
|
|
2174
|
+
return {
|
|
2175
|
+
...state,
|
|
2176
|
+
labels: { ...state.labels, [itemId]: [...existing, action.label] },
|
|
2177
|
+
progress: { ...state.progress, labeled: Object.keys({ ...state.labels, [itemId]: true }).length },
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
case 'REMOVE_LABEL': {
|
|
2181
|
+
const id = state.dataset[state.currentIndex]?.id;
|
|
2182
|
+
if (!id) return state;
|
|
2183
|
+
const items = (state.labels[id] || []).filter((_, i) => i !== action.index);
|
|
2184
|
+
return { ...state, labels: { ...state.labels, [id]: items } };
|
|
2185
|
+
}
|
|
2186
|
+
case 'UPDATE_SCHEMA':
|
|
2187
|
+
return { ...state, schema: action.schema };
|
|
2188
|
+
case 'SET_DATASET':
|
|
2189
|
+
return { ...state, dataset: action.dataset, currentIndex: 0, progress: { labeled: 0, total: action.dataset.length } };
|
|
2190
|
+
default:
|
|
2191
|
+
return state;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
export function ProjectProvider({ children }) {
|
|
2196
|
+
const [state, dispatch] = useReducer(reducer, initialState);
|
|
2197
|
+
|
|
2198
|
+
const actions = {
|
|
2199
|
+
nextItem: () => dispatch({ type: 'NEXT_ITEM' }),
|
|
2200
|
+
prevItem: () => dispatch({ type: 'PREV_ITEM' }),
|
|
2201
|
+
addLabel: (label) => dispatch({ type: 'ADD_LABEL', label }),
|
|
2202
|
+
removeLabel: (index) => dispatch({ type: 'REMOVE_LABEL', index }),
|
|
2203
|
+
updateSchema: (schema) => dispatch({ type: 'UPDATE_SCHEMA', schema }),
|
|
2204
|
+
setDataset: (dataset) => dispatch({ type: 'SET_DATASET', dataset }),
|
|
2205
|
+
};
|
|
2206
|
+
|
|
2207
|
+
return (
|
|
2208
|
+
<ProjectContext.Provider value={{ ...state, ...actions }}>
|
|
2209
|
+
{children}
|
|
2210
|
+
</ProjectContext.Provider>
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
export function useProject() {
|
|
2215
|
+
const ctx = useContext(ProjectContext);
|
|
2216
|
+
if (!ctx) throw new Error('useProject must be used within ProjectProvider');
|
|
2217
|
+
return ctx;
|
|
2218
|
+
}
|
|
2219
|
+
`;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// ─── Labelers ───────────────────────────────────────────────────────────────
|
|
2223
|
+
|
|
2224
|
+
function generateImageLabeler() {
|
|
2225
|
+
return `import { useRef, useState, useEffect } from 'react';
|
|
2226
|
+
|
|
2227
|
+
export default function ImageLabeler({ item, labels, schema, onAddLabel }) {
|
|
2228
|
+
const canvasRef = useRef(null);
|
|
2229
|
+
const [drawing, setDrawing] = useState(false);
|
|
2230
|
+
const [startPos, setStartPos] = useState(null);
|
|
2231
|
+
const [activeClass, setActiveClass] = useState(schema[0]?.id || null);
|
|
2232
|
+
|
|
2233
|
+
useEffect(() => {
|
|
2234
|
+
const canvas = canvasRef.current;
|
|
2235
|
+
if (!canvas) return;
|
|
2236
|
+
const ctx = canvas.getContext('2d');
|
|
2237
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
2238
|
+
|
|
2239
|
+
// Draw placeholder background
|
|
2240
|
+
ctx.fillStyle = '#f5f3ef';
|
|
2241
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
2242
|
+
ctx.fillStyle = '#ccc';
|
|
2243
|
+
ctx.font = '16px sans-serif';
|
|
2244
|
+
ctx.textAlign = 'center';
|
|
2245
|
+
ctx.fillText(item?.name || 'No image loaded', canvas.width / 2, canvas.height / 2);
|
|
2246
|
+
|
|
2247
|
+
// Draw existing labels as rectangles
|
|
2248
|
+
if (labels) {
|
|
2249
|
+
labels.forEach((label) => {
|
|
2250
|
+
const schemaItem = schema.find((s) => s.id === label.classId);
|
|
2251
|
+
ctx.strokeStyle = schemaItem?.color || '#FFD700';
|
|
2252
|
+
ctx.lineWidth = 2;
|
|
2253
|
+
ctx.strokeRect(label.x, label.y, label.width, label.height);
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
}, [item, labels, schema]);
|
|
2257
|
+
|
|
2258
|
+
const handleMouseDown = (e) => {
|
|
2259
|
+
const rect = canvasRef.current.getBoundingClientRect();
|
|
2260
|
+
setStartPos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
|
2261
|
+
setDrawing(true);
|
|
2262
|
+
};
|
|
2263
|
+
|
|
2264
|
+
const handleMouseUp = (e) => {
|
|
2265
|
+
if (!drawing || !startPos) return;
|
|
2266
|
+
const rect = canvasRef.current.getBoundingClientRect();
|
|
2267
|
+
const endX = e.clientX - rect.left;
|
|
2268
|
+
const endY = e.clientY - rect.top;
|
|
2269
|
+
|
|
2270
|
+
const box = {
|
|
2271
|
+
classId: activeClass,
|
|
2272
|
+
x: Math.min(startPos.x, endX),
|
|
2273
|
+
y: Math.min(startPos.y, endY),
|
|
2274
|
+
width: Math.abs(endX - startPos.x),
|
|
2275
|
+
height: Math.abs(endY - startPos.y),
|
|
2276
|
+
};
|
|
2277
|
+
|
|
2278
|
+
if (box.width > 5 && box.height > 5) {
|
|
2279
|
+
onAddLabel(box);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
setDrawing(false);
|
|
2283
|
+
setStartPos(null);
|
|
2284
|
+
};
|
|
2285
|
+
|
|
2286
|
+
return (
|
|
2287
|
+
<div style={{ display: 'flex', gap: '16px' }}>
|
|
2288
|
+
<div style={{ flex: 1 }}>
|
|
2289
|
+
<canvas
|
|
2290
|
+
ref={canvasRef}
|
|
2291
|
+
width={640}
|
|
2292
|
+
height={480}
|
|
2293
|
+
style={{ border: '1px solid #ddd', borderRadius: '8px', cursor: 'crosshair', maxWidth: '100%' }}
|
|
2294
|
+
onMouseDown={handleMouseDown}
|
|
2295
|
+
onMouseUp={handleMouseUp}
|
|
2296
|
+
/>
|
|
2297
|
+
</div>
|
|
2298
|
+
<div className="labeler-sidebar">
|
|
2299
|
+
<h3 style={{ marginBottom: '12px', fontSize: '14px' }}>Label Classes</h3>
|
|
2300
|
+
{schema.map((cls) => (
|
|
2301
|
+
<button
|
|
2302
|
+
key={cls.id}
|
|
2303
|
+
onClick={() => setActiveClass(cls.id)}
|
|
2304
|
+
style={{
|
|
2305
|
+
display: 'flex', alignItems: 'center', gap: '8px', width: '100%',
|
|
2306
|
+
padding: '8px', marginBottom: '4px', border: activeClass === cls.id ? '2px solid #333' : '1px solid #ddd',
|
|
2307
|
+
borderRadius: '6px', background: 'white', cursor: 'pointer',
|
|
2308
|
+
}}
|
|
2309
|
+
>
|
|
2310
|
+
<span style={{ width: '16px', height: '16px', borderRadius: '3px', background: cls.color }} />
|
|
2311
|
+
{cls.name}
|
|
2312
|
+
</button>
|
|
2313
|
+
))}
|
|
2314
|
+
</div>
|
|
2315
|
+
</div>
|
|
2316
|
+
);
|
|
2317
|
+
}
|
|
2318
|
+
`;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
function generateTextLabeler() {
|
|
2322
|
+
return `import { useState } from 'react';
|
|
2323
|
+
|
|
2324
|
+
export default function TextLabeler({ item, labels, schema, onAddLabel }) {
|
|
2325
|
+
const [activeClass, setActiveClass] = useState(schema[0]?.id || null);
|
|
2326
|
+
const text = item?.content || 'No text loaded. Upload a dataset to get started.';
|
|
2327
|
+
|
|
2328
|
+
const handleTextSelect = () => {
|
|
2329
|
+
const selection = window.getSelection();
|
|
2330
|
+
if (!selection || selection.isCollapsed) return;
|
|
2331
|
+
|
|
2332
|
+
const range = selection.getRangeAt(0);
|
|
2333
|
+
const start = range.startOffset;
|
|
2334
|
+
const end = range.endOffset;
|
|
2335
|
+
const selectedText = selection.toString();
|
|
2336
|
+
|
|
2337
|
+
if (selectedText.trim()) {
|
|
2338
|
+
onAddLabel({
|
|
2339
|
+
classId: activeClass,
|
|
2340
|
+
start,
|
|
2341
|
+
end,
|
|
2342
|
+
text: selectedText,
|
|
2343
|
+
});
|
|
2344
|
+
selection.removeAllRanges();
|
|
2345
|
+
}
|
|
2346
|
+
};
|
|
2347
|
+
|
|
2348
|
+
// Build highlighted text
|
|
2349
|
+
const renderText = () => {
|
|
2350
|
+
if (!labels || labels.length === 0) return text;
|
|
2351
|
+
|
|
2352
|
+
const sorted = [...labels].sort((a, b) => a.start - b.start);
|
|
2353
|
+
const parts = [];
|
|
2354
|
+
let lastEnd = 0;
|
|
2355
|
+
|
|
2356
|
+
sorted.forEach((label, i) => {
|
|
2357
|
+
if (label.start > lastEnd) {
|
|
2358
|
+
parts.push(<span key={\`t-\${i}\`}>{text.slice(lastEnd, label.start)}</span>);
|
|
2359
|
+
}
|
|
2360
|
+
const cls = schema.find((s) => s.id === label.classId);
|
|
2361
|
+
parts.push(
|
|
2362
|
+
<span key={\`l-\${i}\`} style={{ background: cls?.color + '40', borderBottom: \`2px solid \${cls?.color || '#FFD700'}\`, padding: '0 2px' }}>
|
|
2363
|
+
{text.slice(label.start, label.end)}
|
|
2364
|
+
</span>
|
|
2365
|
+
);
|
|
2366
|
+
lastEnd = label.end;
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
if (lastEnd < text.length) {
|
|
2370
|
+
parts.push(<span key="end">{text.slice(lastEnd)}</span>);
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
return parts;
|
|
2374
|
+
};
|
|
2375
|
+
|
|
2376
|
+
return (
|
|
2377
|
+
<div style={{ display: 'flex', gap: '16px' }}>
|
|
2378
|
+
<div style={{ flex: 1 }}>
|
|
2379
|
+
<div
|
|
2380
|
+
onMouseUp={handleTextSelect}
|
|
2381
|
+
style={{
|
|
2382
|
+
background: 'white', padding: '24px', borderRadius: '8px', border: '1px solid #ddd',
|
|
2383
|
+
lineHeight: '1.8', fontSize: '15px', minHeight: '300px', cursor: 'text', userSelect: 'text',
|
|
2384
|
+
}}
|
|
2385
|
+
>
|
|
2386
|
+
{renderText()}
|
|
2387
|
+
</div>
|
|
2388
|
+
</div>
|
|
2389
|
+
<div className="labeler-sidebar">
|
|
2390
|
+
<h3 style={{ marginBottom: '12px', fontSize: '14px' }}>Entity Types</h3>
|
|
2391
|
+
{schema.map((cls) => (
|
|
2392
|
+
<button
|
|
2393
|
+
key={cls.id}
|
|
2394
|
+
onClick={() => setActiveClass(cls.id)}
|
|
2395
|
+
style={{
|
|
2396
|
+
display: 'flex', alignItems: 'center', gap: '8px', width: '100%',
|
|
2397
|
+
padding: '8px', marginBottom: '4px', border: activeClass === cls.id ? '2px solid #333' : '1px solid #ddd',
|
|
2398
|
+
borderRadius: '6px', background: 'white', cursor: 'pointer',
|
|
2399
|
+
}}
|
|
2400
|
+
>
|
|
2401
|
+
<span style={{ width: '16px', height: '16px', borderRadius: '3px', background: cls.color }} />
|
|
2402
|
+
{cls.name}
|
|
2403
|
+
</button>
|
|
2404
|
+
))}
|
|
2405
|
+
<p style={{ marginTop: '12px', fontSize: '12px', color: '#888' }}>
|
|
2406
|
+
Select text to create an annotation
|
|
2407
|
+
</p>
|
|
2408
|
+
</div>
|
|
2409
|
+
</div>
|
|
2410
|
+
);
|
|
2411
|
+
}
|
|
2412
|
+
`;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function generateAudioLabeler() {
|
|
2416
|
+
return `import { useState } from 'react';
|
|
2417
|
+
|
|
2418
|
+
export default function AudioLabeler({ item, labels, schema, onAddLabel }) {
|
|
2419
|
+
const [activeClass, setActiveClass] = useState(schema[0]?.id || null);
|
|
2420
|
+
const [transcription, setTranscription] = useState('');
|
|
2421
|
+
|
|
2422
|
+
const addSegment = () => {
|
|
2423
|
+
onAddLabel({
|
|
2424
|
+
classId: activeClass,
|
|
2425
|
+
start: 0,
|
|
2426
|
+
end: 5,
|
|
2427
|
+
transcription,
|
|
2428
|
+
});
|
|
2429
|
+
setTranscription('');
|
|
2430
|
+
};
|
|
2431
|
+
|
|
2432
|
+
return (
|
|
2433
|
+
<div style={{ display: 'flex', gap: '16px' }}>
|
|
2434
|
+
<div style={{ flex: 1 }}>
|
|
2435
|
+
<div className="labeler-canvas">
|
|
2436
|
+
<div style={{ textAlign: 'center' }}>
|
|
2437
|
+
<p style={{ marginBottom: '8px' }}>Waveform Display</p>
|
|
2438
|
+
<div style={{
|
|
2439
|
+
width: '100%', maxWidth: '600px', height: '120px', background: '#f0eee8',
|
|
2440
|
+
borderRadius: '8px', margin: '0 auto 16px',
|
|
2441
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#aaa',
|
|
2442
|
+
}}>
|
|
2443
|
+
{item?.name || 'No audio loaded'}
|
|
2444
|
+
</div>
|
|
2445
|
+
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
|
2446
|
+
<button className="btn btn-secondary">◀◀</button>
|
|
2447
|
+
<button className="btn btn-primary">▶ Play</button>
|
|
2448
|
+
<button className="btn btn-secondary">▶▶</button>
|
|
2449
|
+
</div>
|
|
2450
|
+
</div>
|
|
2451
|
+
</div>
|
|
2452
|
+
|
|
2453
|
+
<div style={{ marginTop: '16px' }}>
|
|
2454
|
+
<h3 style={{ fontSize: '14px', marginBottom: '8px' }}>Transcription</h3>
|
|
2455
|
+
<textarea
|
|
2456
|
+
value={transcription}
|
|
2457
|
+
onChange={(e) => setTranscription(e.target.value)}
|
|
2458
|
+
placeholder="Type transcription for the current segment..."
|
|
2459
|
+
style={{ width: '100%', minHeight: '80px', padding: '12px', border: '1px solid #ddd', borderRadius: '8px', fontSize: '14px', resize: 'vertical' }}
|
|
2460
|
+
/>
|
|
2461
|
+
<button className="btn btn-primary" style={{ marginTop: '8px' }} onClick={addSegment}>
|
|
2462
|
+
Add Segment
|
|
2463
|
+
</button>
|
|
2464
|
+
</div>
|
|
2465
|
+
</div>
|
|
2466
|
+
<div className="labeler-sidebar">
|
|
2467
|
+
<h3 style={{ marginBottom: '12px', fontSize: '14px' }}>Segment Labels</h3>
|
|
2468
|
+
{schema.map((cls) => (
|
|
2469
|
+
<button
|
|
2470
|
+
key={cls.id}
|
|
2471
|
+
onClick={() => setActiveClass(cls.id)}
|
|
2472
|
+
style={{
|
|
2473
|
+
display: 'flex', alignItems: 'center', gap: '8px', width: '100%',
|
|
2474
|
+
padding: '8px', marginBottom: '4px', border: activeClass === cls.id ? '2px solid #333' : '1px solid #ddd',
|
|
2475
|
+
borderRadius: '6px', background: 'white', cursor: 'pointer',
|
|
2476
|
+
}}
|
|
2477
|
+
>
|
|
2478
|
+
<span style={{ width: '16px', height: '16px', borderRadius: '3px', background: cls.color }} />
|
|
2479
|
+
{cls.name}
|
|
2480
|
+
</button>
|
|
2481
|
+
))}
|
|
2482
|
+
</div>
|
|
2483
|
+
</div>
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
`;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
function generateVideoLabeler() {
|
|
2490
|
+
return `import { useState } from 'react';
|
|
2491
|
+
|
|
2492
|
+
export default function VideoLabeler({ item, labels, schema, onAddLabel }) {
|
|
2493
|
+
const [activeClass, setActiveClass] = useState(schema[0]?.id || null);
|
|
2494
|
+
const [currentFrame, setCurrentFrame] = useState(0);
|
|
2495
|
+
const totalFrames = 300;
|
|
2496
|
+
|
|
2497
|
+
const addAnnotation = () => {
|
|
2498
|
+
onAddLabel({
|
|
2499
|
+
classId: activeClass,
|
|
2500
|
+
frame: currentFrame,
|
|
2501
|
+
type: 'frame-label',
|
|
2502
|
+
});
|
|
2503
|
+
};
|
|
2504
|
+
|
|
2505
|
+
return (
|
|
2506
|
+
<div style={{ display: 'flex', gap: '16px' }}>
|
|
2507
|
+
<div style={{ flex: 1 }}>
|
|
2508
|
+
<div className="labeler-canvas">
|
|
2509
|
+
<div style={{ textAlign: 'center' }}>
|
|
2510
|
+
<p>{item?.name || 'No video loaded'}</p>
|
|
2511
|
+
<p style={{ fontSize: '13px', marginTop: '4px' }}>Frame {currentFrame} / {totalFrames}</p>
|
|
2512
|
+
</div>
|
|
2513
|
+
</div>
|
|
2514
|
+
|
|
2515
|
+
<div style={{ marginTop: '12px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
2516
|
+
<button className="btn btn-secondary" onClick={() => setCurrentFrame(Math.max(0, currentFrame - 1))}>
|
|
2517
|
+
◀ Prev
|
|
2518
|
+
</button>
|
|
2519
|
+
<input
|
|
2520
|
+
type="range"
|
|
2521
|
+
min="0"
|
|
2522
|
+
max={totalFrames}
|
|
2523
|
+
value={currentFrame}
|
|
2524
|
+
onChange={(e) => setCurrentFrame(Number(e.target.value))}
|
|
2525
|
+
style={{ flex: 1 }}
|
|
2526
|
+
/>
|
|
2527
|
+
<button className="btn btn-secondary" onClick={() => setCurrentFrame(Math.min(totalFrames, currentFrame + 1))}>
|
|
2528
|
+
Next ▶
|
|
2529
|
+
</button>
|
|
2530
|
+
</div>
|
|
2531
|
+
|
|
2532
|
+
<div style={{ marginTop: '12px', display: 'flex', gap: '8px' }}>
|
|
2533
|
+
<button className="btn btn-primary" onClick={addAnnotation}>Label Frame</button>
|
|
2534
|
+
</div>
|
|
2535
|
+
|
|
2536
|
+
<div style={{ marginTop: '16px', background: 'white', borderRadius: '8px', padding: '12px', border: '1px solid #ddd' }}>
|
|
2537
|
+
<h4 style={{ fontSize: '13px', marginBottom: '8px' }}>Timeline</h4>
|
|
2538
|
+
<div style={{ height: '32px', background: '#f0eee8', borderRadius: '4px', position: 'relative' }}>
|
|
2539
|
+
{labels && labels.map((label, i) => {
|
|
2540
|
+
const cls = schema.find((s) => s.id === label.classId);
|
|
2541
|
+
const left = (label.frame / totalFrames) * 100;
|
|
2542
|
+
return (
|
|
2543
|
+
<div
|
|
2544
|
+
key={i}
|
|
2545
|
+
style={{
|
|
2546
|
+
position: 'absolute', left: \`\${left}%\`, top: '4px',
|
|
2547
|
+
width: '4px', height: '24px', background: cls?.color || '#FFD700',
|
|
2548
|
+
borderRadius: '2px',
|
|
2549
|
+
}}
|
|
2550
|
+
/>
|
|
2551
|
+
);
|
|
2552
|
+
})}
|
|
2553
|
+
<div
|
|
2554
|
+
style={{
|
|
2555
|
+
position: 'absolute', left: \`\${(currentFrame / totalFrames) * 100}%\`, top: 0,
|
|
2556
|
+
width: '2px', height: '100%', background: '#333',
|
|
2557
|
+
}}
|
|
2558
|
+
/>
|
|
2559
|
+
</div>
|
|
2560
|
+
</div>
|
|
2561
|
+
</div>
|
|
2562
|
+
<div className="labeler-sidebar">
|
|
2563
|
+
<h3 style={{ marginBottom: '12px', fontSize: '14px' }}>Frame Labels</h3>
|
|
2564
|
+
{schema.map((cls) => (
|
|
2565
|
+
<button
|
|
2566
|
+
key={cls.id}
|
|
2567
|
+
onClick={() => setActiveClass(cls.id)}
|
|
2568
|
+
style={{
|
|
2569
|
+
display: 'flex', alignItems: 'center', gap: '8px', width: '100%',
|
|
2570
|
+
padding: '8px', marginBottom: '4px', border: activeClass === cls.id ? '2px solid #333' : '1px solid #ddd',
|
|
2571
|
+
borderRadius: '6px', background: 'white', cursor: 'pointer',
|
|
2572
|
+
}}
|
|
2573
|
+
>
|
|
2574
|
+
<span style={{ width: '16px', height: '16px', borderRadius: '3px', background: cls.color }} />
|
|
2575
|
+
{cls.name}
|
|
2576
|
+
</button>
|
|
2577
|
+
))}
|
|
2578
|
+
</div>
|
|
2579
|
+
</div>
|
|
2580
|
+
);
|
|
2581
|
+
}
|
|
2582
|
+
`;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
// ─── Data Labeling Pages ────────────────────────────────────────────────────
|
|
2586
|
+
|
|
2587
|
+
function generateWorkspacePage(dataTypes) {
|
|
2588
|
+
const labelerImports = [];
|
|
2589
|
+
const labelerCases = [];
|
|
2590
|
+
|
|
2591
|
+
if (dataTypes.includes('image')) {
|
|
2592
|
+
labelerImports.push(`import ImageLabeler from '../labelers/ImageLabeler.jsx';`);
|
|
2593
|
+
labelerCases.push(` case 'image': return <ImageLabeler item={currentItem} labels={currentLabels} schema={schema} onAddLabel={addLabel} />;`);
|
|
2594
|
+
}
|
|
2595
|
+
if (dataTypes.includes('text')) {
|
|
2596
|
+
labelerImports.push(`import TextLabeler from '../labelers/TextLabeler.jsx';`);
|
|
2597
|
+
labelerCases.push(` case 'text': return <TextLabeler item={currentItem} labels={currentLabels} schema={schema} onAddLabel={addLabel} />;`);
|
|
2598
|
+
}
|
|
2599
|
+
if (dataTypes.includes('audio')) {
|
|
2600
|
+
labelerImports.push(`import AudioLabeler from '../labelers/AudioLabeler.jsx';`);
|
|
2601
|
+
labelerCases.push(` case 'audio': return <AudioLabeler item={currentItem} labels={currentLabels} schema={schema} onAddLabel={addLabel} />;`);
|
|
2602
|
+
}
|
|
2603
|
+
if (dataTypes.includes('video')) {
|
|
2604
|
+
labelerImports.push(`import VideoLabeler from '../labelers/VideoLabeler.jsx';`);
|
|
2605
|
+
labelerCases.push(` case 'video': return <VideoLabeler item={currentItem} labels={currentLabels} schema={schema} onAddLabel={addLabel} />;`);
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
const defaultType = dataTypes[0];
|
|
2609
|
+
|
|
2610
|
+
return `import { useState, useEffect, useCallback } from 'react';
|
|
2611
|
+
import { useProject } from '../context/ProjectContext.jsx';
|
|
2612
|
+
import Toolbar from '../components/Toolbar.jsx';
|
|
2613
|
+
import DataTypeSelector from '../components/DataTypeSelector.jsx';
|
|
2614
|
+
import ProgressBar from '../components/ProgressBar.jsx';
|
|
2615
|
+
import KeyboardShortcuts from '../components/KeyboardShortcuts.jsx';
|
|
2616
|
+
import { getShortcuts } from '../utils/shortcuts.js';
|
|
2617
|
+
${labelerImports.join('\n')}
|
|
2618
|
+
|
|
2619
|
+
export default function Workspace() {
|
|
2620
|
+
const { dataset, currentIndex, labels, schema, progress, addLabel, nextItem, prevItem } = useProject();
|
|
2621
|
+
const [activeType, setActiveType] = useState('${defaultType}');
|
|
2622
|
+
const [activeTool, setActiveTool] = useState('select');
|
|
2623
|
+
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
2624
|
+
|
|
2625
|
+
const currentItem = dataset[currentIndex] || null;
|
|
2626
|
+
const currentLabels = currentItem ? (labels[currentItem.id] || []) : [];
|
|
2627
|
+
const shortcuts = getShortcuts(activeType);
|
|
2628
|
+
|
|
2629
|
+
useEffect(() => {
|
|
2630
|
+
const handler = (e) => {
|
|
2631
|
+
if (e.key === '?') setShowShortcuts((v) => !v);
|
|
2632
|
+
if (e.key === 'ArrowRight') nextItem();
|
|
2633
|
+
if (e.key === 'ArrowLeft') prevItem();
|
|
2634
|
+
};
|
|
2635
|
+
window.addEventListener('keydown', handler);
|
|
2636
|
+
return () => window.removeEventListener('keydown', handler);
|
|
2637
|
+
}, [nextItem, prevItem]);
|
|
2638
|
+
|
|
2639
|
+
const renderLabeler = () => {
|
|
2640
|
+
switch (activeType) {
|
|
2641
|
+
${labelerCases.join('\n')}
|
|
2642
|
+
default: return <div className="labeler-canvas"><p>Select a data type</p></div>;
|
|
2643
|
+
}
|
|
2644
|
+
};
|
|
2645
|
+
|
|
2646
|
+
return (
|
|
2647
|
+
<div className="workspace">
|
|
2648
|
+
<DataTypeSelector activeType={activeType} onTypeChange={setActiveType} />
|
|
2649
|
+
<Toolbar activeTool={activeTool} onToolChange={setActiveTool} />
|
|
2650
|
+
<div className="workspace-main">
|
|
2651
|
+
{renderLabeler()}
|
|
2652
|
+
</div>
|
|
2653
|
+
<ProgressBar labeled={progress.labeled} total={progress.total} />
|
|
2654
|
+
<KeyboardShortcuts shortcuts={shortcuts} isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
|
|
2655
|
+
</div>
|
|
2656
|
+
);
|
|
2657
|
+
}
|
|
2658
|
+
`;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
function generateDatasetManagerPage() {
|
|
2662
|
+
return `import { useState } from 'react';
|
|
2663
|
+
import { useProject } from '../context/ProjectContext.jsx';
|
|
2664
|
+
|
|
2665
|
+
export default function DatasetManager() {
|
|
2666
|
+
const { dataset, setDataset } = useProject();
|
|
2667
|
+
const [dragOver, setDragOver] = useState(false);
|
|
2668
|
+
|
|
2669
|
+
const handleDrop = (e) => {
|
|
2670
|
+
e.preventDefault();
|
|
2671
|
+
setDragOver(false);
|
|
2672
|
+
const files = Array.from(e.dataTransfer.files);
|
|
2673
|
+
const items = files.map((f, i) => ({
|
|
2674
|
+
id: \`item-\${Date.now()}-\${i}\`,
|
|
2675
|
+
name: f.name,
|
|
2676
|
+
type: f.type,
|
|
2677
|
+
size: f.size,
|
|
2678
|
+
}));
|
|
2679
|
+
setDataset([...dataset, ...items]);
|
|
2680
|
+
};
|
|
2681
|
+
|
|
2682
|
+
const handleDelete = (id) => {
|
|
2683
|
+
setDataset(dataset.filter((d) => d.id !== id));
|
|
2684
|
+
};
|
|
2685
|
+
|
|
2686
|
+
return (
|
|
2687
|
+
<div style={{ padding: '24px' }}>
|
|
2688
|
+
<h2 style={{ marginBottom: '20px' }}>Dataset Manager</h2>
|
|
2689
|
+
|
|
2690
|
+
<div
|
|
2691
|
+
className="upload-zone"
|
|
2692
|
+
style={{ borderColor: dragOver ? '#B07C00' : undefined }}
|
|
2693
|
+
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
|
2694
|
+
onDragLeave={() => setDragOver(false)}
|
|
2695
|
+
onDrop={handleDrop}
|
|
2696
|
+
>
|
|
2697
|
+
<p style={{ fontSize: '18px', marginBottom: '8px' }}>Drop files here to upload</p>
|
|
2698
|
+
<p>or click to browse</p>
|
|
2699
|
+
</div>
|
|
2700
|
+
|
|
2701
|
+
<div className="dataset-list">
|
|
2702
|
+
<h3 style={{ margin: '16px 0' }}>Items ({dataset.length})</h3>
|
|
2703
|
+
{dataset.length === 0 && <p style={{ color: '#888' }}>No items in dataset. Upload files to get started.</p>}
|
|
2704
|
+
{dataset.map((item) => (
|
|
2705
|
+
<div className="dataset-item" key={item.id}>
|
|
2706
|
+
<div>
|
|
2707
|
+
<strong>{item.name}</strong>
|
|
2708
|
+
<span style={{ marginLeft: '12px', color: '#888', fontSize: '13px' }}>
|
|
2709
|
+
{item.type} - {(item.size / 1024).toFixed(1)} KB
|
|
2710
|
+
</span>
|
|
2711
|
+
</div>
|
|
2712
|
+
<button className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 10px' }} onClick={() => handleDelete(item.id)}>
|
|
2713
|
+
Delete
|
|
2714
|
+
</button>
|
|
2715
|
+
</div>
|
|
2716
|
+
))}
|
|
2717
|
+
</div>
|
|
2718
|
+
</div>
|
|
2719
|
+
);
|
|
2720
|
+
}
|
|
2721
|
+
`;
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
function generateLabelSchemaPage() {
|
|
2725
|
+
return `import { useState } from 'react';
|
|
2726
|
+
import { useProject } from '../context/ProjectContext.jsx';
|
|
2727
|
+
|
|
2728
|
+
export default function LabelSchema() {
|
|
2729
|
+
const { schema, updateSchema } = useProject();
|
|
2730
|
+
const [newName, setNewName] = useState('');
|
|
2731
|
+
const [newColor, setNewColor] = useState('#FFD700');
|
|
2732
|
+
const [newShortcut, setNewShortcut] = useState('');
|
|
2733
|
+
|
|
2734
|
+
const addClass = () => {
|
|
2735
|
+
if (!newName.trim()) return;
|
|
2736
|
+
const item = {
|
|
2737
|
+
id: String(Date.now()),
|
|
2738
|
+
name: newName.trim(),
|
|
2739
|
+
color: newColor,
|
|
2740
|
+
shortcut: newShortcut || null,
|
|
2741
|
+
};
|
|
2742
|
+
updateSchema([...schema, item]);
|
|
2743
|
+
setNewName('');
|
|
2744
|
+
setNewShortcut('');
|
|
2745
|
+
};
|
|
2746
|
+
|
|
2747
|
+
const removeClass = (id) => {
|
|
2748
|
+
updateSchema(schema.filter((s) => s.id !== id));
|
|
2749
|
+
};
|
|
2750
|
+
|
|
2751
|
+
return (
|
|
2752
|
+
<div style={{ padding: '24px' }}>
|
|
2753
|
+
<h2 style={{ marginBottom: '20px' }}>Label Schema</h2>
|
|
2754
|
+
|
|
2755
|
+
<div className="schema-list">
|
|
2756
|
+
{schema.map((item) => (
|
|
2757
|
+
<div className="schema-item" key={item.id}>
|
|
2758
|
+
<span className="schema-color" style={{ background: item.color }} />
|
|
2759
|
+
<span style={{ flex: 1 }}>{item.name}</span>
|
|
2760
|
+
{item.shortcut && <span className="schema-shortcut">{item.shortcut}</span>}
|
|
2761
|
+
<button className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 10px' }} onClick={() => removeClass(item.id)}>
|
|
2762
|
+
Remove
|
|
2763
|
+
</button>
|
|
2764
|
+
</div>
|
|
2765
|
+
))}
|
|
2766
|
+
</div>
|
|
2767
|
+
|
|
2768
|
+
<div style={{ marginTop: '24px', background: 'white', borderRadius: '12px', padding: '20px', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
|
|
2769
|
+
<h3 style={{ marginBottom: '12px' }}>Add Label Class</h3>
|
|
2770
|
+
<div style={{ display: 'flex', gap: '12px', alignItems: 'end', flexWrap: 'wrap' }}>
|
|
2771
|
+
<div className="form-group" style={{ marginBottom: 0 }}>
|
|
2772
|
+
<label>Name</label>
|
|
2773
|
+
<input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="e.g. Person" />
|
|
2774
|
+
</div>
|
|
2775
|
+
<div className="form-group" style={{ marginBottom: 0 }}>
|
|
2776
|
+
<label>Color</label>
|
|
2777
|
+
<input type="color" value={newColor} onChange={(e) => setNewColor(e.target.value)} style={{ width: '48px', height: '36px', padding: '2px' }} />
|
|
2778
|
+
</div>
|
|
2779
|
+
<div className="form-group" style={{ marginBottom: 0 }}>
|
|
2780
|
+
<label>Shortcut</label>
|
|
2781
|
+
<input type="text" value={newShortcut} onChange={(e) => setNewShortcut(e.target.value)} placeholder="e.g. 3" maxLength={1} style={{ width: '60px' }} />
|
|
2782
|
+
</div>
|
|
2783
|
+
<button className="btn btn-primary" onClick={addClass}>Add</button>
|
|
2784
|
+
</div>
|
|
2785
|
+
</div>
|
|
2786
|
+
</div>
|
|
2787
|
+
);
|
|
2788
|
+
}
|
|
2789
|
+
`;
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
function generateExportPage() {
|
|
2793
|
+
return `import { useState } from 'react';
|
|
2794
|
+
import { useProject } from '../context/ProjectContext.jsx';
|
|
2795
|
+
import { exportAsJSON, exportAsCSV, exportAsCOCO } from '../utils/labelFormats.js';
|
|
2796
|
+
|
|
2797
|
+
const FORMATS = [
|
|
2798
|
+
{ id: 'json', name: 'JSON', description: 'Standard JSON format' },
|
|
2799
|
+
{ id: 'csv', name: 'CSV', description: 'Comma-separated values' },
|
|
2800
|
+
{ id: 'coco', name: 'COCO', description: 'COCO annotation format' },
|
|
2801
|
+
];
|
|
2802
|
+
|
|
2803
|
+
export default function Export() {
|
|
2804
|
+
const { dataset, labels, schema } = useProject();
|
|
2805
|
+
const [selectedFormat, setSelectedFormat] = useState('json');
|
|
2806
|
+
|
|
2807
|
+
const handleExport = () => {
|
|
2808
|
+
let content;
|
|
2809
|
+
let filename;
|
|
2810
|
+
let type;
|
|
2811
|
+
|
|
2812
|
+
switch (selectedFormat) {
|
|
2813
|
+
case 'csv':
|
|
2814
|
+
content = exportAsCSV(dataset, labels, schema);
|
|
2815
|
+
filename = 'labels.csv';
|
|
2816
|
+
type = 'text/csv';
|
|
2817
|
+
break;
|
|
2818
|
+
case 'coco':
|
|
2819
|
+
content = exportAsCOCO(dataset, labels, schema);
|
|
2820
|
+
filename = 'annotations.json';
|
|
2821
|
+
type = 'application/json';
|
|
2822
|
+
break;
|
|
2823
|
+
default:
|
|
2824
|
+
content = exportAsJSON(dataset, labels, schema);
|
|
2825
|
+
filename = 'labels.json';
|
|
2826
|
+
type = 'application/json';
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
const blob = new Blob([content], { type });
|
|
2830
|
+
const url = URL.createObjectURL(blob);
|
|
2831
|
+
const a = document.createElement('a');
|
|
2832
|
+
a.href = url;
|
|
2833
|
+
a.download = filename;
|
|
2834
|
+
a.click();
|
|
2835
|
+
URL.revokeObjectURL(url);
|
|
2836
|
+
};
|
|
2837
|
+
|
|
2838
|
+
const labeledCount = Object.keys(labels).length;
|
|
2839
|
+
|
|
2840
|
+
return (
|
|
2841
|
+
<div style={{ padding: '24px' }}>
|
|
2842
|
+
<h2 style={{ marginBottom: '20px' }}>Export Labels</h2>
|
|
2843
|
+
|
|
2844
|
+
<p style={{ marginBottom: '16px', color: '#666' }}>
|
|
2845
|
+
{labeledCount} of {dataset.length} items labeled
|
|
2846
|
+
</p>
|
|
2847
|
+
|
|
2848
|
+
<div className="export-options">
|
|
2849
|
+
{FORMATS.map((fmt) => (
|
|
2850
|
+
<div
|
|
2851
|
+
key={fmt.id}
|
|
2852
|
+
className={\`export-option\${selectedFormat === fmt.id ? ' selected' : ''}\`}
|
|
2853
|
+
onClick={() => setSelectedFormat(fmt.id)}
|
|
2854
|
+
>
|
|
2855
|
+
<h3>{fmt.name}</h3>
|
|
2856
|
+
<p>{fmt.description}</p>
|
|
2857
|
+
</div>
|
|
2858
|
+
))}
|
|
2859
|
+
</div>
|
|
2860
|
+
|
|
2861
|
+
<button className="btn btn-primary" onClick={handleExport} style={{ fontSize: '16px', padding: '12px 32px' }}>
|
|
2862
|
+
Download {selectedFormat.toUpperCase()}
|
|
2863
|
+
</button>
|
|
2864
|
+
</div>
|
|
2865
|
+
);
|
|
2866
|
+
}
|
|
2867
|
+
`;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
// ─── Utils ──────────────────────────────────────────────────────────────────
|
|
2871
|
+
|
|
2872
|
+
function generateShortcuts(dataTypes) {
|
|
2873
|
+
const sections = [];
|
|
2874
|
+
sections.push(` const common = [
|
|
2875
|
+
{ key: '?', description: 'Toggle keyboard shortcuts' },
|
|
2876
|
+
{ key: '\\u2192', description: 'Next item' },
|
|
2877
|
+
{ key: '\\u2190', description: 'Previous item' },
|
|
2878
|
+
];`);
|
|
2879
|
+
|
|
2880
|
+
if (dataTypes.includes('image')) {
|
|
2881
|
+
sections.push(` const image = [
|
|
2882
|
+
{ key: 'B', description: 'Bounding box tool' },
|
|
2883
|
+
{ key: 'P', description: 'Polygon tool' },
|
|
2884
|
+
{ key: 'V', description: 'Select tool' },
|
|
2885
|
+
{ key: 'Z', description: 'Zoom tool' },
|
|
2886
|
+
];`);
|
|
2887
|
+
}
|
|
2888
|
+
if (dataTypes.includes('text')) {
|
|
2889
|
+
sections.push(` const text = [
|
|
2890
|
+
{ key: 'Enter', description: 'Confirm selection' },
|
|
2891
|
+
{ key: 'Escape', description: 'Clear selection' },
|
|
2892
|
+
];`);
|
|
2893
|
+
}
|
|
2894
|
+
if (dataTypes.includes('audio')) {
|
|
2895
|
+
sections.push(` const audio = [
|
|
2896
|
+
{ key: 'Space', description: 'Play / Pause' },
|
|
2897
|
+
{ key: 'S', description: 'Add segment marker' },
|
|
2898
|
+
];`);
|
|
2899
|
+
}
|
|
2900
|
+
if (dataTypes.includes('video')) {
|
|
2901
|
+
sections.push(` const video = [
|
|
2902
|
+
{ key: 'Space', description: 'Play / Pause' },
|
|
2903
|
+
{ key: ',', description: 'Previous frame' },
|
|
2904
|
+
{ key: '.', description: 'Next frame' },
|
|
2905
|
+
{ key: 'L', description: 'Label current frame' },
|
|
2906
|
+
];`);
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
const switchCases = [];
|
|
2910
|
+
if (dataTypes.includes('image')) switchCases.push(` case 'image': return [...common, ...image];`);
|
|
2911
|
+
if (dataTypes.includes('text')) switchCases.push(` case 'text': return [...common, ...text];`);
|
|
2912
|
+
if (dataTypes.includes('audio')) switchCases.push(` case 'audio': return [...common, ...audio];`);
|
|
2913
|
+
if (dataTypes.includes('video')) switchCases.push(` case 'video': return [...common, ...video];`);
|
|
2914
|
+
|
|
2915
|
+
return `export function getShortcuts(dataType) {
|
|
2916
|
+
${sections.join('\n\n')}
|
|
2917
|
+
|
|
2918
|
+
switch (dataType) {
|
|
2919
|
+
${switchCases.join('\n')}
|
|
2920
|
+
default: return common;
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
`;
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
function generateLabelFormats() {
|
|
2927
|
+
return `export function exportAsJSON(dataset, labels, schema) {
|
|
2928
|
+
const output = {
|
|
2929
|
+
schema: schema.map((s) => ({ id: s.id, name: s.name, color: s.color })),
|
|
2930
|
+
items: dataset.map((item) => ({
|
|
2931
|
+
id: item.id,
|
|
2932
|
+
name: item.name,
|
|
2933
|
+
labels: labels[item.id] || [],
|
|
2934
|
+
})),
|
|
2935
|
+
};
|
|
2936
|
+
return JSON.stringify(output, null, 2);
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
export function exportAsCSV(dataset, labels, schema) {
|
|
2940
|
+
const rows = ['item_id,item_name,label_class,label_data'];
|
|
2941
|
+
|
|
2942
|
+
dataset.forEach((item) => {
|
|
2943
|
+
const itemLabels = labels[item.id] || [];
|
|
2944
|
+
if (itemLabels.length === 0) {
|
|
2945
|
+
rows.push(\`\${item.id},\${item.name},,\`);
|
|
2946
|
+
} else {
|
|
2947
|
+
itemLabels.forEach((label) => {
|
|
2948
|
+
const cls = schema.find((s) => s.id === label.classId);
|
|
2949
|
+
const data = JSON.stringify(label).replace(/"/g, '""');
|
|
2950
|
+
rows.push(\`\${item.id},\${item.name},\${cls?.name || ''},"\${data}"\`);
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
});
|
|
2954
|
+
|
|
2955
|
+
return rows.join('\\n');
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
export function exportAsCOCO(dataset, labels, schema) {
|
|
2959
|
+
const output = {
|
|
2960
|
+
info: { description: 'MyVillageOS Data Labeling Export', version: '1.0' },
|
|
2961
|
+
categories: schema.map((s, i) => ({ id: i + 1, name: s.name })),
|
|
2962
|
+
images: dataset.map((item, i) => ({ id: i + 1, file_name: item.name })),
|
|
2963
|
+
annotations: [],
|
|
2964
|
+
};
|
|
2965
|
+
|
|
2966
|
+
let annId = 1;
|
|
2967
|
+
dataset.forEach((item, itemIdx) => {
|
|
2968
|
+
const itemLabels = labels[item.id] || [];
|
|
2969
|
+
itemLabels.forEach((label) => {
|
|
2970
|
+
const catIdx = schema.findIndex((s) => s.id === label.classId);
|
|
2971
|
+
output.annotations.push({
|
|
2972
|
+
id: annId++,
|
|
2973
|
+
image_id: itemIdx + 1,
|
|
2974
|
+
category_id: catIdx + 1,
|
|
2975
|
+
bbox: label.x != null ? [label.x, label.y, label.width, label.height] : [],
|
|
2976
|
+
});
|
|
2977
|
+
});
|
|
2978
|
+
});
|
|
2979
|
+
|
|
2980
|
+
return JSON.stringify(output, null, 2);
|
|
2981
|
+
}
|
|
2982
|
+
`;
|
|
2983
|
+
}
|