rastack 0.0.43 → 0.0.45

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 (41) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/admin/AdminApp.tsx +461 -0
  3. package/admin/adapters.ts +346 -0
  4. package/admin/httpEngine.ts +56 -0
  5. package/admin/index.tsx +140 -0
  6. package/admin/ui/FilterBar.tsx +231 -0
  7. package/admin/ui/Popover.tsx +54 -0
  8. package/admin/ui/Sidebar.tsx +86 -0
  9. package/admin/ui/icons.tsx +146 -0
  10. package/admin/ui/styles.ts +398 -0
  11. package/admin/useAdmin.ts +434 -0
  12. package/dist/admin.js +459 -0
  13. package/dist/dev/harness.d.ts +70 -0
  14. package/dist/dev/harness.js +189 -0
  15. package/dist/rastack-admin.d.ts +14 -0
  16. package/dist/rastack-admin.js +128 -0
  17. package/dist/rastack-dev.d.ts +18 -0
  18. package/dist/rastack-dev.js +197 -0
  19. package/dist/rastack-wasm-build.d.ts +9 -4
  20. package/dist/rastack-wasm-build.js +44 -33
  21. package/dist/rastack.d.ts +2 -3
  22. package/dist/rastack.js +13 -32
  23. package/dist/wasm/package.json +4 -0
  24. package/dist/wasm/rastack_wasm.d.ts +100 -0
  25. package/dist/wasm/rastack_wasm.js +745 -0
  26. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  27. package/dist/wasm/rastack_wasm_bg.wasm.d.ts +28 -0
  28. package/package.json +6 -2
  29. package/scripts/build-admin.js +48 -0
  30. package/scripts/copy-wasm.js +35 -0
  31. package/src/dev/harness.ts +174 -0
  32. package/src/rastack-admin.ts +112 -0
  33. package/src/rastack-dev.ts +197 -0
  34. package/src/rastack-wasm-build.ts +52 -41
  35. package/src/rastack.ts +14 -37
  36. package/test/dev.spec.ts +373 -0
  37. package/wasm/package.json +4 -0
  38. package/wasm/rastack_wasm.d.ts +100 -0
  39. package/wasm/rastack_wasm.js +745 -0
  40. package/wasm/rastack_wasm_bg.wasm +0 -0
  41. package/wasm/rastack_wasm_bg.wasm.d.ts +28 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The Linear-style changelist filter bar: active filters render as pill "chips"
