@xeplr/cli 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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/bin/xeplr.js +77 -0
  4. package/lib/commands/encrypt.js +144 -0
  5. package/lib/generate/index.js +147 -0
  6. package/lib/nextSteps.js +39 -0
  7. package/lib/plan.js +44 -0
  8. package/lib/prompt.js +130 -0
  9. package/lib/questions.js +125 -0
  10. package/package.json +39 -0
  11. package/templates/api/app.js +21 -0
  12. package/templates/api/bin/www +62 -0
  13. package/templates/api/db/setup.js +10 -0
  14. package/templates/api/development.env.tpl +47 -0
  15. package/templates/api/env.required.js +29 -0
  16. package/templates/api/gitignore.tpl +2 -0
  17. package/templates/api/migrations/0001_tasks.sql +23 -0
  18. package/templates/api/migrations-auth/0001_home_menu.sql +14 -0
  19. package/templates/api/migrations-auth/0002_tasks_menu.sql +11 -0
  20. package/templates/api/models/Task.js +48 -0
  21. package/templates/api/models/index.js +7 -0
  22. package/templates/api/package.json +30 -0
  23. package/templates/api/routes/index.js +46 -0
  24. package/templates/ui/dotenv.tpl +8 -0
  25. package/templates/ui/gitignore.tpl +2 -0
  26. package/templates/ui/index.html +12 -0
  27. package/templates/ui/package.json +21 -0
  28. package/templates/ui/public/logo-wide.svg +6 -0
  29. package/templates/ui/public/logo.svg +4 -0
  30. package/templates/ui/src/App.jsx +84 -0
  31. package/templates/ui/src/api/tasks.js +34 -0
  32. package/templates/ui/src/index.css +275 -0
  33. package/templates/ui/src/main.jsx +27 -0
  34. package/templates/ui/src/pages/Home.jsx +73 -0
  35. package/templates/ui/src/pages/Tasks.jsx +106 -0
  36. package/templates/ui/src/pages/TasksHelp.jsx +70 -0
  37. package/templates/ui/src/useTasksController.js +83 -0
  38. package/templates/ui/vite.config.js +50 -0
