@valentinkolb/cloud 0.4.0 → 0.5.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/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { charts } from "@valentinkolb/stdlib";
|
|
2
|
+
import type { JSX } from "solid-js";
|
|
3
|
+
import { createSignal, onCleanup, onMount, Show } from "solid-js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Chart — minimal Solid wrapper around `stdlib.charts`.
|
|
7
|
+
*
|
|
8
|
+
* **Live-update story.** `charts.<kind>(opts)` returns an SVG string;
|
|
9
|
+
* Solid's `innerHTML` is reactive, so any time a prop (signal, store
|
|
10
|
+
* slice, derived value) changes, the SVG re-renders. No manual
|
|
11
|
+
* subscription, no imperative DOM patching. Trade-off: every change
|
|
12
|
+
* is a full SVG re-build, not a diff — fine for dashboard cadences
|
|
13
|
+
* (poll, websocket, store updates). Don't use this for 60fps streaming.
|
|
14
|
+
*
|
|
15
|
+
* **Sizing.** stdlib emits `<svg viewBox="0 0 W H">` with no width/
|
|
16
|
+
* height attributes, so the SVG would otherwise fall back to the
|
|
17
|
+
* browser's replaced-element default (300×150) and either overflow
|
|
18
|
+
* or look squished. We measure the wrapping `<div>` with a
|
|
19
|
+
* ResizeObserver and pass the actual pixel dimensions to stdlib —
|
|
20
|
+
* the viewBox matches the container, no aspect distortion, no
|
|
21
|
+
* letterboxing. Sizing the wrapper itself is the caller's job
|
|
22
|
+
* (`class="h-56 w-full"`, flex child, etc.). On SSR (no observer)
|
|
23
|
+
* the chart renders at stdlib's default size; the first client-side
|
|
24
|
+
* frame re-measures and re-renders.
|
|
25
|
+
*
|
|
26
|
+
* **Why so thin.** The props are a discriminated union over each
|
|
27
|
+
* stdlib chart function — `kind: "line"` brings in exactly the params
|
|
28
|
+
* `charts.line` expects, `kind: "bar"` brings in `charts.bar`'s, etc.
|
|
29
|
+
* Zero invented API, zero option renaming. If stdlib gains a new
|
|
30
|
+
* option, it's automatically available at every callsite.
|
|
31
|
+
*
|
|
32
|
+
* **Theming.** stdlib charts use `currentColor` for axes / ticks /
|
|
33
|
+
* tick labels — set the wrapping element's `color` (via Tailwind
|
|
34
|
+
* `text-dimmed` / `text-primary` / dark variants) and everything
|
|
35
|
+
* inherits. Series colors come from `--stdlib-chart-c1..c8` CSS
|
|
36
|
+
* custom properties; override on the parent for per-chart palettes.
|
|
37
|
+
*
|
|
38
|
+
* ```tsx
|
|
39
|
+
* <Chart kind="line" class="h-48 text-dimmed"
|
|
40
|
+
* series={[{ data: points() }]}
|
|
41
|
+
* yAxis={{ format: v => `€${v}k` }} />
|
|
42
|
+
*
|
|
43
|
+
* <Chart kind="donut" class="h-48" data={slices()} />
|
|
44
|
+
*
|
|
45
|
+
* <Chart kind="sparkline" class="w-24 h-6 text-emerald-600" data={trend()} />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** All chart kinds shipped by `stdlib.charts`. */
|
|
50
|
+
export type ChartKind = keyof typeof charts;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Per-kind props: `kind` discriminator + the exact options that
|
|
54
|
+
* `charts.<kind>` accepts, **minus** `width` / `height` (the wrapper
|
|
55
|
+
* owns those — they're derived from container measurement). Solid's
|
|
56
|
+
* component model handles discriminated unions natively, so callsites
|
|
57
|
+
* get full type safety.
|
|
58
|
+
*/
|
|
59
|
+
export type ChartProps = {
|
|
60
|
+
[K in ChartKind]: { kind: K; class?: string; style?: JSX.CSSProperties | string } & Omit<
|
|
61
|
+
Parameters<(typeof charts)[K]>[0],
|
|
62
|
+
"width" | "height"
|
|
63
|
+
>;
|
|
64
|
+
}[ChartKind];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Internal — strips wrapper-only keys from props and forwards the
|
|
68
|
+
* rest (plus measured size) to `charts[kind]`. The `any` is the
|
|
69
|
+
* price for dispatching one function call across 8 different option
|
|
70
|
+
* types; an explicit per-kind switch would type it but balloon the
|
|
71
|
+
* component for no runtime benefit.
|
|
72
|
+
*/
|
|
73
|
+
const renderSvg = (props: ChartProps, width: number, height: number): string => {
|
|
74
|
+
const { kind, class: _class, style: _style, ...opts } = props;
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
|
+
return (charts[kind] as (o: unknown) => string)({ ...(opts as any), width, height });
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Empty-data short-circuit. Kept per-kind because stdlib's payload
|
|
80
|
+
* key differs (series vs data vs groups). We're conservative: only
|
|
81
|
+
* block on truly empty inputs; partially-filled series get rendered
|
|
82
|
+
* as-is and stdlib handles the gaps. */
|
|
83
|
+
const isEmpty = (props: ChartProps): boolean => {
|
|
84
|
+
if (props.kind === "line" || props.kind === "scatter") {
|
|
85
|
+
return !props.series?.length || props.series.every((s) => !s.data.length);
|
|
86
|
+
}
|
|
87
|
+
if (props.kind === "bar" || props.kind === "donut" || props.kind === "pie") {
|
|
88
|
+
return !props.data?.length;
|
|
89
|
+
}
|
|
90
|
+
if (props.kind === "histogram" || props.kind === "sparkline") {
|
|
91
|
+
return !props.data?.length;
|
|
92
|
+
}
|
|
93
|
+
if (props.kind === "boxplot") {
|
|
94
|
+
return !props.groups?.length;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const Chart = (props: ChartProps): JSX.Element => {
|
|
100
|
+
let containerRef: HTMLDivElement | undefined;
|
|
101
|
+
// Initial size matches stdlib's chart-function defaults so the SSR
|
|
102
|
+
// render is sensible. The observer updates this on the first
|
|
103
|
+
// client-side frame; the SVG re-renders reactively via innerHTML.
|
|
104
|
+
const [size, setSize] = createSignal({ width: 480, height: 280 });
|
|
105
|
+
|
|
106
|
+
onMount(() => {
|
|
107
|
+
if (!containerRef) return;
|
|
108
|
+
// Seed immediately from layout — avoids one wasted re-render in
|
|
109
|
+
// the case where the container already has its final size at
|
|
110
|
+
// mount time (the common case for dashboard widgets).
|
|
111
|
+
const rect = containerRef.getBoundingClientRect();
|
|
112
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
113
|
+
setSize({ width: Math.round(rect.width), height: Math.round(rect.height) });
|
|
114
|
+
}
|
|
115
|
+
const ro = new ResizeObserver((entries) => {
|
|
116
|
+
const entry = entries[0];
|
|
117
|
+
if (!entry) return;
|
|
118
|
+
const { width, height } = entry.contentRect;
|
|
119
|
+
// Floor to integer pixels; sub-pixel jitter would trigger an
|
|
120
|
+
// SVG re-render on every scroll/zoom otherwise.
|
|
121
|
+
if (width > 0 && height > 0) {
|
|
122
|
+
setSize((prev) => {
|
|
123
|
+
const w = Math.round(width);
|
|
124
|
+
const h = Math.round(height);
|
|
125
|
+
return prev.width === w && prev.height === h ? prev : { width: w, height: h };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
ro.observe(containerRef);
|
|
130
|
+
onCleanup(() => ro.disconnect());
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<Show
|
|
135
|
+
when={!isEmpty(props)}
|
|
136
|
+
fallback={
|
|
137
|
+
<div
|
|
138
|
+
ref={containerRef}
|
|
139
|
+
class={`flex items-center justify-center text-xs text-dimmed ${props.class ?? ""}`}
|
|
140
|
+
style={props.style}
|
|
141
|
+
>
|
|
142
|
+
No data
|
|
143
|
+
</div>
|
|
144
|
+
}
|
|
145
|
+
>
|
|
146
|
+
{/* The wrapping div is what the ResizeObserver watches. `block`
|
|
147
|
+
+ the caller's sizing classes (h-48, w-full, flex-1, …) drive
|
|
148
|
+
the available space; the SVG inside fills it via viewBox =
|
|
149
|
+
container size. `innerHTML` is reactive in Solid — re-runs
|
|
150
|
+
on every prop / size change, so live data updates propagate
|
|
151
|
+
without ceremony. */}
|
|
152
|
+
<div
|
|
153
|
+
ref={containerRef}
|
|
154
|
+
class={`block ${props.class ?? ""}`}
|
|
155
|
+
style={props.style}
|
|
156
|
+
innerHTML={renderSvg(props, size().width, size().height)}
|
|
157
|
+
/>
|
|
158
|
+
</Show>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export default Chart;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { For, Show } from "solid-js";
|
|
2
|
+
import CopyButton from "./CopyButton";
|
|
3
|
+
import { type CodeDisplayLanguage, highlightCodeDisplayLines } from "./code-highlight";
|
|
4
|
+
|
|
5
|
+
export type { CodeDisplayLanguage };
|
|
6
|
+
|
|
7
|
+
export type CodeDisplayProps = {
|
|
8
|
+
code: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
language?: CodeDisplayLanguage;
|
|
11
|
+
copy?: boolean;
|
|
12
|
+
lineNumbers?: boolean;
|
|
13
|
+
class?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function CodeDisplay(props: CodeDisplayProps) {
|
|
17
|
+
const lines = () => highlightCodeDisplayLines(props.code, language());
|
|
18
|
+
const lineNumbers = () => props.lineNumbers ?? true;
|
|
19
|
+
const language = () => props.language ?? "text";
|
|
20
|
+
const hasHeader = () => Boolean(props.title || props.copy !== false);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
class={`code-display my-3 overflow-hidden rounded-lg bg-zinc-100 ring-1 ring-inset ring-zinc-200/70 dark:bg-zinc-900/70 dark:ring-zinc-800/80 ${props.class ?? ""}`}
|
|
25
|
+
>
|
|
26
|
+
<Show when={hasHeader()}>
|
|
27
|
+
<div class="flex items-center justify-between gap-3 px-3 py-1.5">
|
|
28
|
+
<Show when={props.title}>{(title) => <p class="truncate text-xs font-semibold text-secondary">{title()}</p>}</Show>
|
|
29
|
+
<Show when={props.copy !== false}>
|
|
30
|
+
<CopyButton
|
|
31
|
+
text={props.code}
|
|
32
|
+
class="focus-ui inline-flex h-6 w-6 items-center justify-center rounded text-[10px] text-dimmed hover:bg-white/80 hover:text-primary dark:hover:bg-zinc-800"
|
|
33
|
+
/>
|
|
34
|
+
</Show>
|
|
35
|
+
</div>
|
|
36
|
+
</Show>
|
|
37
|
+
|
|
38
|
+
<div class={`code-display-code overflow-x-auto px-3 ${hasHeader() ? "pb-2" : "py-2"} font-mono text-xs leading-5`}>
|
|
39
|
+
<div class="min-w-max">
|
|
40
|
+
<For each={lines()}>
|
|
41
|
+
{(line, index) => (
|
|
42
|
+
<div class={lineNumbers() ? "grid grid-cols-[2rem_1fr]" : "grid grid-cols-[1fr]"}>
|
|
43
|
+
<Show when={lineNumbers()}>
|
|
44
|
+
<span class="select-none pr-3 text-right tabular-nums text-zinc-400 dark:text-zinc-600">{index() + 1}</span>
|
|
45
|
+
</Show>
|
|
46
|
+
<code class="whitespace-pre pr-4 font-mono text-primary" innerHTML={line || " "} />
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
</For>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -20,7 +20,7 @@ export type ContextMenuProps = ParentProps<{
|
|
|
20
20
|
}>;
|
|
21
21
|
|
|
22
22
|
const ITEM_BASE_CLASSES =
|
|
23
|
-
"flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-
|
|
23
|
+
"flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-zinc-100 dark:hover:bg-white/10";
|
|
24
24
|
|
|
25
25
|
const getVariantClasses = (variant?: "danger") =>
|
|
26
26
|
variant === "danger" ? "text-red-600 dark:text-red-400" : "text-zinc-700 dark:text-zinc-300";
|
|
@@ -178,7 +178,7 @@ export default function ContextMenu(props: ContextMenuProps) {
|
|
|
178
178
|
ref={menuRef}
|
|
179
179
|
role="menu"
|
|
180
180
|
aria-label="Context menu"
|
|
181
|
-
class="fixed z-50 w-52 max-w-[min(22rem,calc(100vw-1rem))] overflow-y-auto rounded-xl border border-zinc-300/60 bg-white/95 p-0 text-zinc-900 shadow-
|
|
181
|
+
class="fixed z-50 w-52 max-w-[min(22rem,calc(100vw-1rem))] overflow-y-auto rounded-xl border border-zinc-300/60 bg-white/95 p-0 text-zinc-900 [box-shadow:var(--theme-shadow-float)] ring-1 ring-black/5 backdrop-blur-sm dark:border-zinc-600/50 dark:bg-zinc-950/95 dark:text-zinc-100"
|
|
182
182
|
style={{
|
|
183
183
|
left: `${Math.min(coords().x, window.innerWidth - 220)}px`,
|
|
184
184
|
top: `${Math.min(coords().y, window.innerHeight - 320)}px`,
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { createEffect, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
|
|
2
|
+
import Placeholder from "./Placeholder";
|
|
3
|
+
|
|
4
|
+
export type DataTableColumn<T> = {
|
|
5
|
+
id: string;
|
|
6
|
+
header: JSX.Element | ((ctx: { col: DataTableColumn<T> }) => JSX.Element);
|
|
7
|
+
subtitle?: JSX.Element | ((ctx: { col: DataTableColumn<T> }) => JSX.Element);
|
|
8
|
+
value?: keyof T | ((row: T) => unknown);
|
|
9
|
+
class?: string;
|
|
10
|
+
headerClass?: string;
|
|
11
|
+
cellClass?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type DataTableRenderCell<T> = (ctx: {
|
|
15
|
+
row: T;
|
|
16
|
+
col: DataTableColumn<T>;
|
|
17
|
+
value: unknown;
|
|
18
|
+
render: (value: unknown) => JSX.Element;
|
|
19
|
+
}) => JSX.Element;
|
|
20
|
+
|
|
21
|
+
export type DataTableRenderHeader<T> = (ctx: { col: DataTableColumn<T>; render: () => JSX.Element }) => JSX.Element;
|
|
22
|
+
|
|
23
|
+
export type DataTableFooter<T> = {
|
|
24
|
+
values?: Record<string, unknown>;
|
|
25
|
+
renderCell?: (ctx: { col: DataTableColumn<T>; value: unknown; render: (value: unknown) => JSX.Element }) => JSX.Element;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type DataTableProps<T> = {
|
|
29
|
+
rows: readonly T[];
|
|
30
|
+
columns: readonly DataTableColumn<T>[];
|
|
31
|
+
getRowId?: (row: T) => string;
|
|
32
|
+
selectedRowId?: string | null;
|
|
33
|
+
rowClass?: string | ((row: T) => string | undefined);
|
|
34
|
+
hoverRows?: boolean;
|
|
35
|
+
onRowClick?: (row: T) => void;
|
|
36
|
+
onRowDoubleClick?: (row: T) => void;
|
|
37
|
+
renderCell?: DataTableRenderCell<T>;
|
|
38
|
+
renderHeader?: DataTableRenderHeader<T>;
|
|
39
|
+
footer?: DataTableFooter<T>;
|
|
40
|
+
hasMore?: boolean;
|
|
41
|
+
loadingMore?: boolean;
|
|
42
|
+
onLoadMore?: () => void;
|
|
43
|
+
empty?: JSX.Element;
|
|
44
|
+
density?: "compact" | "normal";
|
|
45
|
+
stickyHeader?: boolean;
|
|
46
|
+
highlightColumns?: boolean;
|
|
47
|
+
verticalAlign?: "top" | "middle" | "bottom";
|
|
48
|
+
cellContentClass?: string;
|
|
49
|
+
fillHeight?: boolean;
|
|
50
|
+
class?: string;
|
|
51
|
+
tableClass?: string;
|
|
52
|
+
scrollPreserveKey?: string | false;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const defaultRender = (value: unknown): JSX.Element => {
|
|
56
|
+
if (value === null || value === undefined || value === "") return "—";
|
|
57
|
+
if (value instanceof Date) return value.toLocaleString();
|
|
58
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
59
|
+
if (typeof value === "number" || typeof value === "bigint") return String(value);
|
|
60
|
+
if (typeof value === "string") return value;
|
|
61
|
+
return JSON.stringify(value);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const renderColumnPart = <T,>(
|
|
65
|
+
part: DataTableColumn<T>["header"] | DataTableColumn<T>["subtitle"],
|
|
66
|
+
col: DataTableColumn<T>,
|
|
67
|
+
): JSX.Element => {
|
|
68
|
+
if (typeof part === "function") return part({ col });
|
|
69
|
+
return part;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export default function DataTable<T>(props: DataTableProps<T>) {
|
|
73
|
+
const [hoveredColumn, setHoveredColumn] = createSignal<number | null>(null);
|
|
74
|
+
let scrollRef: HTMLDivElement | undefined;
|
|
75
|
+
let loadMoreRef: HTMLDivElement | undefined;
|
|
76
|
+
let hasMore = false;
|
|
77
|
+
let loadingMore = false;
|
|
78
|
+
let onLoadMore: (() => void) | undefined;
|
|
79
|
+
const rowId = (row: T) => props.getRowId?.(row);
|
|
80
|
+
const isInteractive = () => !!props.onRowClick || !!props.onRowDoubleClick;
|
|
81
|
+
const shouldHoverRows = () => props.hoverRows ?? isInteractive();
|
|
82
|
+
const shouldRenderLoadMoreSentinel = () => !!props.onLoadMore;
|
|
83
|
+
const cellPadding = () => (props.density === "compact" ? "px-3 py-1.5" : "px-3 py-2");
|
|
84
|
+
const headerPadding = () => (props.density === "compact" ? "px-3 py-1.5" : "px-3 py-2");
|
|
85
|
+
const cellContentClass = () => props.cellContentClass ?? "truncate";
|
|
86
|
+
const cellVerticalAlignClass = () =>
|
|
87
|
+
props.verticalAlign === "top" ? "align-top" : props.verticalAlign === "bottom" ? "align-bottom" : "align-middle";
|
|
88
|
+
const tableClass = () => props.tableClass ?? `w-full text-xs ${props.fillHeight ? "h-full" : ""}`;
|
|
89
|
+
const columnHoverClass = (index: number) =>
|
|
90
|
+
props.highlightColumns !== false && shouldHoverRows() && hoveredColumn() === index ? "bg-zinc-950/[0.015] dark:bg-black/[0.12]" : "";
|
|
91
|
+
const setHoveredColumnIfEnabled = (index: number) => {
|
|
92
|
+
if (shouldHoverRows()) setHoveredColumn(index);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const isNearBottom = () => {
|
|
96
|
+
if (!scrollRef) return false;
|
|
97
|
+
return scrollRef.scrollTop + scrollRef.clientHeight >= scrollRef.scrollHeight - 240;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const maybeLoadMore = () => {
|
|
101
|
+
if (!hasMore || loadingMore || !onLoadMore) return;
|
|
102
|
+
if (!isNearBottom()) return;
|
|
103
|
+
onLoadMore();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const valueOf = (row: T, col: DataTableColumn<T>) => {
|
|
107
|
+
if (typeof col.value === "function") return col.value(row);
|
|
108
|
+
if (col.value) return row[col.value];
|
|
109
|
+
return undefined;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const renderHeaderDefault = (col: DataTableColumn<T>): JSX.Element => (
|
|
113
|
+
<div class="flex flex-col gap-0.5 leading-tight">
|
|
114
|
+
<span class="text-primary font-semibold">{renderColumnPart(col.header, col)}</span>
|
|
115
|
+
<Show when={col.subtitle !== undefined}>
|
|
116
|
+
<span class="text-[10px] text-dimmed font-normal">{renderColumnPart(col.subtitle, col)}</span>
|
|
117
|
+
</Show>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const renderCellDefault = (row: T, col: DataTableColumn<T>) => defaultRender(valueOf(row, col));
|
|
122
|
+
|
|
123
|
+
const onRowKeyDown = (event: KeyboardEvent, row: T) => {
|
|
124
|
+
if (!isInteractive()) return;
|
|
125
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
props.onRowClick?.(row);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const rowClass = (row: T) => {
|
|
131
|
+
if (typeof props.rowClass === "function") return props.rowClass(row) ?? "";
|
|
132
|
+
return props.rowClass ?? "";
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
onMount(() => {
|
|
136
|
+
if (typeof IntersectionObserver === "undefined" || !scrollRef || !loadMoreRef) return;
|
|
137
|
+
const observer = new IntersectionObserver(
|
|
138
|
+
(entries) => {
|
|
139
|
+
if (entries.some((entry) => entry.isIntersecting)) maybeLoadMore();
|
|
140
|
+
},
|
|
141
|
+
{ root: scrollRef, rootMargin: "240px" },
|
|
142
|
+
);
|
|
143
|
+
observer.observe(loadMoreRef);
|
|
144
|
+
onCleanup(() => observer.disconnect());
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
createEffect(() => {
|
|
148
|
+
props.rows.length;
|
|
149
|
+
hasMore = !!props.hasMore;
|
|
150
|
+
loadingMore = !!props.loadingMore;
|
|
151
|
+
onLoadMore = props.onLoadMore;
|
|
152
|
+
maybeLoadMore();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Show when={props.columns.length > 0} fallback={<Placeholder surface="paper">No columns.</Placeholder>}>
|
|
157
|
+
<div
|
|
158
|
+
ref={scrollRef}
|
|
159
|
+
role="region"
|
|
160
|
+
aria-label="Data table"
|
|
161
|
+
class={props.class ?? "paper overflow-auto flex-1 min-h-0"}
|
|
162
|
+
data-scroll-preserve={props.scrollPreserveKey || undefined}
|
|
163
|
+
onScroll={maybeLoadMore}
|
|
164
|
+
onMouseLeave={() => setHoveredColumn(null)}
|
|
165
|
+
>
|
|
166
|
+
<table class={tableClass()}>
|
|
167
|
+
<thead class={props.stickyHeader === false ? undefined : "sticky top-0 z-10 bg-zinc-50 dark:bg-zinc-950"}>
|
|
168
|
+
<tr class="border-b border-zinc-100 dark:border-zinc-800">
|
|
169
|
+
<For each={props.columns}>
|
|
170
|
+
{(col, index) => (
|
|
171
|
+
<th
|
|
172
|
+
class={`${headerPadding()} text-left ${columnHoverClass(index())} ${col.headerClass ?? ""} ${col.class ?? ""}`}
|
|
173
|
+
onMouseEnter={() => setHoveredColumnIfEnabled(index())}
|
|
174
|
+
>
|
|
175
|
+
{props.renderHeader ? props.renderHeader({ col, render: () => renderHeaderDefault(col) }) : renderHeaderDefault(col)}
|
|
176
|
+
</th>
|
|
177
|
+
)}
|
|
178
|
+
</For>
|
|
179
|
+
</tr>
|
|
180
|
+
</thead>
|
|
181
|
+
<Show when={props.footer}>
|
|
182
|
+
{(footer) => (
|
|
183
|
+
<tfoot class="sticky bottom-0 z-10 bg-zinc-50 dark:bg-zinc-950">
|
|
184
|
+
<tr class="border-t border-zinc-100 dark:border-zinc-800">
|
|
185
|
+
<For each={props.columns}>
|
|
186
|
+
{(col, index) => {
|
|
187
|
+
const value = () => footer().values?.[col.id];
|
|
188
|
+
return (
|
|
189
|
+
<td
|
|
190
|
+
class={`px-3 py-1.5 text-[11px] text-dimmed ${columnHoverClass(index())}`}
|
|
191
|
+
onMouseEnter={() => setHoveredColumnIfEnabled(index())}
|
|
192
|
+
>
|
|
193
|
+
{footer().renderCell
|
|
194
|
+
? footer().renderCell!({ col, value: value(), render: defaultRender })
|
|
195
|
+
: defaultRender(value())}
|
|
196
|
+
</td>
|
|
197
|
+
);
|
|
198
|
+
}}
|
|
199
|
+
</For>
|
|
200
|
+
</tr>
|
|
201
|
+
</tfoot>
|
|
202
|
+
)}
|
|
203
|
+
</Show>
|
|
204
|
+
<tbody>
|
|
205
|
+
<Show
|
|
206
|
+
when={props.rows.length > 0}
|
|
207
|
+
fallback={
|
|
208
|
+
<tr>
|
|
209
|
+
<td class="p-0" colspan={props.columns.length}>
|
|
210
|
+
<Placeholder>{props.empty ?? "No records"}</Placeholder>
|
|
211
|
+
</td>
|
|
212
|
+
</tr>
|
|
213
|
+
}
|
|
214
|
+
>
|
|
215
|
+
<For each={props.rows}>
|
|
216
|
+
{(row) => {
|
|
217
|
+
const id = () => rowId(row);
|
|
218
|
+
const isSelected = () => props.selectedRowId && id() === props.selectedRowId;
|
|
219
|
+
return (
|
|
220
|
+
<tr
|
|
221
|
+
class={`border-b border-zinc-100 dark:border-zinc-800/60 last:border-0 ${
|
|
222
|
+
shouldHoverRows() ? `${isInteractive() ? "cursor-pointer" : ""} hover:bg-blue-500/[0.08] dark:hover:bg-blue-400/[0.12]` : ""
|
|
223
|
+
} ${isSelected() ? "bg-blue-50 dark:bg-blue-900/20" : ""} ${rowClass(row)}`}
|
|
224
|
+
tabIndex={isInteractive() ? 0 : undefined}
|
|
225
|
+
onClick={() => props.onRowClick?.(row)}
|
|
226
|
+
onDblClick={() => props.onRowDoubleClick?.(row)}
|
|
227
|
+
onKeyDown={(e) => onRowKeyDown(e, row)}
|
|
228
|
+
>
|
|
229
|
+
<For each={props.columns}>
|
|
230
|
+
{(col, index) => {
|
|
231
|
+
const value = () => valueOf(row, col);
|
|
232
|
+
return (
|
|
233
|
+
<td
|
|
234
|
+
class={`${cellPadding()} ${cellVerticalAlignClass()} max-w-[260px] ${columnHoverClass(index())} ${col.cellClass ?? ""} ${col.class ?? ""}`}
|
|
235
|
+
onMouseEnter={() => setHoveredColumnIfEnabled(index())}
|
|
236
|
+
>
|
|
237
|
+
<div class={cellContentClass()}>
|
|
238
|
+
{props.renderCell
|
|
239
|
+
? props.renderCell({
|
|
240
|
+
row,
|
|
241
|
+
col,
|
|
242
|
+
value: value(),
|
|
243
|
+
render: (v) => renderCellDefault(row, { ...col, value: () => v }),
|
|
244
|
+
})
|
|
245
|
+
: defaultRender(value())}
|
|
246
|
+
</div>
|
|
247
|
+
</td>
|
|
248
|
+
);
|
|
249
|
+
}}
|
|
250
|
+
</For>
|
|
251
|
+
</tr>
|
|
252
|
+
);
|
|
253
|
+
}}
|
|
254
|
+
</For>
|
|
255
|
+
<Show when={props.fillHeight}>
|
|
256
|
+
<tr aria-hidden="true">
|
|
257
|
+
<td class="h-full p-0" colspan={props.columns.length} />
|
|
258
|
+
</tr>
|
|
259
|
+
</Show>
|
|
260
|
+
</Show>
|
|
261
|
+
</tbody>
|
|
262
|
+
</table>
|
|
263
|
+
<Show when={shouldRenderLoadMoreSentinel()}>
|
|
264
|
+
<div ref={loadMoreRef} class="h-1" aria-hidden="true" />
|
|
265
|
+
</Show>
|
|
266
|
+
</div>
|
|
267
|
+
</Show>
|
|
268
|
+
);
|
|
269
|
+
}
|