rastack 0.0.55 → 0.0.56
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/CHANGELOG.md +7 -0
- package/admin/AdminApp.tsx +307 -50
- package/admin/ui/DetailsModal.tsx +125 -0
- package/admin/ui/Sidebar.tsx +28 -10
- package/admin/ui/icons.tsx +41 -0
- package/admin/ui/motion.tsx +292 -0
- package/admin/ui/styles.ts +277 -0
- package/admin/useAdmin.ts +91 -0
- package/dist/admin.js +295 -18
- package/dist/dev/harness.d.ts +44 -13
- package/dist/dev/harness.js +117 -16
- package/dist/rastack-dev.d.ts +9 -5
- package/dist/rastack-dev.js +49 -22
- package/dist/rastack-update.js +11 -4
- package/dist/rastack.d.ts +1 -1
- package/dist/rastack.js +5 -4
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/package.json +7 -7
- package/src/dev/harness.ts +121 -15
- package/src/rastack-dev.ts +56 -24
- package/src/rastack-update.ts +11 -4
- package/src/rastack.ts +5 -4
- package/test/dev.spec.ts +42 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [0.0.56](https://github.com/theserverkid/reactapistack/compare/v0.0.55...v0.0.56) (2026-07-18)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **deps:** pin TypeScript to 5.x, keep esbuild 0.28.1 ([#96](https://github.com/theserverkid/reactapistack/issues/96)) ([2a5984d](https://github.com/theserverkid/reactapistack/commit/2a5984d4d10d5af8cf5cc448cd4eca5348515589)), closes [#76](https://github.com/theserverkid/reactapistack/issues/76)
|
|
11
|
+
|
|
5
12
|
### [0.0.55](https://github.com/theserverkid/reactapistack/compare/v0.0.54...v0.0.55) (2026-07-16)
|
|
6
13
|
|
|
7
14
|
### [0.0.54](https://github.com/theserverkid/reactapistack/compare/v0.0.53...v0.0.54) (2026-07-16)
|
package/admin/AdminApp.tsx
CHANGED
|
@@ -22,11 +22,11 @@ import {
|
|
|
22
22
|
import type { RastackEngine } from "../provider/types";
|
|
23
23
|
import {
|
|
24
24
|
AdminResource,
|
|
25
|
-
adminResources,
|
|
26
25
|
changelistHeaders,
|
|
27
26
|
findResource,
|
|
28
27
|
} from "./adapters";
|
|
29
28
|
import {
|
|
29
|
+
useDashboardStats,
|
|
30
30
|
useDeleteRecord,
|
|
31
31
|
useFilterModel,
|
|
32
32
|
useResourceForm,
|
|
@@ -36,9 +36,22 @@ import { Sidebar } from "./ui/Sidebar";
|
|
|
36
36
|
import { FilterBar } from "./ui/FilterBar";
|
|
37
37
|
import { AdminStyles } from "./ui/styles";
|
|
38
38
|
import {
|
|
39
|
+
BarChart,
|
|
40
|
+
CountUp,
|
|
41
|
+
MeterRow,
|
|
42
|
+
Reveal,
|
|
43
|
+
SegmentBar,
|
|
44
|
+
seriesColor,
|
|
45
|
+
} from "./ui/motion";
|
|
46
|
+
import { DetailsModal, type DetailRow } from "./ui/DetailsModal";
|
|
47
|
+
import {
|
|
48
|
+
IconActivity,
|
|
39
49
|
IconArrowLeft,
|
|
50
|
+
IconArrowUpRight,
|
|
40
51
|
IconChevronRight,
|
|
52
|
+
IconDatabase,
|
|
41
53
|
IconInbox,
|
|
54
|
+
IconLayers,
|
|
42
55
|
IconLink,
|
|
43
56
|
IconPlus,
|
|
44
57
|
IconTable,
|
|
@@ -122,58 +135,267 @@ function PageHead(props: {
|
|
|
122
135
|
);
|
|
123
136
|
}
|
|
124
137
|
|
|
125
|
-
// -- index /
|
|
138
|
+
// -- index / Insight dashboard ----------------------------------------------
|
|
126
139
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
/** One KPI tile: an icon, an animated count, and a quiet sublabel. */
|
|
141
|
+
function StatTile(props: {
|
|
142
|
+
icon: React.ReactNode;
|
|
143
|
+
label: string;
|
|
144
|
+
value: number;
|
|
145
|
+
sub: string;
|
|
146
|
+
index: number;
|
|
147
|
+
accent?: string;
|
|
148
|
+
}): React.JSX.Element {
|
|
149
|
+
return (
|
|
150
|
+
<Reveal className="ras-stat" index={props.index}>
|
|
151
|
+
<div className="ras-stat-top">
|
|
152
|
+
<span className="ras-stat-ico" style={props.accent ? { color: props.accent } : undefined}>
|
|
153
|
+
{props.icon}
|
|
154
|
+
</span>
|
|
155
|
+
<span className="ras-stat-label">{props.label}</span>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="ras-stat-value">
|
|
158
|
+
<CountUp value={props.value} delay={props.index * 80} />
|
|
159
|
+
</div>
|
|
160
|
+
<div className="ras-stat-sub">{props.sub}</div>
|
|
161
|
+
</Reveal>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function DashboardScreen(props: {
|
|
166
|
+
engine: RastackEngine;
|
|
167
|
+
resources: AdminResource[];
|
|
168
|
+
}): React.JSX.Element {
|
|
169
|
+
const stats = useDashboardStats(props.engine, props.resources);
|
|
170
|
+
const populated = useMemo(
|
|
171
|
+
() => stats.models.filter((m) => m.count > 0),
|
|
172
|
+
[stats.models],
|
|
173
|
+
);
|
|
174
|
+
const [details, setDetails] = useState<null | "apps" | "tables">(null);
|
|
175
|
+
|
|
176
|
+
const appRows: DetailRow[] = stats.byApp.map((a, i) => ({
|
|
177
|
+
label: a.app,
|
|
178
|
+
value: a.count,
|
|
179
|
+
color: seriesColor(i),
|
|
180
|
+
meta: `${a.models} ${a.models === 1 ? "table" : "tables"}`,
|
|
181
|
+
}));
|
|
182
|
+
const tableRows: DetailRow[] = stats.models.map((m, i) => ({
|
|
183
|
+
label: m.resource.label,
|
|
184
|
+
value: m.count,
|
|
185
|
+
color: seriesColor(i),
|
|
186
|
+
meta: m.resource.app,
|
|
187
|
+
}));
|
|
137
188
|
|
|
138
189
|
return (
|
|
139
190
|
<>
|
|
140
|
-
<Topbar crumbs={[{ label: "
|
|
141
|
-
<div className="ras-content">
|
|
191
|
+
<Topbar crumbs={[{ label: "Insight" }]} />
|
|
192
|
+
<div className="ras-content" key="dashboard">
|
|
142
193
|
<PageHead
|
|
143
194
|
eyebrow="React API Stack"
|
|
144
|
-
title="
|
|
145
|
-
subtitle="
|
|
195
|
+
title="Insight"
|
|
196
|
+
subtitle="Every model in the manifest, live — stored as Apache Iceberg tables on object storage."
|
|
146
197
|
/>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
198
|
+
|
|
199
|
+
{/* KPI strip */}
|
|
200
|
+
<div className="ras-statgrid">
|
|
201
|
+
<StatTile
|
|
202
|
+
index={0}
|
|
203
|
+
icon={<IconDatabase size={17} />}
|
|
204
|
+
label="Total records"
|
|
205
|
+
value={stats.totalRecords}
|
|
206
|
+
sub={stats.loading ? "counting…" : "across every table"}
|
|
207
|
+
/>
|
|
208
|
+
<StatTile
|
|
209
|
+
index={1}
|
|
210
|
+
icon={<IconTable size={17} />}
|
|
211
|
+
label="Tables"
|
|
212
|
+
value={stats.modelCount}
|
|
213
|
+
sub={`${populated.length} with data`}
|
|
214
|
+
accent="var(--color-success)"
|
|
215
|
+
/>
|
|
216
|
+
<StatTile
|
|
217
|
+
index={2}
|
|
218
|
+
icon={<IconLayers size={17} />}
|
|
219
|
+
label="Apps"
|
|
220
|
+
value={stats.appCount}
|
|
221
|
+
sub="namespaces in the schema"
|
|
222
|
+
accent="var(--color-warning)"
|
|
223
|
+
/>
|
|
224
|
+
<StatTile
|
|
225
|
+
index={3}
|
|
226
|
+
icon={<IconLink size={17} />}
|
|
227
|
+
label="Relations"
|
|
228
|
+
value={stats.relationCount}
|
|
229
|
+
sub="foreign keys inferred"
|
|
230
|
+
accent="var(--color-accent)"
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{/* Data distribution — the animated bar chart */}
|
|
235
|
+
<Reveal index={4}>
|
|
236
|
+
<Card className="ras-panel">
|
|
237
|
+
<div className="ras-panel-head">
|
|
238
|
+
<div>
|
|
239
|
+
<div className="ras-panel-title">
|
|
240
|
+
<IconActivity size={15} /> Data distribution
|
|
241
|
+
</div>
|
|
242
|
+
<div className="ras-panel-sub">Records per table</div>
|
|
243
|
+
</div>
|
|
244
|
+
<span className="ras-pill">{stats.modelCount} tables</span>
|
|
173
245
|
</div>
|
|
174
|
-
|
|
175
|
-
|
|
246
|
+
{stats.loading ? (
|
|
247
|
+
<div className="ras-chart-skel" />
|
|
248
|
+
) : stats.totalRecords === 0 ? (
|
|
249
|
+
<div className="ras-panel-empty">
|
|
250
|
+
No records yet — add data to any table to see it chart here.
|
|
251
|
+
</div>
|
|
252
|
+
) : (
|
|
253
|
+
<BarChart
|
|
254
|
+
data={stats.models.map((m) => ({
|
|
255
|
+
label: m.resource.label,
|
|
256
|
+
value: m.count,
|
|
257
|
+
}))}
|
|
258
|
+
/>
|
|
259
|
+
)}
|
|
260
|
+
</Card>
|
|
261
|
+
</Reveal>
|
|
262
|
+
|
|
263
|
+
{/* Two breakdown cards */}
|
|
264
|
+
<div className="ras-panel-row">
|
|
265
|
+
<Reveal index={5}>
|
|
266
|
+
<Card className="ras-panel">
|
|
267
|
+
<div className="ras-panel-head">
|
|
268
|
+
<div>
|
|
269
|
+
<div className="ras-panel-title">Records by app</div>
|
|
270
|
+
<div className="ras-panel-sub">Share of the warehouse</div>
|
|
271
|
+
</div>
|
|
272
|
+
{stats.byApp.length ? (
|
|
273
|
+
<button type="button" className="ras-details-btn" onClick={() => setDetails("apps")}>
|
|
274
|
+
Details
|
|
275
|
+
</button>
|
|
276
|
+
) : null}
|
|
277
|
+
</div>
|
|
278
|
+
{stats.byApp.length && stats.totalRecords > 0 ? (
|
|
279
|
+
<>
|
|
280
|
+
<SegmentBar
|
|
281
|
+
segments={stats.byApp.map((a, i) => ({
|
|
282
|
+
label: a.app,
|
|
283
|
+
value: a.count,
|
|
284
|
+
color: seriesColor(i),
|
|
285
|
+
}))}
|
|
286
|
+
/>
|
|
287
|
+
<div className="ras-legend">
|
|
288
|
+
{stats.byApp.map((a, i) => (
|
|
289
|
+
<div className="ras-legend-row" key={a.app}>
|
|
290
|
+
<span className="ras-legend-key">
|
|
291
|
+
<span className="ras-legend-dot" style={{ background: seriesColor(i) }} />
|
|
292
|
+
{a.app}
|
|
293
|
+
</span>
|
|
294
|
+
<span className="ras-legend-meta">
|
|
295
|
+
{a.models} {a.models === 1 ? "table" : "tables"}
|
|
296
|
+
</span>
|
|
297
|
+
<span className="ras-legend-val">
|
|
298
|
+
<CountUp value={a.count} delay={300 + i * 60} />
|
|
299
|
+
<span className="ras-legend-pct">
|
|
300
|
+
{Math.round((a.count / stats.totalRecords) * 100)}%
|
|
301
|
+
</span>
|
|
302
|
+
</span>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
</>
|
|
307
|
+
) : (
|
|
308
|
+
<div className="ras-panel-empty">No data across any app yet.</div>
|
|
309
|
+
)}
|
|
310
|
+
</Card>
|
|
311
|
+
</Reveal>
|
|
312
|
+
|
|
313
|
+
<Reveal index={6}>
|
|
314
|
+
<Card className="ras-panel">
|
|
315
|
+
<div className="ras-panel-head">
|
|
316
|
+
<div>
|
|
317
|
+
<div className="ras-panel-title">Largest tables</div>
|
|
318
|
+
<div className="ras-panel-sub">By record count</div>
|
|
319
|
+
</div>
|
|
320
|
+
{populated.length ? (
|
|
321
|
+
<button type="button" className="ras-details-btn" onClick={() => setDetails("tables")}>
|
|
322
|
+
Details
|
|
323
|
+
</button>
|
|
324
|
+
) : null}
|
|
325
|
+
</div>
|
|
326
|
+
{populated.length ? (
|
|
327
|
+
<div className="ras-meters">
|
|
328
|
+
{populated.slice(0, 5).map((m, i) => (
|
|
329
|
+
<a
|
|
330
|
+
key={m.resource.key}
|
|
331
|
+
href={`#/${m.resource.app}/${m.resource.model}/`}
|
|
332
|
+
className="ras-meter-link"
|
|
333
|
+
>
|
|
334
|
+
<MeterRow
|
|
335
|
+
label={m.resource.label}
|
|
336
|
+
value={m.count}
|
|
337
|
+
max={populated[0].count}
|
|
338
|
+
color={seriesColor(i)}
|
|
339
|
+
index={i}
|
|
340
|
+
/>
|
|
341
|
+
</a>
|
|
342
|
+
))}
|
|
343
|
+
</div>
|
|
344
|
+
) : (
|
|
345
|
+
<div className="ras-panel-empty">Tables fill in as you add records.</div>
|
|
346
|
+
)}
|
|
347
|
+
</Card>
|
|
348
|
+
</Reveal>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{/* Model launcher */}
|
|
352
|
+
<Reveal index={7}>
|
|
353
|
+
<div className="ras-section-label">Browse tables</div>
|
|
354
|
+
</Reveal>
|
|
355
|
+
<div className="ras-cardgrid">
|
|
356
|
+
{stats.models.map((m, i) => (
|
|
357
|
+
<Reveal key={m.resource.key} index={8 + i} step={45}>
|
|
358
|
+
<Card
|
|
359
|
+
className="ras-modelcard"
|
|
360
|
+
onClick={() => go(`#/${m.resource.app}/${m.resource.model}/`)}
|
|
361
|
+
>
|
|
362
|
+
<div className="ras-mc-top">
|
|
363
|
+
<span className="ras-mc-icon">
|
|
364
|
+
<IconTable size={18} />
|
|
365
|
+
</span>
|
|
366
|
+
<span className="ras-mc-count">
|
|
367
|
+
{stats.loading ? "—" : <CountUp value={m.count} delay={i * 40} />}
|
|
368
|
+
</span>
|
|
369
|
+
</div>
|
|
370
|
+
<div className="ras-mc-name">{m.resource.label}</div>
|
|
371
|
+
<div className="ras-mc-meta">{m.resource.app}</div>
|
|
372
|
+
<div className="ras-mc-go">
|
|
373
|
+
View records <IconArrowUpRight size={14} />
|
|
374
|
+
</div>
|
|
375
|
+
</Card>
|
|
376
|
+
</Reveal>
|
|
377
|
+
))}
|
|
378
|
+
</div>
|
|
176
379
|
</div>
|
|
380
|
+
|
|
381
|
+
<DetailsModal
|
|
382
|
+
open={details === "apps"}
|
|
383
|
+
title="Records by app"
|
|
384
|
+
subtitle="Every app namespace by total records"
|
|
385
|
+
rows={appRows}
|
|
386
|
+
total={stats.totalRecords}
|
|
387
|
+
unit="records"
|
|
388
|
+
onClose={() => setDetails(null)}
|
|
389
|
+
/>
|
|
390
|
+
<DetailsModal
|
|
391
|
+
open={details === "tables"}
|
|
392
|
+
title="Tables by record count"
|
|
393
|
+
subtitle="Every table in the manifest, ranked"
|
|
394
|
+
rows={tableRows}
|
|
395
|
+
total={stats.totalRecords}
|
|
396
|
+
unit="records"
|
|
397
|
+
onClose={() => setDetails(null)}
|
|
398
|
+
/>
|
|
177
399
|
</>
|
|
178
400
|
);
|
|
179
401
|
}
|
|
@@ -278,9 +500,14 @@ function ListScreen(props: {
|
|
|
278
500
|
eyebrow={resource.app}
|
|
279
501
|
title={resource.label}
|
|
280
502
|
subtitle={
|
|
281
|
-
table.query.isLoading
|
|
282
|
-
|
|
283
|
-
|
|
503
|
+
table.query.isLoading ? (
|
|
504
|
+
"Loading…"
|
|
505
|
+
) : (
|
|
506
|
+
<>
|
|
507
|
+
<CountUp value={table.itemCount} duration={650} />{" "}
|
|
508
|
+
{table.itemCount === 1 ? "record" : "records"}
|
|
509
|
+
</>
|
|
510
|
+
)
|
|
284
511
|
}
|
|
285
512
|
actions={
|
|
286
513
|
<Button onClick={() => go(addHash)}>
|
|
@@ -402,19 +629,44 @@ function FormScreen(props: {
|
|
|
402
629
|
|
|
403
630
|
// -- root -------------------------------------------------------------------
|
|
404
631
|
|
|
632
|
+
const COLLAPSE_KEY = "ras-admin-sidebar-collapsed";
|
|
633
|
+
|
|
634
|
+
/** Persisted sidebar collapse state; drives the grid-track transition. */
|
|
635
|
+
function useSidebarCollapsed(): [boolean, () => void] {
|
|
636
|
+
const [collapsed, setCollapsed] = useState(() => {
|
|
637
|
+
try {
|
|
638
|
+
return localStorage.getItem(COLLAPSE_KEY) === "1";
|
|
639
|
+
} catch {
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
const toggle = () =>
|
|
644
|
+
setCollapsed((c) => {
|
|
645
|
+
const next = !c;
|
|
646
|
+
try {
|
|
647
|
+
localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
|
|
648
|
+
} catch {
|
|
649
|
+
/* storage may be unavailable */
|
|
650
|
+
}
|
|
651
|
+
return next;
|
|
652
|
+
});
|
|
653
|
+
return [collapsed, toggle];
|
|
654
|
+
}
|
|
655
|
+
|
|
405
656
|
export function AdminApp(props: {
|
|
406
657
|
engine: RastackEngine;
|
|
407
658
|
resources: AdminResource[];
|
|
408
659
|
}): React.JSX.Element {
|
|
409
660
|
const route = useRoute();
|
|
410
661
|
const { engine, resources } = props;
|
|
662
|
+
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
|
411
663
|
|
|
412
664
|
const activeKey =
|
|
413
665
|
route.name === "index" ? undefined : `${route.app}.${route.model}`;
|
|
414
666
|
|
|
415
667
|
let screen: React.ReactNode;
|
|
416
668
|
if (route.name === "index") {
|
|
417
|
-
screen = <
|
|
669
|
+
screen = <DashboardScreen engine={engine} resources={resources} />;
|
|
418
670
|
} else {
|
|
419
671
|
const resource = findResource(resources, route.app, route.model);
|
|
420
672
|
if (!resource) {
|
|
@@ -452,8 +704,13 @@ export function AdminApp(props: {
|
|
|
452
704
|
return (
|
|
453
705
|
<>
|
|
454
706
|
<AdminStyles />
|
|
455
|
-
<div className="ras-root">
|
|
456
|
-
<Sidebar
|
|
707
|
+
<div className={"ras-root" + (collapsed ? " is-collapsed" : "")}>
|
|
708
|
+
<Sidebar
|
|
709
|
+
resources={resources}
|
|
710
|
+
activeKey={activeKey}
|
|
711
|
+
collapsed={collapsed}
|
|
712
|
+
onToggleCollapse={toggleCollapsed}
|
|
713
|
+
/>
|
|
457
714
|
<main className="ras-main">{screen}</main>
|
|
458
715
|
</div>
|
|
459
716
|
</>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A backdrop-blur "Details" drill-down modal — the reference's "Details" affordance.
|
|
3
|
+
* Built to the design-engineer §4 spec, dependency-free:
|
|
4
|
+
*
|
|
5
|
+
* • overlay: fixed inset:0, translucent fill + backdrop-filter blur(16px)
|
|
6
|
+
* saturate(140%), fading opacity 0→1 over 400ms
|
|
7
|
+
* • content: transform scale(0.95) translate3d(0,10px,0) → identity over 400ms
|
|
8
|
+
* on cubic-bezier(0.16, 1, 0.3, 1)
|
|
9
|
+
* • execution order: overlay + shell mount → after stabilise, the internal
|
|
10
|
+
* horizontal bars animate width 0%→X% over 800ms cubic-bezier(0.4,0,0.2,1)
|
|
11
|
+
*
|
|
12
|
+
* All of it honours prefers-reduced-motion (via the stylesheet), and the bars
|
|
13
|
+
* carry a count-up value. Closes on Escape, backdrop click, or the close button.
|
|
14
|
+
*/
|
|
15
|
+
import React, { useEffect, useState } from "react";
|
|
16
|
+
import { CountUp } from "./motion";
|
|
17
|
+
import { IconClose } from "./icons";
|
|
18
|
+
|
|
19
|
+
/** One ranked row in the modal: a labelled bar filling to `value / max`. */
|
|
20
|
+
export interface DetailRow {
|
|
21
|
+
label: string;
|
|
22
|
+
value: number;
|
|
23
|
+
color: string;
|
|
24
|
+
meta?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function DetailsModal(props: {
|
|
28
|
+
open: boolean;
|
|
29
|
+
title: string;
|
|
30
|
+
subtitle?: string;
|
|
31
|
+
rows: DetailRow[];
|
|
32
|
+
total: number;
|
|
33
|
+
unit?: string;
|
|
34
|
+
onClose: () => void;
|
|
35
|
+
}): React.JSX.Element | null {
|
|
36
|
+
const { open, onClose } = props;
|
|
37
|
+
// `armed` gates the width-fill: it flips true one tick after mount so the bars
|
|
38
|
+
// transition from 0 rather than appearing full (the §4 "secondary state" step).
|
|
39
|
+
const [armed, setArmed] = useState(false);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!open) {
|
|
43
|
+
setArmed(false);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const raf = requestAnimationFrame(() => setArmed(true));
|
|
47
|
+
const onKey = (e: KeyboardEvent) => {
|
|
48
|
+
if (e.key === "Escape") onClose();
|
|
49
|
+
};
|
|
50
|
+
window.addEventListener("keydown", onKey);
|
|
51
|
+
const prev = document.body.style.overflow;
|
|
52
|
+
document.body.style.overflow = "hidden";
|
|
53
|
+
return () => {
|
|
54
|
+
cancelAnimationFrame(raf);
|
|
55
|
+
window.removeEventListener("keydown", onKey);
|
|
56
|
+
document.body.style.overflow = prev;
|
|
57
|
+
};
|
|
58
|
+
}, [open, onClose]);
|
|
59
|
+
|
|
60
|
+
if (!open) return null;
|
|
61
|
+
const max = Math.max(1, ...props.rows.map((r) => r.value));
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
className="ras-modal-overlay"
|
|
66
|
+
onClick={onClose}
|
|
67
|
+
role="dialog"
|
|
68
|
+
aria-modal="true"
|
|
69
|
+
aria-label={props.title}
|
|
70
|
+
>
|
|
71
|
+
<div className="ras-modal" onClick={(e) => e.stopPropagation()}>
|
|
72
|
+
<div className="ras-modal-head">
|
|
73
|
+
<div>
|
|
74
|
+
<div className="ras-modal-title">{props.title}</div>
|
|
75
|
+
{props.subtitle ? <div className="ras-modal-sub">{props.subtitle}</div> : null}
|
|
76
|
+
</div>
|
|
77
|
+
<button type="button" className="ras-modal-x" onClick={onClose} aria-label="Close">
|
|
78
|
+
<IconClose size={16} />
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="ras-modal-rows">
|
|
83
|
+
{props.rows.map((row, i) => {
|
|
84
|
+
const pct = Math.max(1.5, (row.value / max) * 100);
|
|
85
|
+
const share = props.total > 0 ? Math.round((row.value / props.total) * 100) : 0;
|
|
86
|
+
return (
|
|
87
|
+
<div className="ras-modal-row" key={row.label + i}>
|
|
88
|
+
<div className="ras-modal-row-head">
|
|
89
|
+
<span className="ras-modal-row-label">
|
|
90
|
+
<span className="ras-legend-dot" style={{ background: row.color }} />
|
|
91
|
+
{row.label}
|
|
92
|
+
{row.meta ? <span className="ras-modal-row-meta">{row.meta}</span> : null}
|
|
93
|
+
</span>
|
|
94
|
+
<span className="ras-modal-row-val">
|
|
95
|
+
<CountUp value={row.value} delay={120 + i * 55} duration={900} />
|
|
96
|
+
<span className="ras-modal-row-pct">{share}%</span>
|
|
97
|
+
</span>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="ras-modal-track">
|
|
100
|
+
<div
|
|
101
|
+
className="ras-modal-fill"
|
|
102
|
+
style={{
|
|
103
|
+
width: armed ? `${pct}%` : "0%",
|
|
104
|
+
background: row.color,
|
|
105
|
+
transitionDelay: `${i * 55}ms`,
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div className="ras-modal-foot">
|
|
115
|
+
<span>
|
|
116
|
+
{props.rows.length} {props.rows.length === 1 ? "row" : "rows"}
|
|
117
|
+
</span>
|
|
118
|
+
<span className="ras-modal-total">
|
|
119
|
+
<CountUp value={props.total} duration={900} /> {props.unit ?? "total"}
|
|
120
|
+
</span>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
package/admin/ui/Sidebar.tsx
CHANGED
|
@@ -10,13 +10,16 @@ import { useThemeMode } from "../../theme";
|
|
|
10
10
|
import { humanizeKey } from "../../components";
|
|
11
11
|
import type { AdminResource } from "../adapters";
|
|
12
12
|
import { adminResources } from "../adapters";
|
|
13
|
-
import { BrandMark, IconMoon, IconSun, IconTable } from "./icons";
|
|
13
|
+
import { BrandMark, IconMoon, IconPanelLeft, IconSun, IconTable } from "./icons";
|
|
14
14
|
|
|
15
15
|
export function Sidebar(props: {
|
|
16
16
|
resources: AdminResource[];
|
|
17
17
|
activeKey?: string;
|
|
18
|
+
collapsed?: boolean;
|
|
19
|
+
onToggleCollapse?: () => void;
|
|
18
20
|
}): React.JSX.Element {
|
|
19
21
|
const [mode, setMode] = useThemeMode();
|
|
22
|
+
const collapsed = props.collapsed ?? false;
|
|
20
23
|
|
|
21
24
|
const byApp = useMemo(() => {
|
|
22
25
|
const map = new Map<string, AdminResource[]>();
|
|
@@ -37,15 +40,29 @@ export function Sidebar(props: {
|
|
|
37
40
|
|
|
38
41
|
return (
|
|
39
42
|
<aside className="ras-sidebar">
|
|
40
|
-
<
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
<span className="ras-brand-
|
|
44
|
-
|
|
43
|
+
<div className="ras-brand-row">
|
|
44
|
+
<a className="ras-brand" href="#/" aria-label="React API Stack admin">
|
|
45
|
+
<BrandMark size={32} />
|
|
46
|
+
<span className="ras-brand-text">
|
|
47
|
+
<span className="ras-brand-name" style={{ display: "block" }}>
|
|
48
|
+
React API Stack
|
|
49
|
+
</span>
|
|
50
|
+
<span className="ras-brand-sub">Admin</span>
|
|
45
51
|
</span>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
</a>
|
|
53
|
+
{props.onToggleCollapse ? (
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
className="ras-icon-btn ras-collapse-btn"
|
|
57
|
+
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
58
|
+
aria-pressed={collapsed}
|
|
59
|
+
title={collapsed ? "Expand" : "Collapse"}
|
|
60
|
+
onClick={props.onToggleCollapse}
|
|
61
|
+
>
|
|
62
|
+
<IconPanelLeft size={16} />
|
|
63
|
+
</button>
|
|
64
|
+
) : null}
|
|
65
|
+
</div>
|
|
49
66
|
|
|
50
67
|
<nav className="ras-nav">
|
|
51
68
|
{byApp.map(([app, models]) => (
|
|
@@ -59,9 +76,10 @@ export function Sidebar(props: {
|
|
|
59
76
|
href={`#/${r.app}/${r.model}/`}
|
|
60
77
|
className={"ras-nav-link" + (active ? " is-active" : "")}
|
|
61
78
|
aria-current={active ? "page" : undefined}
|
|
79
|
+
title={collapsed ? r.label : undefined}
|
|
62
80
|
>
|
|
63
81
|
<IconTable size={16} className="ras-ico" />
|
|
64
|
-
{r.label}
|
|
82
|
+
<span className="ras-nav-text">{r.label}</span>
|
|
65
83
|
</a>
|
|
66
84
|
);
|
|
67
85
|
})}
|
package/admin/ui/icons.tsx
CHANGED
|
@@ -125,6 +125,47 @@ export const IconInbox = (p: SvgProps) => (
|
|
|
125
125
|
</Svg>
|
|
126
126
|
);
|
|
127
127
|
|
|
128
|
+
export const IconDatabase = (p: SvgProps) => (
|
|
129
|
+
<Svg {...p}>
|
|
130
|
+
<ellipse cx="12" cy="5" rx="8" ry="3" />
|
|
131
|
+
<path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5" />
|
|
132
|
+
<path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
|
|
133
|
+
</Svg>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
export const IconLayers = (p: SvgProps) => (
|
|
137
|
+
<Svg {...p}>
|
|
138
|
+
<path d="m12 3 9 5-9 5-9-5 9-5Z" />
|
|
139
|
+
<path d="m3 13 9 5 9-5" />
|
|
140
|
+
</Svg>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
export const IconActivity = (p: SvgProps) => (
|
|
144
|
+
<Svg {...p}>
|
|
145
|
+
<path d="M3 12h4l2.5-7 5 14 2.5-7H21" />
|
|
146
|
+
</Svg>
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
export const IconArrowUpRight = (p: SvgProps) => (
|
|
150
|
+
<Svg {...p}>
|
|
151
|
+
<path d="M7 17 17 7M8 7h9v9" />
|
|
152
|
+
</Svg>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
export const IconPanelLeft = (p: SvgProps) => (
|
|
156
|
+
<Svg {...p}>
|
|
157
|
+
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
|
|
158
|
+
<path d="M9.5 4.5v15" />
|
|
159
|
+
</Svg>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
export const IconBell = (p: SvgProps) => (
|
|
163
|
+
<Svg {...p}>
|
|
164
|
+
<path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6Z" />
|
|
165
|
+
<path d="M10 20a2 2 0 0 0 4 0" />
|
|
166
|
+
</Svg>
|
|
167
|
+
);
|
|
168
|
+
|
|
128
169
|
/** The brand mark: an isometric "stack" tile in the accent colour. */
|
|
129
170
|
export function BrandMark({ size = 30 }: { size?: number }): React.JSX.Element {
|
|
130
171
|
return (
|