@myvillage/cli 1.3.0 → 1.5.0

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.
@@ -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}>&#9776;</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}>&#9776;</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">&#9664;&#9664;</button>
2447
+ <button className="btn btn-primary">&#9654; Play</button>
2448
+ <button className="btn btn-secondary">&#9654;&#9654;</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
+ &#9664; 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 &#9654;
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
+ }