create-okjt-app 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,877 +1,877 @@
1
- /**
2
- * The React app (frontend/): public pages on @okjt/site, sign-in pages and
3
- * the dashboard on @okjt/dashboard, as path → contents. `p` is the project.
4
- */
5
- import { palette } from '../palette.mjs';
6
-
7
- const lines = (...parts) => parts.filter((part) => part !== false && part != null).join('\n');
8
- const js = (value) => JSON.stringify(value);
9
-
10
- export function frontendFiles(p) {
11
- return {
12
- 'package.json': packageJson(p),
13
- 'index.html': indexHtml(p),
14
- 'vite.config.js': viteConfig(),
15
- 'okjt.config.js': okjtConfig(p),
16
- '.env.example': 'VITE_API_URL=/api\n',
17
- 'public/brand/logo-dark.svg': wordmark(p.name, palette(p.colors).ink),
18
- 'public/brand/logo-light.svg': wordmark(p.name, '#FFFFFF'),
19
- 'public/brand/okjt-mark.svg': okjtMark(),
20
- 'src/main.jsx': mainJsx(),
21
- 'src/index.css': indexCss(),
22
- 'src/App.jsx': appJsx(p),
23
- 'src/services/api.js': apiJs(p),
24
- 'src/workspace.js': workspaceJs(p),
25
- 'src/site.config.jsx': siteConfig(p),
26
- 'src/dashboard.config.js': dashboardConfig(p),
27
- 'src/components/AuthLayout.jsx': authLayout(),
28
- 'src/components/RequireAuth.jsx': requireAuth(),
29
- 'src/pages/Home.jsx': homePage(p),
30
- 'src/pages/NotFound.jsx': notFound(),
31
- 'src/pages/SignIn.jsx': signIn(p),
32
- 'src/pages/SignUp.jsx': signUp(p),
33
- 'src/pages/AuthPages.jsx': authPages(p),
34
- 'src/pages/Workspaces.jsx': workspacesPage(p),
35
- 'src/pages/dashboard/Dashboard.jsx': dashboardPage(),
36
- 'src/pages/dashboard/Overview.jsx': overviewPage(p),
37
- 'src/pages/dashboard/Notes.jsx': notesPage(),
38
- 'src/pages/dashboard/AccountMenu.jsx': accountMenu(p),
39
- };
40
- }
41
-
42
- function packageJson(p) {
43
- return `${JSON.stringify({
44
- name: `${p.slug}-frontend`,
45
- private: true,
46
- version: '0.0.0',
47
- type: 'module',
48
- scripts: {
49
- theme: 'okjt-theme',
50
- predev: 'okjt-theme',
51
- dev: 'vite',
52
- prebuild: 'okjt-theme',
53
- build: 'vite build',
54
- preview: 'vite preview',
55
- },
56
- dependencies: {
57
- '@okjt/dashboard': '^0.2.0',
58
- '@okjt/site': '^0.1.0',
59
- '@okjt/ui': '^0.1.0',
60
- '@tanstack/react-query': '^5.90.12',
61
- axios: '^1.13.2',
62
- 'date-fns': '^4.1.0',
63
- 'framer-motion': '^12.23.26',
64
- 'lucide-react': '^0.559.0',
65
- react: '^19.2.0',
66
- 'react-dom': '^19.2.0',
67
- 'react-hook-form': '^7.68.0',
68
- 'react-hot-toast': '^2.6.0',
69
- 'react-router-dom': '^7.10.1',
70
- },
71
- devDependencies: {
72
- '@tailwindcss/vite': '^4.1.17',
73
- '@vitejs/plugin-react': '^5.2.0',
74
- tailwindcss: '^4.1.17',
75
- vite: '^7.2.4',
76
- },
77
- }, null, 2)}\n`;
78
- }
79
-
80
- function indexHtml(p) {
81
- return `<!doctype html>
82
- <html lang="en">
83
- <head>
84
- <meta charset="UTF-8" />
85
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
86
- <meta name="theme-color" content="${p.colors.accent}" />
87
- <title>${p.name}</title>
88
- </head>
89
- <body>
90
- <div id="root"></div>
91
- <script type="module" src="/src/main.jsx"></script>
92
- </body>
93
- </html>
94
- `;
95
- }
96
-
97
- function viteConfig() {
98
- return `import { defineConfig } from 'vite';
99
- import react from '@vitejs/plugin-react';
100
- import tailwindcss from '@tailwindcss/vite';
101
-
102
- // In development the API runs beside this app (cd backend && php artisan serve);
103
- // when deployed, both are served from one domain.
104
- export default defineConfig({
105
- plugins: [react(), tailwindcss()],
106
- server: {
107
- proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } },
108
- },
109
- });
110
- `;
111
- }
112
-
113
- function okjtConfig(p) {
114
- const colors = palette(p.colors);
115
- const body = Object.entries(colors).map(([name, value]) => ` ${name}: ${js(value)},`).join('\n');
116
- return `/**
117
- * ${p.name}'s theme for the OKJT kit (@okjt/ui): the colours and fonts every
118
- * page and the dashboard use (\`text-ink\`, \`bg-accent\`, \`border-line\`).
119
- * src/okjt-theme.css is generated from it before every \`npm run dev\` and
120
- * \`npm run build\` (or now: npm run theme).
121
- *
122
- * Made from two colours: accent ${p.colors.accent} and ink ${p.colors.ink}. Change any of them.
123
- */
124
- export default {
125
- theme: {
126
- colors: {
127
- ${body}
128
- },
129
- fonts: {
130
- sans: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
131
- display: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
132
- },
133
- },
134
- };
135
- `;
136
- }
137
-
138
- /** A plain wordmark in the brand's ink; replace public/brand/ with the real logo. */
139
- function wordmark(name, color) {
140
- const text = name.replace(/&/g, '&amp;').replace(/</g, '&lt;');
141
- const width = Math.max(80, Math.round(name.length * 15 + 16));
142
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="32" viewBox="0 0 ${width} 32"><text x="0" y="24" font-family="ui-sans-serif, system-ui, sans-serif" font-size="24" font-weight="700" fill="${color}">${text}</text></svg>\n`;
143
- }
144
-
145
- function okjtMark() {
146
- return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#111"/><text x="12" y="16" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="9" font-weight="700" fill="#fff">OKJ</text></svg>\n';
147
- }
148
-
149
- function mainJsx() {
150
- return `import { StrictMode } from 'react';
151
- import { createRoot } from 'react-dom/client';
152
- import './index.css';
153
- import App from './App';
154
-
155
- createRoot(document.getElementById('root')).render(
156
- <StrictMode>
157
- <App />
158
- </StrictMode>,
159
- );
160
- `;
161
- }
162
-
163
- function indexCss() {
164
- return `@import "tailwindcss";
165
- @import "./okjt-theme.css";
166
-
167
- /* Let Tailwind see the kit's class names. */
168
- @source "../node_modules/@okjt/ui/dist";
169
- @source "../node_modules/@okjt/site/dist";
170
- @source "../node_modules/@okjt/dashboard/dist";
171
-
172
- /* "fit": desktop viewports tall enough for the dashboard's fixed-height
173
- lists (AdminPageLayout \`fill\`), where only the rows scroll. */
174
- @custom-variant fit (@media (min-width: 64rem) and (min-height: 40rem));
175
-
176
- body {
177
- background: var(--color-paper);
178
- color: var(--color-body);
179
- font-family: var(--font-sans);
180
- }
181
-
182
- /* ---- The dashboard (DashboardShell sets .admin-theme on <html>) ---- */
183
- /* Light greys used for text: darker in the dashboard, for contrast. */
184
- .admin-theme {
185
- --color-tone-400: #79716b;
186
- --color-tone-500: #66605a;
187
- }
188
-
189
- /* Always show where keyboard focus is. */
190
- .admin-theme :focus-visible {
191
- outline: 2px solid var(--color-accent);
192
- outline-offset: 2px;
193
- }
194
-
195
- @media (prefers-reduced-motion: reduce) {
196
- .admin-theme *,
197
- .admin-theme *::before,
198
- .admin-theme *::after {
199
- animation-duration: 0.01ms !important;
200
- animation-iteration-count: 1 !important;
201
- transition-duration: 0.01ms !important;
202
- scroll-behavior: auto !important;
203
- }
204
- }
205
-
206
- /* View-only people: fields in an EditableFieldset are disabled. */
207
- .admin-theme fieldset:disabled :is(input, select, textarea, button, label) {
208
- cursor: not-allowed;
209
- }
210
- `;
211
- }
212
-
213
- function appJsx(p) {
214
- const w = p.workspace;
215
- return lines(
216
- "import { lazy, Suspense } from 'react';",
217
- "import { BrowserRouter, Route, Routes } from 'react-router-dom';",
218
- "import { QueryClient, QueryClientProvider } from '@tanstack/react-query';",
219
- "import { Toaster } from 'react-hot-toast';",
220
- "import { AuthProvider } from '@okjt/dashboard';",
221
- "import { SiteProvider } from '@okjt/site';",
222
- "import api from './services/api';",
223
- "import { site } from './site.config';",
224
- "import RequireAuth from './components/RequireAuth';",
225
- "import Home from './pages/Home';",
226
- '',
227
- "const SignIn = lazy(() => import('./pages/SignIn'));",
228
- "const SignUp = lazy(() => import('./pages/SignUp'));",
229
- "const AuthPages = lazy(() => import('./pages/AuthPages'));",
230
- "const Workspaces = lazy(() => import('./pages/Workspaces'));",
231
- "const Dashboard = lazy(() => import('./pages/dashboard/Dashboard'));",
232
- "const Overview = lazy(() => import('./pages/dashboard/Overview'));",
233
- "const Notes = lazy(() => import('./pages/dashboard/Notes'));",
234
- "const NotFound = lazy(() => import('./pages/NotFound'));",
235
- '',
236
- 'const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });',
237
- '',
238
- '/**',
239
- ` * ${p.name}: the public pages (/), signing in and up, your ${w.plural} (/${w.path}),`,
240
- ` * and the dashboard of the ${w.label} you open (/dashboard).`,
241
- ' */',
242
- 'export default function App() {',
243
- ' return (',
244
- ' <QueryClientProvider client={queryClient}>',
245
- ' <BrowserRouter>',
246
- ' <AuthProvider api={api}>',
247
- ' <SiteProvider site={site}>',
248
- ' <Toaster position="top-right" />',
249
- ' <Suspense fallback={null}>',
250
- ' <Routes>',
251
- ' <Route path="/" element={<Home />} />',
252
- ' <Route path="/sign-in" element={<SignIn />} />',
253
- ' <Route path="/sign-up" element={<SignUp />} />',
254
- p.features.google && ' <Route path="/auth/google" element={<AuthPages page="google" />} />',
255
- ' <Route path="/verify-email/:id/:hash" element={<AuthPages page="verify" />} />',
256
- ' <Route path="/accept-invite" element={<AuthPages page="invite" />} />',
257
- ` <Route path="/${w.path}" element={<RequireAuth><Workspaces /></RequireAuth>} />`,
258
- ' <Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>}>',
259
- ' <Route index element={<Overview />} />',
260
- ' <Route path="notes" element={<Notes />} />',
261
- ' </Route>',
262
- ' <Route path="*" element={<NotFound />} />',
263
- ' </Routes>',
264
- ' </Suspense>',
265
- ' </SiteProvider>',
266
- ' </AuthProvider>',
267
- ' </BrowserRouter>',
268
- ' </QueryClientProvider>',
269
- ' );',
270
- '}',
271
- '',
272
- );
273
- }
274
-
275
- function apiJs(p) {
276
- return `import axios from 'axios';
277
- import { toast } from 'react-hot-toast';
278
- import { currentWorkspace } from '../workspace';
279
-
280
- /**
281
- * The API client: sends your token, and inside the dashboard the ${p.workspace.label}
282
- * you're working on (X-Workspace); answers are unwrapped from the kit's
283
- * { success, message, data } to their data.
284
- */
285
- const api = axios.create({
286
- baseURL: import.meta.env.VITE_API_URL || '/api',
287
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
288
- });
289
-
290
- api.interceptors.request.use((config) => {
291
- const token = localStorage.getItem('auth_token');
292
- if (token) config.headers.Authorization = \`Bearer \${token}\`;
293
- const slug = currentWorkspace();
294
- if (slug && window.location.pathname.startsWith('/dashboard') && !config.headers['X-Workspace']) {
295
- config.headers['X-Workspace'] = slug;
296
- }
297
- return config;
298
- });
299
-
300
- api.interceptors.response.use(
301
- (response) => {
302
- const payload = response.data;
303
- if (payload && typeof payload === 'object' && 'success' in payload && 'data' in payload) response.data = payload.data;
304
- return response;
305
- },
306
- (error) => {
307
- if (error.response?.status === 403 && error.response.data?.reason === 'role') {
308
- toast.error(error.response.data.message || 'You don’t have permission to do that.', { id: 'role-denied' });
309
- }
310
- if (error.response?.status === 401) {
311
- localStorage.removeItem('auth_token');
312
- if (window.location.pathname.startsWith('/dashboard')) window.location.assign('/sign-in');
313
- }
314
- return Promise.reject(error);
315
- },
316
- );
317
-
318
- export default api;
319
- `;
320
- }
321
-
322
- function workspaceJs(p) {
323
- const w = p.workspace;
324
- return `import { useQuery } from '@tanstack/react-query';
325
- import api from './services/api';
326
-
327
- /** The ${w.label} the dashboard works on: its address (slug), remembered in this browser. */
328
- const KEY = 'workspace';
329
-
330
- export function currentWorkspace() {
331
- try { return localStorage.getItem(KEY); } catch { return null; }
332
- }
333
-
334
- export function openWorkspace(slug) {
335
- try { localStorage.setItem(KEY, slug); } catch { /* private window: this visit only */ }
336
- }
337
-
338
- /** Your ${w.plural}: [{ id, name, slug, status, role }]. */
339
- export function useWorkspaces() {
340
- return useQuery({ queryKey: ['workspaces'], queryFn: () => api.get('/${w.path}').then((res) => res.data) });
341
- }
342
- `;
343
- }
344
-
345
- function siteConfig(p) {
346
- const w = p.workspace;
347
- return `import { FileText, ShieldCheck } from 'lucide-react';
348
- import { useAuth } from '@okjt/dashboard';
349
-
350
- /**
351
- * ${p.name}'s public pages (@okjt/site): the words, links and pictures the
352
- * kit's layout and sections show. Colours and fonts are in okjt.config.js.
353
- */
354
- export const site = {
355
- brand: { name: ${js(p.name)}, logo: { light: '/brand/logo-light.svg', dark: '/brand/logo-dark.svg' } },
356
-
357
- nav: [
358
- { to: '/', label: 'Home' },
359
- { to: '/#faq', label: 'Questions' },
360
- ],
361
-
362
- useAccount: () => ({ signedIn: useAuth().isAuthenticated }),
363
- accountLinks: {
364
- signIn: { to: '/sign-in', label: 'Sign in' },
365
- signUp: { to: '/sign-up', label: 'Get started' },
366
- home: { to: '/${w.path}', label: ${js(`Your ${w.plural}`)} },
367
- },
368
-
369
- // Photos are keys; add pictures to public/photos and name them here.
370
- usePhotos: () => ({ url: (key) => \`/photos/\${key}.jpg\`, alt: () => '' }),
371
- usePlaces: () => () => null,
372
- media: {},
373
-
374
- footer: {
375
- about: ${js(p.tagline)},
376
- columns: [
377
- { title: 'Product', links: [{ to: '/sign-up', label: 'Get started', signedIn: false }, { to: '/sign-in', label: 'Sign in', signedIn: false }] },
378
- ],
379
- promises: [{ icon: ShieldCheck, text: 'Your data stays yours' }],
380
- watermark: ${js(`${p.slug}.`)},
381
- copyright: ${js(p.name)},
382
- madeBy: { label: 'Built by', name: 'OKJ Technologies', href: 'https://okjtech.co.ke', logo: '/brand/okjt-mark.svg' },
383
- },
384
-
385
- legal: {
386
- documents: [{ to: '/terms', label: 'Terms of service', icon: FileText }],
387
- contact: 'Contact OKJ Technologies through okjtech.co.ke.',
388
- lastUpdated: ${js(new Date().toISOString().slice(0, 10))},
389
- seeAlso: [],
390
- },
391
-
392
- seo: {
393
- origin: ${js(`https://${p.domain}`)},
394
- siteName: ${js(p.name)},
395
- locale: 'en_KE',
396
- defaultTitle: ${js(p.name)},
397
- defaultDescription: ${js(p.tagline)},
398
- pages: { '/': { title: ${js(p.name)}, description: ${js(p.tagline)} } },
399
- resolve: () => undefined,
400
- },
401
- };
402
- `;
403
- }
404
-
405
- function dashboardConfig(p) {
406
- const w = p.workspace;
407
- return `import { useActivityPanel, useCollaboration, NotificationsBell } from '@okjt/dashboard';
408
- import { LayoutDashboard, NotebookPen } from 'lucide-react';
409
- import api from './services/api';
410
- import AccountMenu from './pages/dashboard/AccountMenu';
411
-
412
- /**
413
- * ${p.name}'s dashboard (@okjt/dashboard): its pages, roles, and shell. Add a
414
- * module: a page under pages/dashboard, a route in App.jsx, a sidebar item
415
- * here and an entry in MODULES (the key is the server's $revisionModule).
416
- */
417
- export const MODULES = [
418
- { key: 'overview', path: '/dashboard', label: 'Overview' },
419
- { key: 'notes', path: '/dashboard/notes', label: 'Notes' },
420
- ];
421
-
422
- export const ROLES = ${JSON.stringify(p.roles, null, 2).replace(/"(\w+)":/g, '$1:').replace(/"/g, "'")};
423
-
424
- export const roleLabel = (role) => ROLES.find((r) => r.value === role)?.label || role;
425
-
426
- /** The guided tour on a first visit (\`target\` is an element's id). Edit or extend it. */
427
- export const TUTORIAL_STEPS = [
428
- { title: 'Welcome', content: 'A quick look around: it takes a minute.', target: 'header', position: 'bottom' },
429
- { title: 'Overview', content: 'Where you start: the numbers that matter, at a glance.', target: 'sidebar-overview', position: 'right' },
430
- { title: 'Notes', content: 'Write things down together. Every change is kept, and any version can be restored.', target: 'sidebar-notes', position: 'right' },
431
- { title: 'You', content: 'Your role, your other ${w.plural}, and signing out.', target: 'header-account', position: 'bottom' },
432
- ];
433
-
434
- export const dashboard = {
435
- home: { label: 'Overview', path: '/dashboard' },
436
- tutorialSteps: TUTORIAL_STEPS,
437
- useDefaultPanel: useActivityPanel,
438
- useAfterChange: () => useCollaboration().refresh,
439
- api,
440
- modules: MODULES,
441
- roles: ROLES,
442
- roleLabel,
443
- shell: {
444
- logo: { src: '/brand/logo-light.svg', alt: ${js(p.name)}, to: '/${w.path}' },
445
- nav: [
446
- { items: [{ path: '/dashboard', icon: LayoutDashboard, label: 'Overview', id: 'sidebar-overview' }] },
447
- { title: 'Work', items: [{ path: '/dashboard/notes', icon: NotebookPen, label: 'Notes', id: 'sidebar-notes' }] },
448
- ],
449
- quickLinks: [],
450
- Notifications: NotificationsBell,
451
- AccountMenu,
452
- },
453
- };
454
- `;
455
- }
456
-
457
- function authLayout() {
458
- return `import { AuthLayout as KitAuthLayout } from '@okjt/dashboard';
459
- import { useSite } from '@okjt/site';
460
-
461
- /** The sign-in pages' frame (the kit's), with this project's logo. */
462
- export default function AuthLayout(props) {
463
- const { brand } = useSite();
464
- return <KitAuthLayout brand={<img src={brand.logo.dark} alt={brand.name} className="h-8 w-auto" />} {...props} />;
465
- }
466
- `;
467
- }
468
-
469
- function requireAuth() {
470
- return `import { Navigate, useLocation } from 'react-router-dom';
471
- import { useAuth } from '@okjt/dashboard';
472
-
473
- /** Signed-in pages: everyone else goes to sign in, and comes back after. */
474
- export default function RequireAuth({ children }) {
475
- const { isAuthenticated, loading } = useAuth();
476
- const location = useLocation();
477
- if (loading) return null;
478
- if (!isAuthenticated) return <Navigate to={\`/sign-in?next=\${encodeURIComponent(location.pathname)}\`} replace />;
479
- return children;
480
- }
481
- `;
482
- }
483
-
484
- function homePage(p) {
485
- const w = p.workspace;
486
- return `import { Container, FaqSection, PillLink, SectionHeading, SiteLayout } from '@okjt/site';
487
-
488
- const FAQ = [
489
- { q: 'Who can see my ${w.plural}?', a: 'Only the people you invite, with the role you give them.' },
490
- { q: 'Can I undo a change?', a: 'Yes: every change is kept in the version history, and any version can be restored.' },
491
- { q: 'Can we work at the same time?', a: 'Yes: you see who else is there, and what changed since you last looked.' },
492
- ];
493
-
494
- /** The home page: what ${p.name} is, a way in, and questions. Replace the words with your own. */
495
- export default function Home() {
496
- return (
497
- <SiteLayout cta={false}>
498
- <section className="pt-36 pb-24 sm:pt-44">
499
- <Container>
500
- <SectionHeading
501
- as="h1"
502
- eyebrow=${js(p.name)}
503
- title=${js(p.tagline)}
504
- text="Everything your team works on, in one place: who's doing what, what changed, and nothing lost."
505
- />
506
- <div className="mt-10 flex flex-wrap justify-center gap-3">
507
- <PillLink to="/sign-up" size="lg">Get started</PillLink>
508
- <PillLink to="/sign-in" variant="light" size="lg" arrow>Sign in</PillLink>
509
- </div>
510
- </Container>
511
- </section>
512
- <section className="pb-24">
513
- <Container>
514
- <FaqSection items={FAQ} />
515
- </Container>
516
- </section>
517
- </SiteLayout>
518
- );
519
- }
520
- `;
521
- }
522
-
523
- function notFound() {
524
- return `import { Container, PillLink, SectionHeading, SiteLayout } from '@okjt/site';
525
-
526
- export default function NotFound() {
527
- return (
528
- <SiteLayout cta={false}>
529
- <section className="pt-40 pb-32">
530
- <Container>
531
- <SectionHeading as="h1" eyebrow="404" title="This page isn’t here." text="It may have moved, or the address has a typo." />
532
- <div className="mt-10 flex justify-center"><PillLink to="/">Home</PillLink></div>
533
- </Container>
534
- </section>
535
- </SiteLayout>
536
- );
537
- }
538
- `;
539
- }
540
-
541
- function signIn(p) {
542
- return lines(
543
- "import { useEffect } from 'react';",
544
- "import { Link, useNavigate, useSearchParams } from 'react-router-dom';",
545
- `import { ${p.features.google ? 'GoogleButton, ' : ''}SignInPage, useAuth } from '@okjt/dashboard';`,
546
- "import AuthLayout from '../components/AuthLayout';",
547
- '',
548
- '/** Only paths on this site (never "//elsewhere.example"). */',
549
- "const safeNext = (next) => typeof next === 'string' && next.startsWith('/') && !next.startsWith('//');",
550
- '',
551
- `/** Signing in (the kit's page), then on to where you were going, or your ${p.workspace.plural}. */`,
552
- 'export default function SignIn() {',
553
- ' const navigate = useNavigate();',
554
- ' const [params] = useSearchParams();',
555
- ` const next = safeNext(params.get('next')) ? params.get('next') : '/${p.workspace.path}';`,
556
- ' const { isAuthenticated } = useAuth();',
557
- '',
558
- ' useEffect(() => {',
559
- ' if (isAuthenticated) navigate(next, { replace: true });',
560
- ' }, [isAuthenticated, navigate, next]);',
561
- '',
562
- ' return (',
563
- ' <SignInPage',
564
- ' layout={AuthLayout}',
565
- ' onSignedIn={() => navigate(next, { replace: true })}',
566
- ` subtitle=${js(`Sign in to ${p.name}.`)}`,
567
- ` footer={<>New here? <Link to="/sign-up" className="font-medium text-accent-strong hover:underline">Create an account</Link></>}`,
568
- ` forgotHint="Forgot it? Ask an owner to invite you again."`,
569
- p.features.google && ' google={<GoogleButton />}',
570
- ' />',
571
- ' );',
572
- '}',
573
- '',
574
- );
575
- }
576
-
577
- function signUp(p) {
578
- return `import { useState } from 'react';
579
- import { Link } from 'react-router-dom';
580
- import { AuthError, AuthField, AuthSubmit, useAuth } from '@okjt/dashboard';
581
- import AuthLayout from '../components/AuthLayout';
582
-
583
- /** Creating an account; then the verification email (the server asks for it before the first sign-in). */
584
- export default function SignUp() {
585
- const { register } = useAuth();
586
- const [form, setForm] = useState({ name: '', email: '', password: '', password_confirmation: '' });
587
- const [errors, setErrors] = useState({});
588
- const [error, setError] = useState(null);
589
- const [sentTo, setSentTo] = useState(null);
590
- const [saving, setSaving] = useState(false);
591
- const set = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.value }));
592
-
593
- const onSubmit = async (e) => {
594
- e.preventDefault();
595
- setSaving(true);
596
- setError(null);
597
- const result = await register(form);
598
- setSaving(false);
599
- if (result.requires_verification) return setSentTo(result.email);
600
- setErrors(Object.fromEntries(Object.entries(result.errors || {}).map(([k, v]) => [k, v[0]])));
601
- setError(result.message);
602
- };
603
-
604
- if (sentTo) {
605
- return (
606
- <AuthLayout title="Check your inbox" subtitle={<>We sent a link to <strong className="text-body">{sentTo}</strong>. Open it to verify your address, then sign in.</>}
607
- footer={<Link to="/sign-in" className="hover:text-body">← Back to sign in</Link>} />
608
- );
609
- }
610
-
611
- return (
612
- <AuthLayout title=${js(`Join ${p.name}`)} subtitle="Create your account. It takes a minute."
613
- footer={<>Already have one? <Link to="/sign-in" className="font-medium text-accent-strong hover:underline">Sign in</Link></>}>
614
- <AuthError>{error}</AuthError>
615
- <form onSubmit={onSubmit} className="space-y-5" noValidate>
616
- <AuthField id="sign-up-name" label="Your name" autoComplete="name" value={form.name} onChange={set('name')} error={errors.name} required />
617
- <AuthField id="sign-up-email" label="Email" type="email" autoComplete="email" value={form.email} onChange={set('email')} error={errors.email} required />
618
- <AuthField id="sign-up-password" label="Password" type="password" autoComplete="new-password" placeholder="At least 8 characters" value={form.password} onChange={set('password')} error={errors.password} required />
619
- <AuthField id="sign-up-password-confirm" label="Confirm password" type="password" autoComplete="new-password" value={form.password_confirmation} onChange={set('password_confirmation')} required />
620
- <div className="pt-2"><AuthSubmit loading={saving}>Create account</AuthSubmit></div>
621
- </form>
622
- </AuthLayout>
623
- );
624
- }
625
- `;
626
- }
627
-
628
- function authPages(p) {
629
- return lines(
630
- "import { useNavigate } from 'react-router-dom';",
631
- `import { AcceptInvitePage, ${p.features.google ? 'GoogleCallbackPage, ' : ''}VerifyEmailPage } from '@okjt/dashboard';`,
632
- "import api from '../services/api';",
633
- "import AuthLayout from '../components/AuthLayout';",
634
- '',
635
- `/** Where emailed links${p.features.google ? ' and Google sign-in' : ''} land (the kit's pages), then on to your ${p.workspace.plural}. */`,
636
- 'export default function AuthPages({ page }) {',
637
- ' const navigate = useNavigate();',
638
- ` const home = () => navigate('/${p.workspace.path}', { replace: true });`,
639
- '',
640
- p.features.google && " if (page === 'google') return <GoogleCallbackPage layout={AuthLayout} onSignedIn={home} />;",
641
- ` if (page === 'verify') return <VerifyEmailPage layout={AuthLayout} next="/${p.workspace.path}" />;`,
642
- ' return (',
643
- ' <AcceptInvitePage',
644
- ' layout={AuthLayout}',
645
- " accept={(payload) => api.post('/collaborators/accept', payload).then((res) => res.data)}",
646
- ' onSignedIn={home}',
647
- ' />',
648
- ' );',
649
- '}',
650
- '',
651
- );
652
- }
653
-
654
- function workspacesPage(p) {
655
- const w = p.workspace;
656
- const Title = w.plural.charAt(0).toUpperCase() + w.plural.slice(1);
657
- const Label = w.label.charAt(0).toUpperCase() + w.label.slice(1);
658
- return `import { useState } from 'react';
659
- import { useNavigate } from 'react-router-dom';
660
- import { useMutation, useQueryClient } from '@tanstack/react-query';
661
- import { ArrowRight, Plus } from 'lucide-react';
662
- import { AuthError, AuthField, AuthSubmit, useAuth } from '@okjt/dashboard';
663
- import AuthLayout from '../components/AuthLayout';
664
- import api from '../services/api';
665
- import { openWorkspace, useWorkspaces } from '../workspace';
666
- import { roleLabel } from '../dashboard.config';
667
-
668
- /** Your ${w.plural}: open one, or start a new one. */
669
- export default function Workspaces() {
670
- const navigate = useNavigate();
671
- const queryClient = useQueryClient();
672
- const { user, logout } = useAuth();
673
- const { data: workspaces = [], isLoading } = useWorkspaces();
674
- const [name, setName] = useState('');
675
- const [error, setError] = useState(null);
676
-
677
- const open = (slug) => {
678
- openWorkspace(slug);
679
- navigate('/dashboard');
680
- };
681
-
682
- const create = useMutation({
683
- mutationFn: () => api.post('/${w.path}', { name }).then((res) => res.data),
684
- onSuccess: (created) => {
685
- // In the list before the dashboard looks for it there.
686
- queryClient.setQueryData(['workspaces'], (list = []) => [...list, created]);
687
- open(created.slug);
688
- },
689
- onError: (err) => setError(err.response?.data?.message || 'Something went wrong. Try again.'),
690
- });
691
-
692
- return (
693
- <AuthLayout
694
- title=${js(`Your ${w.plural}`)}
695
- subtitle={user ? \`Signed in as \${user.email}.\` : null}
696
- footer={<button type="button" onClick={logout} className="hover:text-body">Sign out</button>}
697
- >
698
- {!isLoading && workspaces.length > 0 && (
699
- <ul className="mb-8 space-y-2">
700
- {workspaces.map((w) => (
701
- <li key={w.id}>
702
- <button type="button" onClick={() => open(w.slug)} className="flex w-full items-center justify-between gap-3 rounded-2xl border border-line bg-white px-5 py-4 text-left hover:border-accent">
703
- <span>
704
- <span className="block text-sm font-semibold text-ink">{w.name}</span>
705
- <span className="block text-xs text-muted">{roleLabel(w.role)}</span>
706
- </span>
707
- <ArrowRight className="h-4 w-4 text-accent" aria-hidden="true" />
708
- </button>
709
- </li>
710
- ))}
711
- </ul>
712
- )}
713
- <AuthError>{error}</AuthError>
714
- <form onSubmit={(e) => { e.preventDefault(); setError(null); create.mutate(); }} className="space-y-4">
715
- <AuthField id="new-workspace" label=${js(`Start a new ${w.label}`)} placeholder=${js(`${Label} name`)} value={name} onChange={(e) => setName(e.target.value)} required />
716
- <AuthSubmit loading={create.isPending}><Plus className="h-4 w-4" aria-hidden="true" /> Create</AuthSubmit>
717
- </form>
718
- {!isLoading && workspaces.length === 0 && <p className="mt-6 text-sm text-muted">${Title} you start or are invited to show up here.</p>}
719
- </AuthLayout>
720
- );
721
- }
722
- `;
723
- }
724
-
725
- function dashboardPage() {
726
- return `import { Navigate, Outlet } from 'react-router-dom';
727
- import { DashboardProvider, DashboardShell } from '@okjt/dashboard';
728
- import { PermissionsProvider } from '@okjt/ui';
729
- import { dashboard } from '../../dashboard.config';
730
- import { currentWorkspace, useWorkspaces } from '../../workspace';
731
-
732
- /** The dashboard of the workspace you opened, with your role in it (viewers may only look). */
733
- export default function Dashboard() {
734
- const { data: workspaces, isLoading } = useWorkspaces();
735
- if (isLoading) return null;
736
- const workspace = workspaces?.find((w) => w.slug === currentWorkspace());
737
- if (!workspace) return <Navigate to={dashboard.shell.logo.to} replace />;
738
-
739
- const permissions = { role: workspace.role, isOwner: workspace.role === 'owner', canEdit: workspace.role !== 'viewer' };
740
- return (
741
- <PermissionsProvider value={permissions}>
742
- <DashboardProvider dashboard={dashboard}>
743
- <DashboardShell>
744
- <Outlet context={{ workspace }} />
745
- </DashboardShell>
746
- </DashboardProvider>
747
- </PermissionsProvider>
748
- );
749
- }
750
- `;
751
- }
752
-
753
- function overviewPage(p) {
754
- return `import { Link, useOutletContext } from 'react-router-dom';
755
- import { useQuery } from '@tanstack/react-query';
756
- import { NotebookPen } from 'lucide-react';
757
- import { AdminCard, AdminPageHero, AdminPageLayout, AdminSummaryCards } from '@okjt/dashboard';
758
- import api from '../../services/api';
759
-
760
- /** The ${p.workspace.label}'s front page: a few numbers and where to go next. */
761
- export default function Overview() {
762
- const { workspace } = useOutletContext();
763
- const { data: notes = [] } = useQuery({ queryKey: ['notes'], queryFn: () => api.get('/notes').then((res) => res.data) });
764
-
765
- return (
766
- <AdminPageLayout
767
- hero={<AdminPageHero title={workspace.name} breadcrumb="Overview" description="Everything in this ${p.workspace.label}, at a glance." />}
768
- summary={<AdminSummaryCards cards={[{ label: 'Notes', value: notes.length, icon: NotebookPen }]} />}
769
- >
770
- <AdminCard>
771
- <p className="text-sm text-soft">
772
- Start with <Link to="/dashboard/notes" className="font-medium text-accent-strong hover:underline">Notes</Link>: every change is kept in the version history, and the people you invite (Share, in the header) see who else is here.
773
- </p>
774
- </AdminCard>
775
- </AdminPageLayout>
776
- );
777
- }
778
- `;
779
- }
780
-
781
- function notesPage() {
782
- return `import { useState } from 'react';
783
- import { useQuery } from '@tanstack/react-query';
784
- import { NotebookPen, Plus } from 'lucide-react';
785
- import {
786
- AdminInput, AdminListPanel, AdminModal, AdminPageHero, AdminPageLayout, AdminTextarea,
787
- DeleteButton, EmptyState, useAdminAction, useFilteredItems, useSearch,
788
- } from '@okjt/dashboard';
789
- import api from '../../services/api';
790
-
791
- /**
792
- * The starter module: a list with search, add and edit in a dialog, delete
793
- * with a confirmation. Saving goes through useAdminAction (confirm, pending,
794
- * toast, refresh; hidden for viewers). Copy this page for your own modules.
795
- */
796
- export default function Notes() {
797
- const { data: notes = [], isLoading } = useQuery({ queryKey: ['notes'], queryFn: () => api.get('/notes').then((res) => res.data) });
798
- const { searchQuery } = useSearch();
799
- const shown = useFilteredItems(notes, searchQuery);
800
- const [editing, setEditing] = useState(null);
801
-
802
- const save = useAdminAction(
803
- (note) => (note.id ? api.put(\`/notes/\${note.id}\`, note) : api.post('/notes', note)),
804
- { success: 'Note saved', invalidate: [['notes']], onSuccess: () => setEditing(null) },
805
- );
806
- const remove = useAdminAction((id) => api.delete(\`/notes/\${id}\`), {
807
- confirm: 'Delete this note?', success: 'Note deleted', invalidate: [['notes']],
808
- });
809
-
810
- return (
811
- <AdminPageLayout
812
- fill
813
- hero={<AdminPageHero title="Notes" breadcrumb="Notes" description="Decisions, reminders and anything worth writing down." />}
814
- search="Search notes"
815
- actions={[{ id: 'add-note', label: 'Add note', icon: Plus, onClick: () => setEditing({ title: '', body: '' }) }]}
816
- >
817
- <AdminListPanel>
818
- {shown.length === 0 ? (
819
- <EmptyState icon={NotebookPen} loading={isLoading} searchQuery={searchQuery} message="No notes yet." />
820
- ) : (
821
- <ul className="divide-y divide-line">
822
- {shown.map((note) => (
823
- <li key={note.id} className="flex items-start justify-between gap-4 px-5 py-4">
824
- <button type="button" onClick={() => setEditing(note)} className="min-w-0 text-left">
825
- <span className="block font-semibold text-ink">{note.title}</span>
826
- {note.body && <span className="mt-1 block truncate text-sm text-soft">{note.body}</span>}
827
- </button>
828
- <DeleteButton onClick={() => remove.run(note.id)} />
829
- </li>
830
- ))}
831
- </ul>
832
- )}
833
- </AdminListPanel>
834
-
835
- <AdminModal isOpen={!!editing} onClose={() => setEditing(null)} title={editing?.id ? 'Edit note' : 'Add note'}>
836
- {editing && (
837
- <form onSubmit={(e) => { e.preventDefault(); save.run(editing); }} className="space-y-4">
838
- <AdminInput id="note-title" label="Title" value={editing.title} onChange={(e) => setEditing({ ...editing, title: e.target.value })} required />
839
- <AdminTextarea id="note-body" label="Note" rows={6} value={editing.body || ''} onChange={(e) => setEditing({ ...editing, body: e.target.value })} />
840
- <div className="flex justify-end">
841
- <button type="submit" disabled={save.pending} className="rounded-full bg-accent px-5 py-2.5 text-sm font-semibold text-white hover:bg-accent-strong disabled:opacity-60">Save</button>
842
- </div>
843
- </form>
844
- )}
845
- </AdminModal>
846
- </AdminPageLayout>
847
- );
848
- }
849
- `;
850
- }
851
-
852
- function accountMenu(p) {
853
- const w = p.workspace;
854
- return `import { ArrowLeftRight } from 'lucide-react';
855
- import { AccountMenu as KitAccountMenu } from '@okjt/dashboard';
856
- import { usePermissions } from '@okjt/ui';
857
- import { roleLabel } from '../../dashboard.config';
858
-
859
- /** The header's account menu (the kit's): your role here, back to your ${w.plural}, log out. */
860
- export default function AccountMenu({ compactLinks }) {
861
- const { role } = usePermissions();
862
- return (
863
- <KitAccountMenu
864
- compactLinks={compactLinks}
865
- roleText={roleLabel(role)}
866
- fallbackName="You"
867
- extra={({ itemClass }) => (
868
- <a href="/${w.path}" role="menuitem" className={\`\${itemClass} border-b border-tone-100 mb-1\`}>
869
- <ArrowLeftRight className="w-4 h-4 text-accent" />
870
- ${'Your ' + w.plural}
871
- </a>
872
- )}
873
- />
874
- );
875
- }
876
- `;
877
- }
1
+ /**
2
+ * The React app (frontend/): public pages on @okjt/site, sign-in pages and
3
+ * the dashboard on @okjt/dashboard, as path → contents. `p` is the project.
4
+ */
5
+ import { palette } from '../palette.mjs';
6
+
7
+ const lines = (...parts) => parts.filter((part) => part !== false && part != null).join('\n');
8
+ const js = (value) => JSON.stringify(value);
9
+
10
+ export function frontendFiles(p) {
11
+ return {
12
+ 'package.json': packageJson(p),
13
+ 'index.html': indexHtml(p),
14
+ 'vite.config.js': viteConfig(),
15
+ 'okjt.config.js': okjtConfig(p),
16
+ '.env.example': 'VITE_API_URL=/api\n',
17
+ 'public/brand/logo-dark.svg': wordmark(p.name, palette(p.colors).ink),
18
+ 'public/brand/logo-light.svg': wordmark(p.name, '#FFFFFF'),
19
+ 'public/brand/okjt-mark.svg': okjtMark(),
20
+ 'src/main.jsx': mainJsx(),
21
+ 'src/index.css': indexCss(),
22
+ 'src/App.jsx': appJsx(p),
23
+ 'src/services/api.js': apiJs(p),
24
+ 'src/workspace.js': workspaceJs(p),
25
+ 'src/site.config.jsx': siteConfig(p),
26
+ 'src/dashboard.config.js': dashboardConfig(p),
27
+ 'src/components/AuthLayout.jsx': authLayout(),
28
+ 'src/components/RequireAuth.jsx': requireAuth(),
29
+ 'src/pages/Home.jsx': homePage(p),
30
+ 'src/pages/NotFound.jsx': notFound(),
31
+ 'src/pages/SignIn.jsx': signIn(p),
32
+ 'src/pages/SignUp.jsx': signUp(p),
33
+ 'src/pages/AuthPages.jsx': authPages(p),
34
+ 'src/pages/Workspaces.jsx': workspacesPage(p),
35
+ 'src/pages/dashboard/Dashboard.jsx': dashboardPage(),
36
+ 'src/pages/dashboard/Overview.jsx': overviewPage(p),
37
+ 'src/pages/dashboard/Notes.jsx': notesPage(),
38
+ 'src/pages/dashboard/AccountMenu.jsx': accountMenu(p),
39
+ };
40
+ }
41
+
42
+ function packageJson(p) {
43
+ return `${JSON.stringify({
44
+ name: `${p.slug}-frontend`,
45
+ private: true,
46
+ version: '0.0.0',
47
+ type: 'module',
48
+ scripts: {
49
+ theme: 'okjt-theme',
50
+ predev: 'okjt-theme',
51
+ dev: 'vite',
52
+ prebuild: 'okjt-theme',
53
+ build: 'vite build',
54
+ preview: 'vite preview',
55
+ },
56
+ dependencies: {
57
+ '@okjt/dashboard': '^0.2.2',
58
+ '@okjt/site': '^0.1.0',
59
+ '@okjt/ui': '^0.1.0',
60
+ '@tanstack/react-query': '^5.90.12',
61
+ axios: '^1.13.2',
62
+ 'date-fns': '^4.1.0',
63
+ 'framer-motion': '^12.23.26',
64
+ 'lucide-react': '^0.559.0',
65
+ react: '^19.2.0',
66
+ 'react-dom': '^19.2.0',
67
+ 'react-hook-form': '^7.68.0',
68
+ 'react-hot-toast': '^2.6.0',
69
+ 'react-router-dom': '^7.10.1',
70
+ },
71
+ devDependencies: {
72
+ '@tailwindcss/vite': '^4.1.17',
73
+ '@vitejs/plugin-react': '^5.2.0',
74
+ tailwindcss: '^4.1.17',
75
+ vite: '^7.2.4',
76
+ },
77
+ }, null, 2)}\n`;
78
+ }
79
+
80
+ function indexHtml(p) {
81
+ return `<!doctype html>
82
+ <html lang="en">
83
+ <head>
84
+ <meta charset="UTF-8" />
85
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
86
+ <meta name="theme-color" content="${p.colors.accent}" />
87
+ <title>${p.name}</title>
88
+ </head>
89
+ <body>
90
+ <div id="root"></div>
91
+ <script type="module" src="/src/main.jsx"></script>
92
+ </body>
93
+ </html>
94
+ `;
95
+ }
96
+
97
+ function viteConfig() {
98
+ return `import { defineConfig } from 'vite';
99
+ import react from '@vitejs/plugin-react';
100
+ import tailwindcss from '@tailwindcss/vite';
101
+
102
+ // In development the API runs beside this app (cd backend && php artisan serve);
103
+ // when deployed, both are served from one domain.
104
+ export default defineConfig({
105
+ plugins: [react(), tailwindcss()],
106
+ server: {
107
+ proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } },
108
+ },
109
+ });
110
+ `;
111
+ }
112
+
113
+ function okjtConfig(p) {
114
+ const colors = palette(p.colors);
115
+ const body = Object.entries(colors).map(([name, value]) => ` ${name}: ${js(value)},`).join('\n');
116
+ return `/**
117
+ * ${p.name}'s theme for the OKJT kit (@okjt/ui): the colours and fonts every
118
+ * page and the dashboard use (\`text-ink\`, \`bg-accent\`, \`border-line\`).
119
+ * src/okjt-theme.css is generated from it before every \`npm run dev\` and
120
+ * \`npm run build\` (or now: npm run theme).
121
+ *
122
+ * Made from two colours: accent ${p.colors.accent} and ink ${p.colors.ink}. Change any of them.
123
+ */
124
+ export default {
125
+ theme: {
126
+ colors: {
127
+ ${body}
128
+ },
129
+ fonts: {
130
+ sans: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
131
+ display: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
132
+ },
133
+ },
134
+ };
135
+ `;
136
+ }
137
+
138
+ /** A plain wordmark in the brand's ink; replace public/brand/ with the real logo. */
139
+ function wordmark(name, color) {
140
+ const text = name.replace(/&/g, '&amp;').replace(/</g, '&lt;');
141
+ const width = Math.max(80, Math.round(name.length * 15 + 16));
142
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="32" viewBox="0 0 ${width} 32"><text x="0" y="24" font-family="ui-sans-serif, system-ui, sans-serif" font-size="24" font-weight="700" fill="${color}">${text}</text></svg>\n`;
143
+ }
144
+
145
+ function okjtMark() {
146
+ return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#111"/><text x="12" y="16" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="9" font-weight="700" fill="#fff">OKJ</text></svg>\n';
147
+ }
148
+
149
+ function mainJsx() {
150
+ return `import { StrictMode } from 'react';
151
+ import { createRoot } from 'react-dom/client';
152
+ import './index.css';
153
+ import App from './App';
154
+
155
+ createRoot(document.getElementById('root')).render(
156
+ <StrictMode>
157
+ <App />
158
+ </StrictMode>,
159
+ );
160
+ `;
161
+ }
162
+
163
+ function indexCss() {
164
+ return `@import "tailwindcss";
165
+ @import "./okjt-theme.css";
166
+
167
+ /* Let Tailwind see the kit's class names. */
168
+ @source "../node_modules/@okjt/ui/dist";
169
+ @source "../node_modules/@okjt/site/dist";
170
+ @source "../node_modules/@okjt/dashboard/dist";
171
+
172
+ /* "fit": desktop viewports tall enough for the dashboard's fixed-height
173
+ lists (AdminPageLayout \`fill\`), where only the rows scroll. */
174
+ @custom-variant fit (@media (min-width: 64rem) and (min-height: 40rem));
175
+
176
+ body {
177
+ background: var(--color-paper);
178
+ color: var(--color-body);
179
+ font-family: var(--font-sans);
180
+ }
181
+
182
+ /* ---- The dashboard (DashboardShell sets .admin-theme on <html>) ---- */
183
+ /* Light greys used for text: darker in the dashboard, for contrast. */
184
+ .admin-theme {
185
+ --color-tone-400: #79716b;
186
+ --color-tone-500: #66605a;
187
+ }
188
+
189
+ /* Always show where keyboard focus is. */
190
+ .admin-theme :focus-visible {
191
+ outline: 2px solid var(--color-accent);
192
+ outline-offset: 2px;
193
+ }
194
+
195
+ @media (prefers-reduced-motion: reduce) {
196
+ .admin-theme *,
197
+ .admin-theme *::before,
198
+ .admin-theme *::after {
199
+ animation-duration: 0.01ms !important;
200
+ animation-iteration-count: 1 !important;
201
+ transition-duration: 0.01ms !important;
202
+ scroll-behavior: auto !important;
203
+ }
204
+ }
205
+
206
+ /* View-only people: fields in an EditableFieldset are disabled. */
207
+ .admin-theme fieldset:disabled :is(input, select, textarea, button, label) {
208
+ cursor: not-allowed;
209
+ }
210
+ `;
211
+ }
212
+
213
+ function appJsx(p) {
214
+ const w = p.workspace;
215
+ return lines(
216
+ "import { lazy, Suspense } from 'react';",
217
+ "import { BrowserRouter, Route, Routes } from 'react-router-dom';",
218
+ "import { QueryClient, QueryClientProvider } from '@tanstack/react-query';",
219
+ "import { Toaster } from 'react-hot-toast';",
220
+ "import { AuthProvider } from '@okjt/dashboard';",
221
+ "import { SiteProvider } from '@okjt/site';",
222
+ "import api from './services/api';",
223
+ "import { site } from './site.config';",
224
+ "import RequireAuth from './components/RequireAuth';",
225
+ "import Home from './pages/Home';",
226
+ '',
227
+ "const SignIn = lazy(() => import('./pages/SignIn'));",
228
+ "const SignUp = lazy(() => import('./pages/SignUp'));",
229
+ "const AuthPages = lazy(() => import('./pages/AuthPages'));",
230
+ "const Workspaces = lazy(() => import('./pages/Workspaces'));",
231
+ "const Dashboard = lazy(() => import('./pages/dashboard/Dashboard'));",
232
+ "const Overview = lazy(() => import('./pages/dashboard/Overview'));",
233
+ "const Notes = lazy(() => import('./pages/dashboard/Notes'));",
234
+ "const NotFound = lazy(() => import('./pages/NotFound'));",
235
+ '',
236
+ 'const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });',
237
+ '',
238
+ '/**',
239
+ ` * ${p.name}: the public pages (/), signing in and up, your ${w.plural} (/${w.path}),`,
240
+ ` * and the dashboard of the ${w.label} you open (/dashboard).`,
241
+ ' */',
242
+ 'export default function App() {',
243
+ ' return (',
244
+ ' <QueryClientProvider client={queryClient}>',
245
+ ' <BrowserRouter>',
246
+ ' <AuthProvider api={api}>',
247
+ ' <SiteProvider site={site}>',
248
+ ' <Toaster position="top-right" />',
249
+ ' <Suspense fallback={null}>',
250
+ ' <Routes>',
251
+ ' <Route path="/" element={<Home />} />',
252
+ ' <Route path="/sign-in" element={<SignIn />} />',
253
+ ' <Route path="/sign-up" element={<SignUp />} />',
254
+ p.features.google && ' <Route path="/auth/google" element={<AuthPages page="google" />} />',
255
+ ' <Route path="/verify-email/:id/:hash" element={<AuthPages page="verify" />} />',
256
+ ' <Route path="/accept-invite" element={<AuthPages page="invite" />} />',
257
+ ` <Route path="/${w.path}" element={<RequireAuth><Workspaces /></RequireAuth>} />`,
258
+ ' <Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>}>',
259
+ ' <Route index element={<Overview />} />',
260
+ ' <Route path="notes" element={<Notes />} />',
261
+ ' </Route>',
262
+ ' <Route path="*" element={<NotFound />} />',
263
+ ' </Routes>',
264
+ ' </Suspense>',
265
+ ' </SiteProvider>',
266
+ ' </AuthProvider>',
267
+ ' </BrowserRouter>',
268
+ ' </QueryClientProvider>',
269
+ ' );',
270
+ '}',
271
+ '',
272
+ );
273
+ }
274
+
275
+ function apiJs(p) {
276
+ return `import axios from 'axios';
277
+ import { toast } from 'react-hot-toast';
278
+ import { currentWorkspace } from '../workspace';
279
+
280
+ /**
281
+ * The API client: sends your token, and inside the dashboard the ${p.workspace.label}
282
+ * you're working on (X-Workspace); answers are unwrapped from the kit's
283
+ * { success, message, data } to their data.
284
+ */
285
+ const api = axios.create({
286
+ baseURL: import.meta.env.VITE_API_URL || '/api',
287
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
288
+ });
289
+
290
+ api.interceptors.request.use((config) => {
291
+ const token = localStorage.getItem('auth_token');
292
+ if (token) config.headers.Authorization = \`Bearer \${token}\`;
293
+ const slug = currentWorkspace();
294
+ if (slug && window.location.pathname.startsWith('/dashboard') && !config.headers['X-Workspace']) {
295
+ config.headers['X-Workspace'] = slug;
296
+ }
297
+ return config;
298
+ });
299
+
300
+ api.interceptors.response.use(
301
+ (response) => {
302
+ const payload = response.data;
303
+ if (payload && typeof payload === 'object' && 'success' in payload && 'data' in payload) response.data = payload.data;
304
+ return response;
305
+ },
306
+ (error) => {
307
+ if (error.response?.status === 403 && error.response.data?.reason === 'role') {
308
+ toast.error(error.response.data.message || 'You don’t have permission to do that.', { id: 'role-denied' });
309
+ }
310
+ if (error.response?.status === 401) {
311
+ localStorage.removeItem('auth_token');
312
+ if (window.location.pathname.startsWith('/dashboard')) window.location.assign('/sign-in');
313
+ }
314
+ return Promise.reject(error);
315
+ },
316
+ );
317
+
318
+ export default api;
319
+ `;
320
+ }
321
+
322
+ function workspaceJs(p) {
323
+ const w = p.workspace;
324
+ return `import { useQuery } from '@tanstack/react-query';
325
+ import api from './services/api';
326
+
327
+ /** The ${w.label} the dashboard works on: its address (slug), remembered in this browser. */
328
+ const KEY = 'workspace';
329
+
330
+ export function currentWorkspace() {
331
+ try { return localStorage.getItem(KEY); } catch { return null; }
332
+ }
333
+
334
+ export function openWorkspace(slug) {
335
+ try { localStorage.setItem(KEY, slug); } catch { /* private window: this visit only */ }
336
+ }
337
+
338
+ /** Your ${w.plural}: [{ id, name, slug, status, role }]. */
339
+ export function useWorkspaces() {
340
+ return useQuery({ queryKey: ['workspaces'], queryFn: () => api.get('/${w.path}').then((res) => res.data) });
341
+ }
342
+ `;
343
+ }
344
+
345
+ function siteConfig(p) {
346
+ const w = p.workspace;
347
+ return `import { FileText, ShieldCheck } from 'lucide-react';
348
+ import { useAuth } from '@okjt/dashboard';
349
+
350
+ /**
351
+ * ${p.name}'s public pages (@okjt/site): the words, links and pictures the
352
+ * kit's layout and sections show. Colours and fonts are in okjt.config.js.
353
+ */
354
+ export const site = {
355
+ brand: { name: ${js(p.name)}, logo: { light: '/brand/logo-light.svg', dark: '/brand/logo-dark.svg' } },
356
+
357
+ nav: [
358
+ { to: '/', label: 'Home' },
359
+ { to: '/#faq', label: 'Questions' },
360
+ ],
361
+
362
+ useAccount: () => ({ signedIn: useAuth().isAuthenticated }),
363
+ accountLinks: {
364
+ signIn: { to: '/sign-in', label: 'Sign in' },
365
+ signUp: { to: '/sign-up', label: 'Get started' },
366
+ home: { to: '/${w.path}', label: ${js(`Your ${w.plural}`)} },
367
+ },
368
+
369
+ // Photos are keys; add pictures to public/photos and name them here.
370
+ usePhotos: () => ({ url: (key) => \`/photos/\${key}.jpg\`, alt: () => '' }),
371
+ usePlaces: () => () => null,
372
+ media: {},
373
+
374
+ footer: {
375
+ about: ${js(p.tagline)},
376
+ columns: [
377
+ { title: 'Product', links: [{ to: '/sign-up', label: 'Get started', signedIn: false }, { to: '/sign-in', label: 'Sign in', signedIn: false }] },
378
+ ],
379
+ promises: [{ icon: ShieldCheck, text: 'Your data stays yours' }],
380
+ watermark: ${js(`${p.slug}.`)},
381
+ copyright: ${js(p.name)},
382
+ madeBy: { label: 'Built by', name: 'OKJ Technologies', href: 'https://okjtech.co.ke', logo: '/brand/okjt-mark.svg' },
383
+ },
384
+
385
+ legal: {
386
+ documents: [{ to: '/terms', label: 'Terms of service', icon: FileText }],
387
+ contact: 'Contact OKJ Technologies through okjtech.co.ke.',
388
+ lastUpdated: ${js(new Date().toISOString().slice(0, 10))},
389
+ seeAlso: [],
390
+ },
391
+
392
+ seo: {
393
+ origin: ${js(`https://${p.domain}`)},
394
+ siteName: ${js(p.name)},
395
+ locale: 'en_KE',
396
+ defaultTitle: ${js(p.name)},
397
+ defaultDescription: ${js(p.tagline)},
398
+ pages: { '/': { title: ${js(p.name)}, description: ${js(p.tagline)} } },
399
+ resolve: () => undefined,
400
+ },
401
+ };
402
+ `;
403
+ }
404
+
405
+ function dashboardConfig(p) {
406
+ const w = p.workspace;
407
+ return `import { useActivityPanel, useCollaboration, NotificationsBell } from '@okjt/dashboard';
408
+ import { LayoutDashboard, NotebookPen } from 'lucide-react';
409
+ import api from './services/api';
410
+ import AccountMenu from './pages/dashboard/AccountMenu';
411
+
412
+ /**
413
+ * ${p.name}'s dashboard (@okjt/dashboard): its pages, roles, and shell. Add a
414
+ * module: a page under pages/dashboard, a route in App.jsx, a sidebar item
415
+ * here and an entry in MODULES (the key is the server's $revisionModule).
416
+ */
417
+ export const MODULES = [
418
+ { key: 'overview', path: '/dashboard', label: 'Overview' },
419
+ { key: 'notes', path: '/dashboard/notes', label: 'Notes' },
420
+ ];
421
+
422
+ export const ROLES = ${JSON.stringify(p.roles, null, 2).replace(/"(\w+)":/g, '$1:').replace(/"/g, "'")};
423
+
424
+ export const roleLabel = (role) => ROLES.find((r) => r.value === role)?.label || role;
425
+
426
+ /** The guided tour on a first visit (\`target\` is an element's id). Edit or extend it. */
427
+ export const TUTORIAL_STEPS = [
428
+ { title: 'Welcome', content: 'A quick look around: it takes a minute.', target: 'header', position: 'bottom' },
429
+ { title: 'Overview', content: 'Where you start: the numbers that matter, at a glance.', target: 'sidebar-overview', position: 'right' },
430
+ { title: 'Notes', content: 'Write things down together. Every change is kept, and any version can be restored.', target: 'sidebar-notes', position: 'right' },
431
+ { title: 'You', content: 'Your role, your other ${w.plural}, and signing out.', target: 'header-account', position: 'bottom' },
432
+ ];
433
+
434
+ export const dashboard = {
435
+ home: { label: 'Overview', path: '/dashboard' },
436
+ tutorialSteps: TUTORIAL_STEPS,
437
+ useDefaultPanel: useActivityPanel,
438
+ useAfterChange: () => useCollaboration().refresh,
439
+ api,
440
+ modules: MODULES,
441
+ roles: ROLES,
442
+ roleLabel,
443
+ shell: {
444
+ logo: { src: '/brand/logo-light.svg', alt: ${js(p.name)}, to: '/${w.path}' },
445
+ nav: [
446
+ { items: [{ path: '/dashboard', icon: LayoutDashboard, label: 'Overview', id: 'sidebar-overview' }] },
447
+ { title: 'Work', items: [{ path: '/dashboard/notes', icon: NotebookPen, label: 'Notes', id: 'sidebar-notes' }] },
448
+ ],
449
+ quickLinks: [],
450
+ Notifications: NotificationsBell,
451
+ AccountMenu,
452
+ },
453
+ };
454
+ `;
455
+ }
456
+
457
+ function authLayout() {
458
+ return `import { AuthLayout as KitAuthLayout } from '@okjt/dashboard';
459
+ import { useSite } from '@okjt/site';
460
+
461
+ /** The sign-in pages' frame (the kit's), with this project's logo. */
462
+ export default function AuthLayout(props) {
463
+ const { brand } = useSite();
464
+ return <KitAuthLayout brand={<img src={brand.logo.dark} alt={brand.name} className="h-8 w-auto" />} {...props} />;
465
+ }
466
+ `;
467
+ }
468
+
469
+ function requireAuth() {
470
+ return `import { Navigate, useLocation } from 'react-router-dom';
471
+ import { useAuth } from '@okjt/dashboard';
472
+
473
+ /** Signed-in pages: everyone else goes to sign in, and comes back after. */
474
+ export default function RequireAuth({ children }) {
475
+ const { isAuthenticated, loading } = useAuth();
476
+ const location = useLocation();
477
+ if (loading) return null;
478
+ if (!isAuthenticated) return <Navigate to={\`/sign-in?next=\${encodeURIComponent(location.pathname)}\`} replace />;
479
+ return children;
480
+ }
481
+ `;
482
+ }
483
+
484
+ function homePage(p) {
485
+ const w = p.workspace;
486
+ return `import { Container, FaqSection, PillLink, SectionHeading, SiteLayout } from '@okjt/site';
487
+
488
+ const FAQ = [
489
+ { q: 'Who can see my ${w.plural}?', a: 'Only the people you invite, with the role you give them.' },
490
+ { q: 'Can I undo a change?', a: 'Yes: every change is kept in the version history, and any version can be restored.' },
491
+ { q: 'Can we work at the same time?', a: 'Yes: you see who else is there, and what changed since you last looked.' },
492
+ ];
493
+
494
+ /** The home page: what ${p.name} is, a way in, and questions. Replace the words with your own. */
495
+ export default function Home() {
496
+ return (
497
+ <SiteLayout cta={false}>
498
+ <section className="pt-36 pb-24 sm:pt-44">
499
+ <Container>
500
+ <SectionHeading
501
+ as="h1"
502
+ eyebrow=${js(p.name)}
503
+ title=${js(p.tagline)}
504
+ text="Everything your team works on, in one place: who's doing what, what changed, and nothing lost."
505
+ />
506
+ <div className="mt-10 flex flex-wrap justify-center gap-3">
507
+ <PillLink to="/sign-up" size="lg">Get started</PillLink>
508
+ <PillLink to="/sign-in" variant="light" size="lg" arrow>Sign in</PillLink>
509
+ </div>
510
+ </Container>
511
+ </section>
512
+ <section className="pb-24">
513
+ <Container>
514
+ <FaqSection items={FAQ} />
515
+ </Container>
516
+ </section>
517
+ </SiteLayout>
518
+ );
519
+ }
520
+ `;
521
+ }
522
+
523
+ function notFound() {
524
+ return `import { Container, PillLink, SectionHeading, SiteLayout } from '@okjt/site';
525
+
526
+ export default function NotFound() {
527
+ return (
528
+ <SiteLayout cta={false}>
529
+ <section className="pt-40 pb-32">
530
+ <Container>
531
+ <SectionHeading as="h1" eyebrow="404" title="This page isn’t here." text="It may have moved, or the address has a typo." />
532
+ <div className="mt-10 flex justify-center"><PillLink to="/">Home</PillLink></div>
533
+ </Container>
534
+ </section>
535
+ </SiteLayout>
536
+ );
537
+ }
538
+ `;
539
+ }
540
+
541
+ function signIn(p) {
542
+ return lines(
543
+ "import { useEffect } from 'react';",
544
+ "import { Link, useNavigate, useSearchParams } from 'react-router-dom';",
545
+ `import { ${p.features.google ? 'GoogleButton, ' : ''}SignInPage, useAuth } from '@okjt/dashboard';`,
546
+ "import AuthLayout from '../components/AuthLayout';",
547
+ '',
548
+ '/** Only paths on this site (never "//elsewhere.example"). */',
549
+ "const safeNext = (next) => typeof next === 'string' && next.startsWith('/') && !next.startsWith('//');",
550
+ '',
551
+ `/** Signing in (the kit's page), then on to where you were going, or your ${p.workspace.plural}. */`,
552
+ 'export default function SignIn() {',
553
+ ' const navigate = useNavigate();',
554
+ ' const [params] = useSearchParams();',
555
+ ` const next = safeNext(params.get('next')) ? params.get('next') : '/${p.workspace.path}';`,
556
+ ' const { isAuthenticated } = useAuth();',
557
+ '',
558
+ ' useEffect(() => {',
559
+ ' if (isAuthenticated) navigate(next, { replace: true });',
560
+ ' }, [isAuthenticated, navigate, next]);',
561
+ '',
562
+ ' return (',
563
+ ' <SignInPage',
564
+ ' layout={AuthLayout}',
565
+ ' onSignedIn={() => navigate(next, { replace: true })}',
566
+ ` subtitle=${js(`Sign in to ${p.name}.`)}`,
567
+ ` footer={<>New here? <Link to="/sign-up" className="font-medium text-accent-strong hover:underline">Create an account</Link></>}`,
568
+ ` forgotHint="Forgot it? Ask an owner to invite you again."`,
569
+ p.features.google && ' google={<GoogleButton />}',
570
+ ' />',
571
+ ' );',
572
+ '}',
573
+ '',
574
+ );
575
+ }
576
+
577
+ function signUp(p) {
578
+ return `import { useState } from 'react';
579
+ import { Link } from 'react-router-dom';
580
+ import { AuthError, AuthField, AuthSubmit, useAuth } from '@okjt/dashboard';
581
+ import AuthLayout from '../components/AuthLayout';
582
+
583
+ /** Creating an account; then the verification email (the server asks for it before the first sign-in). */
584
+ export default function SignUp() {
585
+ const { register } = useAuth();
586
+ const [form, setForm] = useState({ name: '', email: '', password: '', password_confirmation: '' });
587
+ const [errors, setErrors] = useState({});
588
+ const [error, setError] = useState(null);
589
+ const [sentTo, setSentTo] = useState(null);
590
+ const [saving, setSaving] = useState(false);
591
+ const set = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.value }));
592
+
593
+ const onSubmit = async (e) => {
594
+ e.preventDefault();
595
+ setSaving(true);
596
+ setError(null);
597
+ const result = await register(form);
598
+ setSaving(false);
599
+ if (result.requires_verification) return setSentTo(result.email);
600
+ setErrors(Object.fromEntries(Object.entries(result.errors || {}).map(([k, v]) => [k, v[0]])));
601
+ setError(result.message);
602
+ };
603
+
604
+ if (sentTo) {
605
+ return (
606
+ <AuthLayout title="Check your inbox" subtitle={<>We sent a link to <strong className="text-body">{sentTo}</strong>. Open it to verify your address, then sign in.</>}
607
+ footer={<Link to="/sign-in" className="hover:text-body">← Back to sign in</Link>} />
608
+ );
609
+ }
610
+
611
+ return (
612
+ <AuthLayout title=${js(`Join ${p.name}`)} subtitle="Create your account. It takes a minute."
613
+ footer={<>Already have one? <Link to="/sign-in" className="font-medium text-accent-strong hover:underline">Sign in</Link></>}>
614
+ <AuthError>{error}</AuthError>
615
+ <form onSubmit={onSubmit} className="space-y-5" noValidate>
616
+ <AuthField id="sign-up-name" label="Your name" autoComplete="name" value={form.name} onChange={set('name')} error={errors.name} required />
617
+ <AuthField id="sign-up-email" label="Email" type="email" autoComplete="email" value={form.email} onChange={set('email')} error={errors.email} required />
618
+ <AuthField id="sign-up-password" label="Password" type="password" autoComplete="new-password" placeholder="At least 8 characters" value={form.password} onChange={set('password')} error={errors.password} required />
619
+ <AuthField id="sign-up-password-confirm" label="Confirm password" type="password" autoComplete="new-password" value={form.password_confirmation} onChange={set('password_confirmation')} required />
620
+ <div className="pt-2"><AuthSubmit loading={saving}>Create account</AuthSubmit></div>
621
+ </form>
622
+ </AuthLayout>
623
+ );
624
+ }
625
+ `;
626
+ }
627
+
628
+ function authPages(p) {
629
+ return lines(
630
+ "import { useNavigate } from 'react-router-dom';",
631
+ `import { AcceptInvitePage, ${p.features.google ? 'GoogleCallbackPage, ' : ''}VerifyEmailPage } from '@okjt/dashboard';`,
632
+ "import api from '../services/api';",
633
+ "import AuthLayout from '../components/AuthLayout';",
634
+ '',
635
+ `/** Where emailed links${p.features.google ? ' and Google sign-in' : ''} land (the kit's pages), then on to your ${p.workspace.plural}. */`,
636
+ 'export default function AuthPages({ page }) {',
637
+ ' const navigate = useNavigate();',
638
+ ` const home = () => navigate('/${p.workspace.path}', { replace: true });`,
639
+ '',
640
+ p.features.google && " if (page === 'google') return <GoogleCallbackPage layout={AuthLayout} onSignedIn={home} />;",
641
+ ` if (page === 'verify') return <VerifyEmailPage layout={AuthLayout} next="/${p.workspace.path}" />;`,
642
+ ' return (',
643
+ ' <AcceptInvitePage',
644
+ ' layout={AuthLayout}',
645
+ " accept={(payload) => api.post('/collaborators/accept', payload).then((res) => res.data)}",
646
+ ' onSignedIn={home}',
647
+ ' />',
648
+ ' );',
649
+ '}',
650
+ '',
651
+ );
652
+ }
653
+
654
+ function workspacesPage(p) {
655
+ const w = p.workspace;
656
+ const Title = w.plural.charAt(0).toUpperCase() + w.plural.slice(1);
657
+ const Label = w.label.charAt(0).toUpperCase() + w.label.slice(1);
658
+ return `import { useState } from 'react';
659
+ import { useNavigate } from 'react-router-dom';
660
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
661
+ import { ArrowRight, Plus } from 'lucide-react';
662
+ import { AuthError, AuthField, AuthSubmit, useAuth } from '@okjt/dashboard';
663
+ import AuthLayout from '../components/AuthLayout';
664
+ import api from '../services/api';
665
+ import { openWorkspace, useWorkspaces } from '../workspace';
666
+ import { roleLabel } from '../dashboard.config';
667
+
668
+ /** Your ${w.plural}: open one, or start a new one. */
669
+ export default function Workspaces() {
670
+ const navigate = useNavigate();
671
+ const queryClient = useQueryClient();
672
+ const { user, logout } = useAuth();
673
+ const { data: workspaces = [], isLoading } = useWorkspaces();
674
+ const [name, setName] = useState('');
675
+ const [error, setError] = useState(null);
676
+
677
+ const open = (slug) => {
678
+ openWorkspace(slug);
679
+ navigate('/dashboard');
680
+ };
681
+
682
+ const create = useMutation({
683
+ mutationFn: () => api.post('/${w.path}', { name }).then((res) => res.data),
684
+ onSuccess: (created) => {
685
+ // In the list before the dashboard looks for it there.
686
+ queryClient.setQueryData(['workspaces'], (list = []) => [...list, created]);
687
+ open(created.slug);
688
+ },
689
+ onError: (err) => setError(err.response?.data?.message || 'Something went wrong. Try again.'),
690
+ });
691
+
692
+ return (
693
+ <AuthLayout
694
+ title=${js(`Your ${w.plural}`)}
695
+ subtitle={user ? \`Signed in as \${user.email}.\` : null}
696
+ footer={<button type="button" onClick={logout} className="hover:text-body">Sign out</button>}
697
+ >
698
+ {!isLoading && workspaces.length > 0 && (
699
+ <ul className="mb-8 space-y-2">
700
+ {workspaces.map((w) => (
701
+ <li key={w.id}>
702
+ <button type="button" onClick={() => open(w.slug)} className="flex w-full items-center justify-between gap-3 rounded-2xl border border-line bg-white px-5 py-4 text-left hover:border-accent">
703
+ <span>
704
+ <span className="block text-sm font-semibold text-ink">{w.name}</span>
705
+ <span className="block text-xs text-muted">{roleLabel(w.role)}</span>
706
+ </span>
707
+ <ArrowRight className="h-4 w-4 text-accent" aria-hidden="true" />
708
+ </button>
709
+ </li>
710
+ ))}
711
+ </ul>
712
+ )}
713
+ <AuthError>{error}</AuthError>
714
+ <form onSubmit={(e) => { e.preventDefault(); setError(null); create.mutate(); }} className="space-y-4">
715
+ <AuthField id="new-workspace" label=${js(`Start a new ${w.label}`)} placeholder=${js(`${Label} name`)} value={name} onChange={(e) => setName(e.target.value)} required />
716
+ <AuthSubmit loading={create.isPending}><Plus className="h-4 w-4" aria-hidden="true" /> Create</AuthSubmit>
717
+ </form>
718
+ {!isLoading && workspaces.length === 0 && <p className="mt-6 text-sm text-muted">${Title} you start or are invited to show up here.</p>}
719
+ </AuthLayout>
720
+ );
721
+ }
722
+ `;
723
+ }
724
+
725
+ function dashboardPage() {
726
+ return `import { Navigate, Outlet } from 'react-router-dom';
727
+ import { DashboardProvider, DashboardShell } from '@okjt/dashboard';
728
+ import { PermissionsProvider } from '@okjt/ui';
729
+ import { dashboard } from '../../dashboard.config';
730
+ import { currentWorkspace, useWorkspaces } from '../../workspace';
731
+
732
+ /** The dashboard of the workspace you opened, with your role in it (viewers may only look). */
733
+ export default function Dashboard() {
734
+ const { data: workspaces, isLoading } = useWorkspaces();
735
+ if (isLoading) return null;
736
+ const workspace = workspaces?.find((w) => w.slug === currentWorkspace());
737
+ if (!workspace) return <Navigate to={dashboard.shell.logo.to} replace />;
738
+
739
+ const permissions = { role: workspace.role, isOwner: workspace.role === 'owner', canEdit: workspace.role !== 'viewer' };
740
+ return (
741
+ <PermissionsProvider value={permissions}>
742
+ <DashboardProvider dashboard={dashboard}>
743
+ <DashboardShell>
744
+ <Outlet context={{ workspace }} />
745
+ </DashboardShell>
746
+ </DashboardProvider>
747
+ </PermissionsProvider>
748
+ );
749
+ }
750
+ `;
751
+ }
752
+
753
+ function overviewPage(p) {
754
+ return `import { Link, useOutletContext } from 'react-router-dom';
755
+ import { useQuery } from '@tanstack/react-query';
756
+ import { NotebookPen } from 'lucide-react';
757
+ import { AdminCard, AdminPageHero, AdminPageLayout, AdminSummaryCards } from '@okjt/dashboard';
758
+ import api from '../../services/api';
759
+
760
+ /** The ${p.workspace.label}'s front page: a few numbers and where to go next. */
761
+ export default function Overview() {
762
+ const { workspace } = useOutletContext();
763
+ const { data: notes = [] } = useQuery({ queryKey: ['notes'], queryFn: () => api.get('/notes').then((res) => res.data) });
764
+
765
+ return (
766
+ <AdminPageLayout
767
+ hero={<AdminPageHero title={workspace.name} breadcrumb="Overview" description="Everything in this ${p.workspace.label}, at a glance." />}
768
+ summary={<AdminSummaryCards cards={[{ label: 'Notes', value: notes.length, icon: NotebookPen }]} />}
769
+ >
770
+ <AdminCard>
771
+ <p className="text-sm text-soft">
772
+ Start with <Link to="/dashboard/notes" className="font-medium text-accent-strong hover:underline">Notes</Link>: every change is kept in the version history, and the people you invite (Share, in the header) see who else is here.
773
+ </p>
774
+ </AdminCard>
775
+ </AdminPageLayout>
776
+ );
777
+ }
778
+ `;
779
+ }
780
+
781
+ function notesPage() {
782
+ return `import { useState } from 'react';
783
+ import { useQuery } from '@tanstack/react-query';
784
+ import { NotebookPen, Plus } from 'lucide-react';
785
+ import {
786
+ AdminInput, AdminListPanel, AdminModal, AdminPageHero, AdminPageLayout, AdminTextarea,
787
+ DeleteButton, EmptyState, useAdminAction, useFilteredItems, useSearch,
788
+ } from '@okjt/dashboard';
789
+ import api from '../../services/api';
790
+
791
+ /**
792
+ * The starter module: a list with search, add and edit in a dialog, delete
793
+ * with a confirmation. Saving goes through useAdminAction (confirm, pending,
794
+ * toast, refresh; hidden for viewers). Copy this page for your own modules.
795
+ */
796
+ export default function Notes() {
797
+ const { data: notes = [], isLoading } = useQuery({ queryKey: ['notes'], queryFn: () => api.get('/notes').then((res) => res.data) });
798
+ const { searchQuery } = useSearch();
799
+ const shown = useFilteredItems(notes, searchQuery);
800
+ const [editing, setEditing] = useState(null);
801
+
802
+ const save = useAdminAction(
803
+ (note) => (note.id ? api.put(\`/notes/\${note.id}\`, note) : api.post('/notes', note)),
804
+ { success: 'Note saved', invalidate: [['notes']], onSuccess: () => setEditing(null) },
805
+ );
806
+ const remove = useAdminAction((id) => api.delete(\`/notes/\${id}\`), {
807
+ confirm: 'Delete this note?', success: 'Note deleted', invalidate: [['notes']],
808
+ });
809
+
810
+ return (
811
+ <AdminPageLayout
812
+ fill
813
+ hero={<AdminPageHero title="Notes" breadcrumb="Notes" description="Decisions, reminders and anything worth writing down." />}
814
+ search="Search notes"
815
+ actions={[{ id: 'add-note', label: 'Add note', icon: Plus, onClick: () => setEditing({ title: '', body: '' }) }]}
816
+ >
817
+ <AdminListPanel>
818
+ {shown.length === 0 ? (
819
+ <EmptyState icon={NotebookPen} loading={isLoading} searchQuery={searchQuery} message="No notes yet." />
820
+ ) : (
821
+ <ul className="divide-y divide-line">
822
+ {shown.map((note) => (
823
+ <li key={note.id} className="flex items-start justify-between gap-4 px-5 py-4">
824
+ <button type="button" onClick={() => setEditing(note)} className="min-w-0 text-left">
825
+ <span className="block font-semibold text-ink">{note.title}</span>
826
+ {note.body && <span className="mt-1 block truncate text-sm text-soft">{note.body}</span>}
827
+ </button>
828
+ <DeleteButton onClick={() => remove.run(note.id)} />
829
+ </li>
830
+ ))}
831
+ </ul>
832
+ )}
833
+ </AdminListPanel>
834
+
835
+ <AdminModal isOpen={!!editing} onClose={() => setEditing(null)} title={editing?.id ? 'Edit note' : 'Add note'}>
836
+ {editing && (
837
+ <form onSubmit={(e) => { e.preventDefault(); save.run(editing); }} className="space-y-4">
838
+ <AdminInput id="note-title" label="Title" value={editing.title} onChange={(e) => setEditing({ ...editing, title: e.target.value })} required />
839
+ <AdminTextarea id="note-body" label="Note" rows={6} value={editing.body || ''} onChange={(e) => setEditing({ ...editing, body: e.target.value })} />
840
+ <div className="flex justify-end">
841
+ <button type="submit" disabled={save.pending} className="rounded-full bg-accent px-5 py-2.5 text-sm font-semibold text-white hover:bg-accent-strong disabled:opacity-60">Save</button>
842
+ </div>
843
+ </form>
844
+ )}
845
+ </AdminModal>
846
+ </AdminPageLayout>
847
+ );
848
+ }
849
+ `;
850
+ }
851
+
852
+ function accountMenu(p) {
853
+ const w = p.workspace;
854
+ return `import { ArrowLeftRight } from 'lucide-react';
855
+ import { AccountMenu as KitAccountMenu } from '@okjt/dashboard';
856
+ import { usePermissions } from '@okjt/ui';
857
+ import { roleLabel } from '../../dashboard.config';
858
+
859
+ /** The header's account menu (the kit's): your role here, back to your ${w.plural}, log out. */
860
+ export default function AccountMenu({ compactLinks }) {
861
+ const { role } = usePermissions();
862
+ return (
863
+ <KitAccountMenu
864
+ compactLinks={compactLinks}
865
+ roleText={roleLabel(role)}
866
+ fallbackName="You"
867
+ extra={({ itemClass }) => (
868
+ <a href="/${w.path}" role="menuitem" className={\`\${itemClass} border-b border-tone-100 mb-1\`}>
869
+ <ArrowLeftRight className="w-4 h-4 text-accent" />
870
+ ${'Your ' + w.plural}
871
+ </a>
872
+ )}
873
+ />
874
+ );
875
+ }
876
+ `;
877
+ }