@@ -0,0 +1,8 @@
1
+ # __NAME__ UI — dev port and the two services it talks to.
2
+ #
3
+ # Read by vite.config.js, so no port is written in code. Change a number here
4
+ # and the dev server and every proxy target follow.
5
+
6
+ UI_PORT=__UI_PORT__
7
+ AUTH_URL=http://localhost:__AUTH_PORT__
8
+ API_URL=http://localhost:__API_PORT__
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ dist/
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>__NAME__</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.jsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "__NAME__-ui",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@xeplr/ui-account": "^1.0.6",
13
+ "react": "^19.2.4",
14
+ "react-dom": "^19.2.4",
15
+ "react-router-dom": "^7.13.2"
16
+ },
17
+ "devDependencies": {
18
+ "@vitejs/plugin-react": "^5.2.0",
19
+ "vite": "^7.3.1"
20
+ }
21
+ }
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 48" width="200" height="48" role="img" aria-label="Sample logo">
2
+ <rect x="2" y="8" width="32" height="32" rx="8" fill="#6366f1"/>
3
+ <path d="M10 16h16M18 16v16M14 32h8" stroke="#fff" stroke-width="3.5" stroke-linecap="round"/>
4
+ <text x="44" y="31" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
5
+ font-size="18" font-weight="600" fill="currentColor">Your App</text>
6
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Sample logo">
2
+ <rect width="64" height="64" rx="14" fill="#6366f1"/>
3
+ <path d="M20 21h24M32 21v22M24 43h16" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
4
+ </svg>
@@ -0,0 +1,84 @@
1
+ import { useMemo } from 'react'
2
+ import { Routes, Route, Outlet, Navigate, useNavigate } from 'react-router-dom'
3
+ import { authRoutes, authPath, NavPage, ProtectedRoute } from '@xeplr/ui-account'
4
+ import Home from './pages/Home.jsx'
5
+ import Tasks from './pages/Tasks.jsx'
6
+
7
+ // The rail is ICON-ONLY when collapsed, so a drawer item without one is
8
+ // invisible until somebody widens the rail. Same 24x24 stroke language the
9
+ // account library's own icons use.
10
+ const TasksIcon = (
11
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
12
+ strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
13
+ <path d="M9 11l3 3L22 4" />
14
+ <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
15
+ </svg>
16
+ )
17
+
18
+ const HomeIcon = (
19
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
20
+ strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
21
+ <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
22
+ <path d="M9 22V12h6v10" />
23
+ </svg>
24
+ )
25
+
26
+ // Module-level (stable reference) so NavPage's memoization actually holds.
27
+ const notifications = { count: 0, onClick: () => {} }
28
+
29
+ // Rendered above the framework's built-in Profile / Change Password section.
30
+ const settingsOverrides = [
31
+ { name: 'Admin', path: authPath('userRoles') }
32
+ ]
33
+
34
+ // Layout route: NavPage and the routed page are independent siblings, and this
35
+ // holds no state of its own — lifting page state up here would re-render the
36
+ // nav on every unrelated change.
37
+ function Shell() {
38
+ const navigate = useNavigate()
39
+
40
+ // The drawer's catalog. A name here must match a seeded `menus` row —
41
+ // @xeplr/ui-account drops unrecognised names SILENTLY, so a typo removes the
42
+ // item with no error anywhere.
43
+ const drawerItems = useMemo(() => [
44
+ { name: 'Home', icon: HomeIcon, clickHandler: () => navigate('/home') },
45
+ { name: 'Tasks', icon: TasksIcon, clickHandler: () => navigate('/tasks') }
46
+ ], [navigate])
47
+
48
+ return (
49
+ <div className="app">
50
+ {/* SAMPLE LOGOS — replace public/logo.svg and public/logo-wide.svg with
51
+ your own. `logo` is the small mark in the top bar and the collapsed
52
+ rail; `expandedLogo` is the wider wordmark the rail swaps to when you
53
+ widen it. */}
54
+ <NavPage
55
+ logo="/logo.svg"
56
+ expandedLogo="/logo-wide.svg"
57
+ notifications={notifications}
58
+ settingsOverrides={settingsOverrides}
59
+ drawerItems={drawerItems}
60
+ />
61
+ <main className="main"><Outlet /></main>
62
+ </div>
63
+ )
64
+ }
65
+
66
+ export default function App() {
67
+ return (
68
+ <Routes>
69
+ {/* @xeplr/ui-account supplies every auth screen — login, register,
70
+ forgot/reset password, activate, profile, change password, and the
71
+ RBAC admin pages. We hand it our Shell so signed-in pages render in
72
+ our own chrome. */}
73
+ {authRoutes({}, { layout: <Shell /> })}
74
+
75
+ <Route element={<ProtectedRoute><Shell /></ProtectedRoute>}>
76
+ <Route path="/" element={<Navigate to="/home" replace />} />
77
+ <Route path="/home" element={<Home />} />
78
+ <Route path="/tasks" element={<Tasks />} />
79
+ </Route>
80
+
81
+ <Route path="*" element={<Navigate to="/auth/login" replace />} />
82
+ </Routes>
83
+ )
84
+ }
@@ -0,0 +1,34 @@
1
+ // MODEL LAYER — plain functions, no React.
2
+ //
3
+ // Kept apart from the screen on purpose: this file could be called from a
4
+ // test, a script, or a different design entirely. `authFetch` attaches the
5
+ // signed-in user's token and returns the PARSED body, throwing on failure.
6
+ import { authFetch } from '@xeplr/ui-account'
7
+
8
+ export async function listTasks() {
9
+ const res = await authFetch('/api/tasks')
10
+ return res.dataArray || []
11
+ }
12
+
13
+ /**
14
+ * Create and update in one call.
15
+ *
16
+ * The API takes a CHANGESET — an array where each entry is judged by what it
17
+ * carries: no id means create, an id means update, an id plus `deleted: true`
18
+ * means remove. One request can do all three, and the whole array is applied
19
+ * in a single transaction, so a later failure undoes the earlier entries
20
+ * rather than leaving half of them written.
21
+ */
22
+ export function saveTasks(changeset) {
23
+ return authFetch('/api/tasks/save', {
24
+ method: 'POST',
25
+ body: JSON.stringify(changeset)
26
+ })
27
+ }
28
+
29
+ export function deleteTasks(ids) {
30
+ return authFetch('/api/tasks/delete', {
31
+ method: 'POST',
32
+ body: JSON.stringify({ ids })
33
+ })
34
+ }
@@ -0,0 +1,275 @@
1
+ :root {
2
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
3
+
4
+ /* The collapsed nav rail's width. It is `position: fixed`, so it sits on top
5
+ of the page rather than pushing it along — the app has to leave the room
6
+ itself. Without this the rail covers the first 60px of every screen, which
7
+ reads as text mysteriously cut off down the left edge rather than as a
8
+ layout problem. */
9
+ --app-rail-width: 60px;
10
+ }
11
+
12
+ body { margin: 0; }
13
+
14
+ .app {
15
+ display: flex;
16
+ flex-direction: column;
17
+ min-height: 100vh;
18
+ padding-left: var(--app-rail-width); /* room for the fixed rail */
19
+ box-sizing: border-box;
20
+
21
+ /* THE PAGE HAS TO JOIN IN, and this belongs HERE rather than on `body`.
22
+ The theme wrapper puts its class on an element INSIDE body, so the
23
+ variables do not exist at body level — setting them there silently
24
+ resolves to nothing and you get a white page framed by dark chrome.
25
+ Everything works; it just looks like two products bolted together. */
26
+ background: var(--xeplr-bg-primary);
27
+ color: var(--xeplr-text-primary);
28
+ }
29
+
30
+ .main {
31
+ flex: 1;
32
+ padding: 24px 28px;
33
+ min-width: 0; /* lets wide tables scroll instead of stretching the page */
34
+ }
35
+
36
+ /* A readable block for anything the app prints raw. */
37
+ .main pre {
38
+ background: var(--xeplr-bg-secondary);
39
+ border: 1px solid var(--xeplr-border-primary);
40
+ border-radius: 8px;
41
+ padding: 14px 16px;
42
+ overflow-x: auto;
43
+ }
44
+
45
+ /* ── the starter welcome screen (src/pages/Home.jsx) ─────────────────────── */
46
+
47
+ .app-welcome { max-width: 680px; }
48
+
49
+ .app-welcome-logo { width: 52px; height: 52px; display: block; margin-bottom: 20px; }
50
+
51
+ .app-welcome h1 { font-size: 30px; margin: 0 0 8px; letter-spacing: -0.02em; }
52
+
53
+ .app-welcome-lead {
54
+ margin: 0 0 28px;
55
+ font-size: 16px;
56
+ color: var(--xeplr-text-muted);
57
+ }
58
+
59
+ .app-welcome h2 {
60
+ font-size: 15px;
61
+ text-transform: uppercase;
62
+ letter-spacing: 0.08em;
63
+ color: var(--xeplr-text-muted);
64
+ margin: 32px 0 12px;
65
+ }
66
+
67
+ .app-checks { list-style: none; padding: 0; margin: 0; }
68
+
69
+ .app-check {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 12px;
73
+ padding: 11px 14px;
74
+ margin-bottom: 8px;
75
+ border: 1px solid var(--xeplr-border-primary);
76
+ border-radius: 8px;
77
+ background: var(--xeplr-bg-secondary);
78
+ font-size: 14px;
79
+ }
80
+
81
+ .app-check-dot {
82
+ width: 8px; height: 8px; border-radius: 50%;
83
+ flex-shrink: 0;
84
+ background: var(--xeplr-text-muted);
85
+ }
86
+ .app-check-dot.is-ok { background: #22c55e; }
87
+ .app-check-dot.is-bad { background: #ef4444; }
88
+ /* Pending pulses, so "still checking" never reads as "finished and fine". */
89
+ .app-check-dot.is-pending { background: #eab308; animation: app-pulse 1s ease-in-out infinite; }
90
+
91
+ @keyframes app-pulse { 50% { opacity: 0.25; } }
92
+
93
+ .app-next { margin: 0; padding-left: 20px; font-size: 14px; line-height: 2; }
94
+
95
+ .app-next code {
96
+ background: var(--xeplr-bg-secondary);
97
+ border: 1px solid var(--xeplr-border-primary);
98
+ border-radius: 5px;
99
+ padding: 2px 6px;
100
+ font-size: 13px;
101
+ }
102
+
103
+ .app-welcome-foot {
104
+ margin-top: 32px;
105
+ padding-top: 20px;
106
+ border-top: 1px solid var(--xeplr-border-primary);
107
+ font-size: 13px;
108
+ color: var(--xeplr-text-muted);
109
+ }
110
+
111
+ /* ── pages with a form and a list (src/pages/Tasks.jsx) ──────────────────── */
112
+
113
+ .app-page { max-width: 860px; }
114
+ .app-page h1 { font-size: 26px; margin: 0 0 6px; letter-spacing: -0.02em; }
115
+ .app-page-lead { margin: 0 0 24px; color: var(--xeplr-text-muted); font-size: 14px; }
116
+
117
+ .app-error {
118
+ padding: 10px 14px;
119
+ margin-bottom: 16px;
120
+ border: 1px solid #ef4444;
121
+ border-radius: 8px;
122
+ background: rgba(239, 68, 68, 0.12);
123
+ font-size: 14px;
124
+ }
125
+
126
+ .app-form {
127
+ border: 1px solid var(--xeplr-border-primary);
128
+ background: var(--xeplr-bg-secondary);
129
+ border-radius: 10px;
130
+ padding: 18px;
131
+ margin-bottom: 28px;
132
+ }
133
+
134
+ .app-field { display: block; margin-bottom: 14px; }
135
+ .app-field > span {
136
+ display: block;
137
+ margin-bottom: 6px;
138
+ font-size: 13px;
139
+ font-weight: 600;
140
+ }
141
+
142
+ .app-field input,
143
+ .app-field textarea,
144
+ .app-field select {
145
+ width: 100%;
146
+ box-sizing: border-box;
147
+ padding: 9px 11px;
148
+ border-radius: 6px;
149
+ border: 1px solid var(--xeplr-border-primary);
150
+ background: var(--xeplr-bg-primary);
151
+ color: var(--xeplr-text-primary);
152
+ font: inherit;
153
+ font-size: 14px;
154
+ }
155
+ .app-field input:focus,
156
+ .app-field textarea:focus,
157
+ .app-field select:focus { outline: 2px solid #6366f1; outline-offset: -1px; }
158
+
159
+ .app-field-row { display: flex; gap: 14px; }
160
+ .app-field-row .app-field { flex: 1; }
161
+
162
+ .app-form-actions { display: flex; gap: 10px; margin-top: 4px; }
163
+
164
+ .app-btn {
165
+ padding: 9px 16px;
166
+ border-radius: 6px;
167
+ border: 1px solid transparent;
168
+ background: #6366f1;
169
+ color: #fff;
170
+ font: inherit;
171
+ font-size: 14px;
172
+ cursor: pointer;
173
+ }
174
+ .app-btn:disabled { opacity: 0.55; cursor: default; }
175
+ .app-btn-quiet {
176
+ background: transparent;
177
+ border-color: var(--xeplr-border-primary);
178
+ color: var(--xeplr-text-primary);
179
+ }
180
+
181
+ .app-table { width: 100%; border-collapse: collapse; font-size: 14px; }
182
+ .app-table th {
183
+ text-align: left;
184
+ padding: 8px 12px;
185
+ font-size: 11px;
186
+ letter-spacing: 0.08em;
187
+ text-transform: uppercase;
188
+ color: var(--xeplr-text-muted);
189
+ border-bottom: 1px solid var(--xeplr-border-primary);
190
+ }
191
+ .app-table td { padding: 12px; border-bottom: 1px solid var(--xeplr-border-primary); vertical-align: top; }
192
+ .app-table tr.is-editing { background: var(--xeplr-bg-secondary); }
193
+ .app-sub { color: var(--xeplr-text-muted); font-size: 13px; margin-top: 3px; }
194
+ .app-empty { color: var(--xeplr-text-muted); text-align: center; padding: 28px; }
195
+ .app-row-actions { text-align: right; white-space: nowrap; }
196
+ .app-row-actions .app-btn { padding: 5px 10px; font-size: 13px; margin-left: 6px; }
197
+
198
+ .app-tag {
199
+ display: inline-block;
200
+ padding: 3px 9px;
201
+ border-radius: 999px;
202
+ font-size: 12px;
203
+ border: 1px solid var(--xeplr-border-primary);
204
+ text-transform: capitalize;
205
+ }
206
+ .app-tag.is-done { border-color: #22c55e; color: #22c55e; }
207
+ .app-tag.is-in_progress { border-color: #eab308; color: #eab308; }
208
+
209
+ /* ── the sample page's instruction panel ─────────────────────────────────── */
210
+
211
+ /* Wider than a normal page, because it carries a column of explanation the
212
+ real pages you write will not have. */
213
+ .app-page-split { display: flex; gap: 32px; align-items: flex-start; max-width: 1180px; }
214
+ .app-page-main { flex: 1; min-width: 0; }
215
+
216
+ .app-help {
217
+ flex: 0 0 330px;
218
+ border: 1px solid var(--xeplr-border-primary);
219
+ border-radius: 10px;
220
+ padding: 20px;
221
+ background: var(--xeplr-bg-secondary);
222
+ font-size: 13px;
223
+ line-height: 1.55;
224
+ position: sticky;
225
+ top: 24px;
226
+ }
227
+
228
+ .app-help-badge {
229
+ display: inline-block;
230
+ padding: 2px 9px;
231
+ border-radius: 999px;
232
+ background: #6366f1;
233
+ color: #fff;
234
+ font-size: 11px;
235
+ font-weight: 700;
236
+ letter-spacing: 0.06em;
237
+ text-transform: uppercase;
238
+ margin-bottom: 12px;
239
+ }
240
+
241
+ .app-help h2 { font-size: 16px; margin: 0 0 8px; }
242
+ .app-help h3 {
243
+ font-size: 11px;
244
+ text-transform: uppercase;
245
+ letter-spacing: 0.08em;
246
+ color: var(--xeplr-text-muted);
247
+ margin: 22px 0 10px;
248
+ }
249
+ .app-help p { margin: 0; color: var(--xeplr-text-muted); }
250
+
251
+ .app-help code {
252
+ display: inline-block;
253
+ background: var(--xeplr-bg-primary);
254
+ border: 1px solid var(--xeplr-border-primary);
255
+ border-radius: 4px;
256
+ padding: 1px 5px;
257
+ font-size: 12px;
258
+ word-break: break-all;
259
+ }
260
+
261
+ .app-help-steps { margin: 0; padding-left: 18px; }
262
+ .app-help-steps li { margin-bottom: 14px; }
263
+ .app-help-steps strong { display: block; margin-bottom: 3px; }
264
+ .app-help-steps > li > code { margin-bottom: 4px; }
265
+ .app-help-steps span { display: block; color: var(--xeplr-text-muted); }
266
+
267
+ .app-help-notes { margin: 0; padding-left: 18px; color: var(--xeplr-text-muted); }
268
+ .app-help-notes li { margin-bottom: 10px; }
269
+
270
+ /* One column when there is not room for two — the explanation drops below the
271
+ form rather than squeezing it. */
272
+ @media (max-width: 1080px) {
273
+ .app-page-split { display: block; }
274
+ .app-help { position: static; margin-top: 28px; width: auto; }
275
+ }
@@ -0,0 +1,27 @@
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import { BrowserRouter } from 'react-router-dom'
4
+ import { configure, AccessProvider, ThemeProvider } from '@xeplr/ui-account'
5
+ import App from './App.jsx'
6
+ import './index.css'
7
+ import '@xeplr/ui-account/src/designs/theme.css'
8
+
9
+ // Auth requests are same-origin in dev — vite proxies /auth/api to AUTH_URL
10
+ // (see vite.config.js, which reads it from .env).
11
+ configure('')
12
+
13
+ ReactDOM.createRoot(document.getElementById('root')).render(
14
+ <React.StrictMode>
15
+ <BrowserRouter>
16
+ {/* REQUIRED, not decoration. theme.css defines its colours only inside
17
+ .xeplr-theme-* classes — there is no :root default — so without a
18
+ theme wrapper every var(--xeplr-*) is undefined and the whole UI
19
+ renders unstyled. */}
20
+ <ThemeProvider>
21
+ <AccessProvider>
22
+ <App />
23
+ </AccessProvider>
24
+ </ThemeProvider>
25
+ </BrowserRouter>
26
+ </React.StrictMode>
27
+ )
@@ -0,0 +1,73 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { authFetch, getUser } from '@xeplr/ui-account'
3
+
4
+ // THE STARTER SCREEN — the equivalent of "Welcome to React".
5
+ //
6
+ // It exists to prove the whole chain works and then get out of the way:
7
+ // you are signed in, the UI reached the API, and the API accepted your token.
8
+ // Replace it with your own first page — that is the point of it.
9
+
10
+ function Check({ ok, pending, children }) {
11
+ return (
12
+ <li className="app-check">
13
+ <span className={'app-check-dot' + (pending ? ' is-pending' : ok ? ' is-ok' : ' is-bad')} />
14
+ {children}
15
+ </li>
16
+ )
17
+ }
18
+
19
+ export default function Home() {
20
+ const user = getUser()
21
+ const [api, setApi] = useState({ state: 'checking' })
22
+
23
+ useEffect(() => {
24
+ // authFetch returns the PARSED body — it has already checked the status
25
+ // and thrown on failure, so there is no .json() to call.
26
+ authFetch('/whoami')
27
+ .then(() => setApi({ state: 'ok' }))
28
+ .catch((e) => setApi({ state: 'error', message: e.message }))
29
+ }, [])
30
+
31
+ return (
32
+ <div className="app-welcome">
33
+ <img src="/logo.svg" alt="" className="app-welcome-logo" />
34
+
35
+ <h1>Welcome to your app</h1>
36
+ <p className="app-welcome-lead">
37
+ Everything below is already working. Replace this page and start building.
38
+ </p>
39
+
40
+ <ul className="app-checks">
41
+ <Check ok>
42
+ Signed in as <strong>{user ? user.email : 'unknown'}</strong>
43
+ </Check>
44
+ <Check ok={api.state === 'ok'} pending={api.state === 'checking'}>
45
+ {api.state === 'checking' && 'Talking to your API…'}
46
+ {api.state === 'ok' && <>Your API answered, and accepted your sign-in</>}
47
+ {api.state === 'error' && <>Could not reach your API — {api.message}</>}
48
+ </Check>
49
+ <Check ok>Sign-in, profile, roles and permissions are all set up</Check>
50
+ </ul>
51
+
52
+ <h2>What to change first</h2>
53
+ <ol className="app-next">
54
+ <li>
55
+ <code>src/pages/Home.jsx</code> — this page.
56
+ </li>
57
+ <li>
58
+ <code>public/logo.svg</code> and <code>public/logo-wide.svg</code> — the
59
+ sample logo, in the top bar and the side rail.
60
+ </li>
61
+ <li>
62
+ <code>src/App.jsx</code> — add your own pages, and the side-rail items
63
+ that open them.
64
+ </li>
65
+ </ol>
66
+
67
+ <p className="app-welcome-foot">
68
+ Use the settings menu, top right, for your profile, password, and the
69
+ admin screens for users and roles.
70
+ </p>
71
+ </div>
72
+ )
73
+ }
@@ -0,0 +1,106 @@
1
+ // DESIGN LAYER — presentation only. Every value and every handler comes from
2
+ // the controller; there is no logic here to get out of step with it.
3
+ import { useTasksController } from '../useTasksController.js'
4
+ import TasksHelp from './TasksHelp.jsx'
5
+
6
+ const STATUSES = [
7
+ { value: 'todo', label: 'To do' },
8
+ { value: 'in_progress', label: 'In progress' },
9
+ { value: 'done', label: 'Done' }
10
+ ]
11
+
12
+ export default function Tasks() {
13
+ const { tasks, form, setField, submit, edit, cancel, remove, editingId, busy, error } =
14
+ useTasksController()
15
+
16
+ return (
17
+ <div className="app-page app-page-split">
18
+ <div className="app-page-main">
19
+ <h1>Tasks</h1>
20
+ <p className="app-page-lead">
21
+ A sample form and list. One model, one migration, one line of routing.
22
+ </p>
23
+
24
+ {error && <div className="app-error">{error}</div>}
25
+
26
+ <form className="app-form" onSubmit={submit}>
27
+ <label className="app-field">
28
+ <span>Title</span>
29
+ <input
30
+ value={form.title}
31
+ onChange={(e) => setField('title', e.target.value)}
32
+ placeholder="What needs doing?"
33
+ required
34
+ />
35
+ </label>
36
+
37
+ <label className="app-field">
38
+ <span>Description</span>
39
+ <textarea
40
+ rows={2}
41
+ value={form.description}
42
+ onChange={(e) => setField('description', e.target.value)}
43
+ placeholder="Optional"
44
+ />
45
+ </label>
46
+
47
+ <div className="app-field-row">
48
+ <label className="app-field">
49
+ <span>Status</span>
50
+ <select value={form.status} onChange={(e) => setField('status', e.target.value)}>
51
+ {STATUSES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
52
+ </select>
53
+ </label>
54
+
55
+ <label className="app-field">
56
+ <span>Due date</span>
57
+ <input
58
+ type="date"
59
+ value={form.dueDate}
60
+ onChange={(e) => setField('dueDate', e.target.value)}
61
+ />
62
+ </label>
63
+ </div>
64
+
65
+ <div className="app-form-actions">
66
+ <button type="submit" className="app-btn" disabled={busy}>
67
+ {editingId ? 'Save changes' : 'Add task'}
68
+ </button>
69
+ {editingId && (
70
+ <button type="button" className="app-btn app-btn-quiet" onClick={cancel}>
71
+ Cancel
72
+ </button>
73
+ )}
74
+ </div>
75
+ </form>
76
+
77
+ <table className="app-table">
78
+ <thead>
79
+ <tr><th>Title</th><th>Status</th><th>Due</th><th /></tr>
80
+ </thead>
81
+ <tbody>
82
+ {tasks.length === 0 && (
83
+ <tr><td colSpan={4} className="app-empty">No tasks yet. Add one above.</td></tr>
84
+ )}
85
+ {tasks.map((t) => (
86
+ <tr key={t.id} className={editingId === t.id ? 'is-editing' : undefined}>
87
+ <td>
88
+ <strong>{t.title}</strong>
89
+ {t.description && <div className="app-sub">{t.description}</div>}
90
+ </td>
91
+ <td><span className={'app-tag is-' + t.status}>{t.status.replace('_', ' ')}</span></td>
92
+ <td>{t.dueDate ? String(t.dueDate).slice(0, 10) : '—'}</td>
93
+ <td className="app-row-actions">
94
+ <button className="app-btn app-btn-quiet" onClick={() => edit(t)}>Edit</button>
95
+ <button className="app-btn app-btn-quiet" onClick={() => remove(t.id)}>Delete</button>
96
+ </td>
97
+ </tr>
98
+ ))}
99
+ </tbody>
100
+ </table>
101
+ </div>
102
+
103
+ <TasksHelp />
104
+ </div>
105
+ )
106
+ }