3
+ * (`Field is Value ×`) whose value opens a switcher popover and whose × drops
4
+ * the filter; a dashed "+ Filter" button opens a two-level menu (pick a field →
5
+ * pick a value). Search sits at the right. Every value is served from the
6
+ * `FilterFieldModel`s discovered by `useFilterModel` (FK target rows, distinct
7
+ * strings, booleans), so the bar reflects the data, and each pick drives an
8
+ * equality filter through the engine.
9
+ */
10
+ import React, { useState } from "react";
11
+ import type { FilterOption } from "../adapters";
12
+ import type { FilterFieldModel } from "../useAdmin";
13
+ import { Popover } from "./Popover";
14
+ import {
15
+ IconChevronRight,
16
+ IconClose,
17
+ IconFilter,
18
+ IconLink,
19
+ IconPlus,
20
+ IconSearch,
21
+ } from "./icons";
22
+
23
+ function facetIcon(kind: FilterFieldModel["kind"]): React.JSX.Element {
24
+ return kind === "fk" ? (
25
+ <IconLink size={15} className="ras-ico" />
26
+ ) : (
27
+ <IconFilter size={15} className="ras-ico" />
28
+ );
29
+ }
30
+
31
+ function labelFor(facet: FilterFieldModel | undefined, value: string): string {
32
+ return facet?.options.find((o) => o.value === value)?.label ?? value;
33
+ }
34
+
35
+ /** A searchable list of value options (used by the add-menu and chip switcher). */
36
+ function OptionList(props: {
37
+ options: FilterOption[];
38
+ selected?: string;
39
+ onPick: (value: string) => void;
40
+ }): React.JSX.Element {
41
+ const [q, setQ] = useState("");
42
+ const shown = q
43
+ ? props.options.filter((o) => o.label.toLowerCase().includes(q.toLowerCase()))
44
+ : props.options;
45
+ return (
46
+ <>
47
+ {props.options.length > 8 ? (
48
+ <input
49
+ className="ras-pop-search"
50
+ placeholder="Search…"
51
+ value={q}
52
+ onChange={(e) => setQ(e.target.value)}
53
+ autoFocus
54
+ />
55
+ ) : null}
56
+ <div className="ras-pop-list">
57
+ {shown.length === 0 ? (
58
+ <div className="ras-pop-empty">No matches</div>
59
+ ) : (
60
+ shown.map((o) => (
61
+ <button
62
+ key={o.value}
63
+ type="button"
64
+ className={"ras-pop-item" + (o.value === props.selected ? " is-selected" : "")}
65
+ onClick={() => props.onPick(o.value)}
66
+ >
67
+ {o.label}
68
+ </button>
69
+ ))
70
+ )}
71
+ </div>
72
+ </>
73
+ );
74
+ }
75
+
76
+ /** The two-level "add filter" menu: fields → values. */
77
+ function AddFilterMenu(props: {
78
+ facets: FilterFieldModel[];
79
+ activeFields: string[];
80
+ onPick: (field: string, value: string) => void;
81
+ close: () => void;
82
+ }): React.JSX.Element {
83
+ const [view, setView] = useState<string | null>(null);
84
+ const available = props.facets.filter((f) => !props.activeFields.includes(f.name));
85
+
86
+ if (view) {
87
+ const facet = props.facets.find((f) => f.name === view);
88
+ if (!facet) return <div className="ras-pop-empty">—</div>;
89
+ return (
90
+ <>
91
+ <div className="ras-pop-head" onClick={() => setView(null)}>
92
+ <IconChevronRight size={14} style={{ transform: "rotate(180deg)" }} />
93
+ {facet.label}
94
+ </div>
95
+ <OptionList
96
+ options={facet.options}
97
+ onPick={(v) => {
98
+ props.onPick(facet.name, v);
99
+ props.close();
100
+ }}
101
+ />
102
+ </>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <div className="ras-pop-list">
108
+ {available.length === 0 ? (
109
+ <div className="ras-pop-empty">No more filters</div>
110
+ ) : (
111
+ available.map((f) => (
112
+ <button
113
+ key={f.name}
114
+ type="button"
115
+ className="ras-pop-item"
116
+ onClick={() => setView(f.name)}
117
+ >
118
+ {facetIcon(f.kind)}
119
+ {f.label}
120
+ <IconChevronRight size={14} className="ras-pop-item-chev" />
121
+ </button>
122
+ ))
123
+ )}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ export function FilterBar(props: {
129
+ facets: FilterFieldModel[];
130
+ filters: Record<string, string>;
131
+ setFilter: (field: string, value: string) => void;
132
+ removeFilter: (field: string) => void;
133
+ clearFilters: () => void;
134
+ search: string | undefined;
135
+ setSearch: (value: string | undefined) => void;
136
+ searchable: boolean;
137
+ }): React.JSX.Element {
138
+ const active = Object.keys(props.filters);
139
+ const facetOf = (name: string) => props.facets.find((f) => f.name === name);
140
+
141
+ return (
142
+ <div className="ras-filterbar">
143
+ {active.map((field) => {
144
+ const facet = facetOf(field);
145
+ const value = props.filters[field];
146
+ return (
147
+ <span className="ras-chip" key={field}>
148
+ <span className="ras-chip-key">
149
+ {facet ? facetIcon(facet.kind) : null}
150
+ {facet?.label ?? field}
151
+ </span>
152
+ <span className="ras-chip-op">is</span>
153
+ {facet && facet.options.length ? (
154
+ <Popover
155
+ trigger={({ toggle }) => (
156
+ <span className="ras-chip-val" onClick={toggle} role="button" tabIndex={0}>
157
+ {labelFor(facet, value)}
158
+ </span>
159
+ )}
160
+ >
161
+ {({ close }) => (
162
+ <OptionList
163
+ options={facet.options}
164
+ selected={value}
165
+ onPick={(v) => {
166
+ props.setFilter(field, v);
167
+ close();
168
+ }}
169
+ />
170
+ )}
171
+ </Popover>
172
+ ) : (
173
+ <span className="ras-chip-val" style={{ cursor: "default" }}>
174
+ {labelFor(facet, value)}
175
+ </span>
176
+ )}
177
+ <span
178
+ className="ras-chip-x"
179
+ role="button"
180
+ tabIndex={0}
181
+ aria-label={`Remove ${facet?.label ?? field} filter`}
182
+ onClick={() => props.removeFilter(field)}
183
+ >
184
+ <IconClose size={13} />
185
+ </span>
186
+ </span>
187
+ );
188
+ })}
189
+
190
+ {props.facets.length ? (
191
+ <Popover
192
+ trigger={({ toggle }) => (
193
+ <button type="button" className="ras-addfilter" onClick={toggle}>
194
+ <IconPlus size={14} />
195
+ Filter
196
+ </button>
197
+ )}
198
+ >
199
+ {({ close }) => (
200
+ <AddFilterMenu
201
+ facets={props.facets}
202
+ activeFields={active}
203
+ onPick={props.setFilter}
204
+ close={close}
205
+ />
206
+ )}
207
+ </Popover>
208
+ ) : null}
209
+
210
+ {active.length > 1 ? (
211
+ <button type="button" className="ras-clearfilter" onClick={props.clearFilters}>
212
+ Clear all
213
+ </button>
214
+ ) : null}
215
+
216
+ <span className="ras-grow" />
217
+
218
+ {props.searchable ? (
219
+ <span className="ras-search">
220
+ <IconSearch size={15} className="ras-search-ico" />
221
+ <input
222
+ type="search"
223
+ placeholder="Search…"
224
+ value={props.search ?? ""}
225
+ onChange={(e) => props.setSearch(e.target.value || undefined)}
226
+ />
227
+ </span>
228
+ ) : null}
229
+ </div>
230
+ );
231
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A tiny controlled popover: a trigger + a floating panel that closes on
3
+ * outside-click or Escape. Used by the Linear-style filter bar (the "add
4
+ * filter" menu and each chip's value switcher). Deliberately dependency-free —
5
+ * no portal, no positioning lib — the panel is absolutely positioned under its
6
+ * trigger and styled by `ui/styles.ts`.
7
+ */
8
+ import React, { useEffect, useRef, useState } from "react";
9
+
10
+ export interface PopoverRenderProps {
11
+ open: boolean;
12
+ toggle: () => void;
13
+ close: () => void;
14
+ }
15
+
16
+ export function Popover(props: {
17
+ align?: "left" | "right";
18
+ trigger: (p: PopoverRenderProps) => React.ReactNode;
19
+ children: (p: { close: () => void }) => React.ReactNode;
20
+ }): React.JSX.Element {
21
+ const [open, setOpen] = useState(false);
22
+ const ref = useRef<HTMLDivElement>(null);
23
+ const close = () => setOpen(false);
24
+
25
+ useEffect(() => {
26
+ if (!open) return;
27
+ const onDown = (e: MouseEvent) => {
28
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
29
+ };
30
+ const onKey = (e: KeyboardEvent) => {
31
+ if (e.key === "Escape") setOpen(false);
32
+ };
33
+ document.addEventListener("mousedown", onDown);
34
+ document.addEventListener("keydown", onKey);
35
+ return () => {
36
+ document.removeEventListener("mousedown", onDown);
37
+ document.removeEventListener("keydown", onKey);
38
+ };
39
+ }, [open]);
40
+
41
+ return (
42
+ <div className="ras-pop-wrap" ref={ref}>
43
+ {props.trigger({ open, toggle: () => setOpen((o) => !o), close })}
44
+ {open ? (
45
+ <div
46
+ className={"ras-pop" + (props.align === "right" ? " align-right" : "")}
47
+ role="menu"
48
+ >
49
+ {props.children({ close })}
50
+ </div>
51
+ ) : null}
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The persistent left rail — the "models on the side". Every admin-enabled
3
+ * resource, grouped by app, always visible so navigation between models is one
4
+ * click from anywhere. The active model is highlighted; a footer carries the
5
+ * light/dark toggle (dogfooding `rastack/theme`'s `useThemeMode`, which
6
+ * recascades via CSS variables with no re-render).
7
+ */
8
+ import React, { useMemo } from "react";
9
+ import { useThemeMode } from "../../theme";
10
+ import { humanizeKey } from "../../components";
11
+ import type { AdminResource } from "../adapters";
12
+ import { adminResources } from "../adapters";
13
+ import { BrandMark, IconMoon, IconSun, IconTable } from "./icons";
14
+
15
+ export function Sidebar(props: {
16
+ resources: AdminResource[];
17
+ activeKey?: string;
18
+ }): React.JSX.Element {
19
+ const [mode, setMode] = useThemeMode();
20
+
21
+ const byApp = useMemo(() => {
22
+ const map = new Map<string, AdminResource[]>();
23
+ for (const r of adminResources(props.resources)) {
24
+ const list = map.get(r.app) ?? [];
25
+ list.push(r);
26
+ map.set(r.app, list);
27
+ }
28
+ return [...map.entries()]
29
+ .sort(([a], [b]) => a.localeCompare(b))
30
+ .map(([app, models]) => [
31
+ app,
32
+ models.slice().sort((a, b) => a.model.localeCompare(b.model)),
33
+ ] as const);
34
+ }, [props.resources]);
35
+
36
+ const isDark = mode === "dark";
37
+
38
+ return (
39
+ <aside className="ras-sidebar">
40
+ <a className="ras-brand" href="#/" aria-label="React API Stack admin">
41
+ <BrandMark size={32} />
42
+ <span>
43
+ <span className="ras-brand-name" style={{ display: "block" }}>
44
+ React API Stack
45
+ </span>
46
+ <span className="ras-brand-sub">Admin</span>
47
+ </span>
48
+ </a>
49
+
50
+ <nav className="ras-nav">
51
+ {byApp.map(([app, models]) => (
52
+ <div className="ras-nav-section" key={app}>
53
+ <div className="ras-nav-label">{humanizeKey(app)}</div>
54
+ {models.map((r) => {
55
+ const active = r.key === props.activeKey;
56
+ return (
57
+ <a
58
+ key={r.key}
59
+ href={`#/${r.app}/${r.model}/`}
60
+ className={"ras-nav-link" + (active ? " is-active" : "")}
61
+ aria-current={active ? "page" : undefined}
62
+ >
63
+ <IconTable size={16} className="ras-ico" />
64
+ {r.label}
65
+ </a>
66
+ );
67
+ })}
68
+ </div>
69
+ ))}
70
+ </nav>
71
+
72
+ <div className="ras-sidebar-foot">
73
+ <span className="ras-foot-meta">Iceberg · object storage</span>
74
+ <button
75
+ type="button"
76
+ className="ras-icon-btn"
77
+ aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
78
+ title={isDark ? "Light" : "Dark"}
79
+ onClick={() => setMode(isDark ? "light" : "dark")}
80
+ >
81
+ {isDark ? <IconSun size={16} /> : <IconMoon size={16} />}
82
+ </button>
83
+ </div>
84
+ </aside>
85
+ );
86
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A small set of inline stroke icons (currentColor, 1.6px) used across the
3
+ * admin chrome. Kept local to the admin — the shared `rastack/components` are
4
+ * intentionally icon-free — so the bundle stays self-contained and offline.
5
+ */
6
+ import React from "react";
7
+
8
+ type SvgProps = {
9
+ size?: number;
10
+ strokeWidth?: number;
11
+ style?: React.CSSProperties;
12
+ className?: string;
13
+ };
14
+
15
+ function Svg({
16
+ size = 16,
17
+ strokeWidth = 1.6,
18
+ children,
19
+ style,
20
+ className,
21
+ }: SvgProps & { children: React.ReactNode }): React.JSX.Element {
22
+ return (
23
+ <svg
24
+ width={size}
25
+ height={size}
26
+ viewBox="0 0 24 24"
27
+ fill="none"
28
+ stroke="currentColor"
29
+ strokeWidth={strokeWidth}
30
+ strokeLinecap="round"
31
+ strokeLinejoin="round"
32
+ style={{ display: "block", flexShrink: 0, ...style }}
33
+ className={className}
34
+ aria-hidden="true"
35
+ >
36
+ {children}
37
+ </svg>
38
+ );
39
+ }
40
+
41
+ export const IconSearch = (p: SvgProps) => (
42
+ <Svg {...p}>
43
+ <circle cx="11" cy="11" r="7" />
44
+ <path d="m20 20-3.2-3.2" />
45
+ </Svg>
46
+ );
47
+
48
+ export const IconFilter = (p: SvgProps) => (
49
+ <Svg {...p}>
50
+ <path d="M3 5h18" />
51
+ <path d="M6 12h12" />
52
+ <path d="M10 19h4" />
53
+ </Svg>
54
+ );
55
+
56
+ export const IconPlus = (p: SvgProps) => (
57
+ <Svg {...p}>
58
+ <path d="M12 5v14M5 12h14" />
59
+ </Svg>
60
+ );
61
+
62
+ export const IconChevronRight = (p: SvgProps) => (
63
+ <Svg {...p}>
64
+ <path d="m9 6 6 6-6 6" />
65
+ </Svg>
66
+ );
67
+
68
+ export const IconChevronDown = (p: SvgProps) => (
69
+ <Svg {...p}>
70
+ <path d="m6 9 6 6 6-6" />
71
+ </Svg>
72
+ );
73
+
74
+ export const IconArrowLeft = (p: SvgProps) => (
75
+ <Svg {...p}>
76
+ <path d="M19 12H5M11 18l-6-6 6-6" />
77
+ </Svg>
78
+ );
79
+
80
+ export const IconClose = (p: SvgProps) => (
81
+ <Svg {...p}>
82
+ <path d="M18 6 6 18M6 6l12 12" />
83
+ </Svg>
84
+ );
85
+
86
+ export const IconTrash = (p: SvgProps) => (
87
+ <Svg {...p}>
88
+ <path d="M4 7h16M10 11v6M14 11v6" />
89
+ <path d="M5 7l1 13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1l1-13" />
90
+ <path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
91
+ </Svg>
92
+ );
93
+
94
+ export const IconSun = (p: SvgProps) => (
95
+ <Svg {...p}>
96
+ <circle cx="12" cy="12" r="4" />
97
+ <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
98
+ </Svg>
99
+ );
100
+
101
+ export const IconMoon = (p: SvgProps) => (
102
+ <Svg {...p}>
103
+ <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
104
+ </Svg>
105
+ );
106
+
107
+ export const IconTable = (p: SvgProps) => (
108
+ <Svg {...p}>
109
+ <rect x="3.5" y="4.5" width="17" height="15" rx="2" />
110
+ <path d="M3.5 9.5h17M9 9.5v10" />
111
+ </Svg>
112
+ );
113
+
114
+ export const IconLink = (p: SvgProps) => (
115
+ <Svg {...p}>
116
+ <path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1" />
117
+ <path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />
118
+ </Svg>
119
+ );
120
+
121
+ export const IconInbox = (p: SvgProps) => (
122
+ <Svg {...p}>
123
+ <path d="M3 13h5l1.5 3h5L21 13" />
124
+ <path d="M5 5h14l2 8v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5Z" />
125
+ </Svg>
126
+ );
127
+
128
+ /** The brand mark: an isometric "stack" tile in the accent colour. */
129
+ export function BrandMark({ size = 30 }: { size?: number }): React.JSX.Element {
130
+ return (
131
+ <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" style={{ display: "block" }}>
132
+ <rect width="32" height="32" rx="9" fill="var(--color-accent)" />
133
+ <g
134
+ fill="none"
135
+ stroke="var(--color-on-accent)"
136
+ strokeWidth="1.9"
137
+ strokeLinejoin="round"
138
+ strokeLinecap="round"
139
+ >
140
+ <path d="M16 7 25 12 16 17 7 12 16 7Z" />
141
+ <path d="M7 16.5 16 21.5 25 16.5" />
142
+ <path d="M7 21 16 26 25 21" opacity="0.55" />
143
+ </g>
144
+ </svg>
145
+ );
146
+ }