@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.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/bin/xeplr.js +77 -0
- package/lib/commands/encrypt.js +144 -0
- package/lib/generate/index.js +147 -0
- package/lib/nextSteps.js +39 -0
- package/lib/plan.js +44 -0
- package/lib/prompt.js +130 -0
- package/lib/questions.js +125 -0
- package/package.json +39 -0
- package/templates/api/app.js +21 -0
- package/templates/api/bin/www +62 -0
- package/templates/api/db/setup.js +10 -0
- package/templates/api/development.env.tpl +47 -0
- package/templates/api/env.required.js +29 -0
- package/templates/api/gitignore.tpl +2 -0
- package/templates/api/migrations/0001_tasks.sql +23 -0
- package/templates/api/migrations-auth/0001_home_menu.sql +14 -0
- package/templates/api/migrations-auth/0002_tasks_menu.sql +11 -0
- package/templates/api/models/Task.js +48 -0
- package/templates/api/models/index.js +7 -0
- package/templates/api/package.json +30 -0
- package/templates/api/routes/index.js +46 -0
- package/templates/ui/dotenv.tpl +8 -0
- package/templates/ui/gitignore.tpl +2 -0
- package/templates/ui/index.html +12 -0
- package/templates/ui/package.json +21 -0
- package/templates/ui/public/logo-wide.svg +6 -0
- package/templates/ui/public/logo.svg +4 -0
- package/templates/ui/src/App.jsx +84 -0
- package/templates/ui/src/api/tasks.js +34 -0
- package/templates/ui/src/index.css +275 -0
- package/templates/ui/src/main.jsx +27 -0
- package/templates/ui/src/pages/Home.jsx +73 -0
- package/templates/ui/src/pages/Tasks.jsx +106 -0
- package/templates/ui/src/pages/TasksHelp.jsx +70 -0
- package/templates/ui/src/useTasksController.js +83 -0
- 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,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
|
+
}
|