@cosmicdrift/kumiko-renderer-web 0.65.0 → 0.66.0
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 +6 -4
- package/src/__tests__/form-action-bar.test.tsx +50 -15
- package/src/__tests__/nav-tree.test.tsx +250 -16
- package/src/__tests__/primitives.test.tsx +9 -6
- package/src/__tests__/render-edit.test.tsx +6 -6
- package/src/__tests__/test-utils.tsx +21 -0
- package/src/__tests__/workspace-shell.test.tsx +21 -66
- package/src/app/__tests__/qualify-nav-provider-key.test.ts +27 -0
- package/src/app/client-plugin.tsx +12 -27
- package/src/app/create-app.tsx +31 -19
- package/src/app/nav-providers-context.tsx +56 -0
- package/src/index.ts +8 -0
- package/src/layout/app-layout.tsx +9 -2
- package/src/layout/default-app-shell.tsx +116 -33
- package/src/layout/nav-tree.tsx +491 -125
- package/src/layout/sidebar-brand.tsx +40 -0
- package/src/layout/sidebar-user.tsx +46 -0
- package/src/layout/sidebar.tsx +1 -1
- package/src/layout/target-resolver-stub.tsx +3 -5
- package/src/layout/workspace-shell.tsx +32 -34
- package/src/primitives/file-upload.tsx +118 -0
- package/src/primitives/index.tsx +314 -175
- package/src/styles.css +76 -50
- package/src/ui/avatar.tsx +110 -0
- package/src/ui/badge.tsx +49 -0
- package/src/ui/breadcrumb.tsx +110 -0
- package/src/ui/button.tsx +65 -0
- package/src/ui/card.tsx +93 -0
- package/src/ui/checkbox.tsx +33 -0
- package/src/ui/collapsible.tsx +34 -0
- package/src/ui/dropdown-menu.tsx +258 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +25 -0
- package/src/ui/select.tsx +191 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +144 -0
- package/src/ui/sidebar.tsx +727 -0
- package/src/ui/skeleton.tsx +14 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/textarea.tsx +19 -0
- package/src/ui/tooltip.tsx +58 -0
- package/src/ui/use-mobile.ts +20 -0
- package/src/__tests__/visual-tree-integration.test.tsx +0 -314
- package/src/app/tree-providers-context.tsx +0 -68
- package/src/layout/__tests__/visual-tree.test.tsx +0 -303
- package/src/layout/tree-node-renderer.tsx +0 -386
- package/src/layout/visual-tree.tsx +0 -398
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SidebarBrand — der TeamSwitcher-Header aus shadcns sidebar-07: Logo-Kachel +
|
|
2
|
+
// Name + Plan/Tagline + ChevronsUpDown. App-Author reicht ihn als `brand` an
|
|
3
|
+
// DefaultAppShell. Reiner Look (kein Team-Switch-Dropdown) — eine App hat
|
|
4
|
+
// meist EINE Identität; wer wechseln will, baut den Dropdown selbst drumrum.
|
|
5
|
+
|
|
6
|
+
import { ChevronsUpDown } from "lucide-react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
|
|
9
|
+
|
|
10
|
+
export type SidebarBrandProps = {
|
|
11
|
+
/** Workspace-/App-Name (fett, erste Zeile). */
|
|
12
|
+
readonly name: string;
|
|
13
|
+
/** Zweite Zeile — Plan, Tagline, Tenant (klein, gedimmt). */
|
|
14
|
+
readonly plan?: string;
|
|
15
|
+
/** Logo in der Kachel — typisch ein Lucide-Icon. Fehlt es, steht der
|
|
16
|
+
* erste Buchstabe des Namens. */
|
|
17
|
+
readonly logo?: ReactNode;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function SidebarBrand({ name, plan, logo }: SidebarBrandProps): ReactNode {
|
|
21
|
+
return (
|
|
22
|
+
<SidebarMenu>
|
|
23
|
+
<SidebarMenuItem>
|
|
24
|
+
<SidebarMenuButton
|
|
25
|
+
size="lg"
|
|
26
|
+
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
27
|
+
>
|
|
28
|
+
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
|
29
|
+
{logo ?? <span className="text-sm font-semibold">{name.charAt(0)}</span>}
|
|
30
|
+
</div>
|
|
31
|
+
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
32
|
+
<span className="truncate font-semibold">{name}</span>
|
|
33
|
+
{plan !== undefined && <span className="truncate text-xs">{plan}</span>}
|
|
34
|
+
</div>
|
|
35
|
+
<ChevronsUpDown className="ml-auto" />
|
|
36
|
+
</SidebarMenuButton>
|
|
37
|
+
</SidebarMenuItem>
|
|
38
|
+
</SidebarMenu>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SidebarUser — der NavUser-Footer aus shadcns sidebar-07: Avatar + Name +
|
|
2
|
+
// Email + ChevronsUpDown. App-Author reicht ihn als `sidebarFooter` an
|
|
3
|
+
// DefaultAppShell. Reiner Look (kein Account-Dropdown) — wer ein User-Menü
|
|
4
|
+
// will, wrappt ihn in einen DropdownMenu.
|
|
5
|
+
|
|
6
|
+
import { ChevronsUpDown } from "lucide-react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
|
9
|
+
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
|
|
10
|
+
|
|
11
|
+
export type SidebarUserProps = {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly email?: string;
|
|
14
|
+
/** Avatar-URL. Fehlt sie, stehen die Initialen aus dem Namen. */
|
|
15
|
+
readonly avatar?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function initials(name: string): string {
|
|
19
|
+
const parts = name.trim().split(/\s+/);
|
|
20
|
+
const first = parts[0]?.charAt(0) ?? "";
|
|
21
|
+
const last = parts.length > 1 ? (parts[parts.length - 1]?.charAt(0) ?? "") : "";
|
|
22
|
+
return (first + last).toUpperCase() || name.slice(0, 2).toUpperCase();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SidebarUser({ name, email, avatar }: SidebarUserProps): ReactNode {
|
|
26
|
+
return (
|
|
27
|
+
<SidebarMenu>
|
|
28
|
+
<SidebarMenuItem>
|
|
29
|
+
<SidebarMenuButton
|
|
30
|
+
size="lg"
|
|
31
|
+
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
32
|
+
>
|
|
33
|
+
<Avatar className="size-8 rounded-lg">
|
|
34
|
+
{avatar !== undefined && <AvatarImage src={avatar} alt={name} />}
|
|
35
|
+
<AvatarFallback className="rounded-lg">{initials(name)}</AvatarFallback>
|
|
36
|
+
</Avatar>
|
|
37
|
+
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
38
|
+
<span className="truncate font-semibold">{name}</span>
|
|
39
|
+
{email !== undefined && <span className="truncate text-xs">{email}</span>}
|
|
40
|
+
</div>
|
|
41
|
+
<ChevronsUpDown className="ml-auto size-4" />
|
|
42
|
+
</SidebarMenuButton>
|
|
43
|
+
</SidebarMenuItem>
|
|
44
|
+
</SidebarMenu>
|
|
45
|
+
);
|
|
46
|
+
}
|
package/src/layout/sidebar.tsx
CHANGED
|
@@ -28,7 +28,7 @@ export function Sidebar({ header, actions, children, footer, testId }: SidebarPr
|
|
|
28
28
|
<aside
|
|
29
29
|
data-testid={testId}
|
|
30
30
|
data-kumiko-layout="sidebar"
|
|
31
|
-
className="w-60 flex-shrink-0
|
|
31
|
+
className="hidden md:flex w-60 flex-shrink-0 bg-transparent flex-col text-sm"
|
|
32
32
|
>
|
|
33
33
|
{header !== undefined && (
|
|
34
34
|
<div
|
|
@@ -12,11 +12,9 @@
|
|
|
12
12
|
// (damit unhandled Klicks sichtbar bleiben).
|
|
13
13
|
//
|
|
14
14
|
// **useDispatchTarget (V.1.4b)** ist der empfohlene Production-Pfad.
|
|
15
|
-
//
|
|
16
|
-
// URL via nav.setSearchParams UND ruft den globalen dispatchTarget für
|
|
15
|
+
// NavTree ruft den Hook im Click-Handler eines target-Knotens — er schreibt
|
|
16
|
+
// die URL via nav.setSearchParams UND ruft den globalen dispatchTarget für
|
|
17
17
|
// Test-Listener-Kompatibilität.
|
|
18
|
-
//
|
|
19
|
-
// Siehe visual-tree.md V.1.2 + V.1.4b.
|
|
20
18
|
|
|
21
19
|
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
20
|
import { useNav } from "@cosmicdrift/kumiko-renderer";
|
|
@@ -40,7 +38,7 @@ export function dispatchTarget(target: TargetRef): void {
|
|
|
40
38
|
return;
|
|
41
39
|
}
|
|
42
40
|
// biome-ignore lint/suspicious/noConsole: fallback wenn kein Subscriber registered
|
|
43
|
-
console.debug("[
|
|
41
|
+
console.debug("[NavTree] target dispatched (unhandled)", target);
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
/** Test-Hook: Exklusiver Spy. Returnt cleanup. */
|
|
@@ -34,12 +34,13 @@ import type { AppSchema, FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kum
|
|
|
34
34
|
import { qualifyNavId, toAppSchema, useNav } from "@cosmicdrift/kumiko-renderer";
|
|
35
35
|
import { type ReactNode, useCallback, useLayoutEffect, useMemo } from "react";
|
|
36
36
|
import { useResolvers } from "../app/resolvers-context";
|
|
37
|
+
import { SidebarProvider } from "../ui/sidebar";
|
|
37
38
|
import { AppLayout } from "./app-layout";
|
|
38
39
|
import { EditorPanel } from "./editor-panel";
|
|
39
40
|
import { lastSegment, NavTree } from "./nav-tree";
|
|
40
41
|
import { Sidebar } from "./sidebar";
|
|
42
|
+
import { parseTargetFromSearchParams } from "./target-url";
|
|
41
43
|
import { Topbar } from "./topbar";
|
|
42
|
-
import { VisualTree } from "./visual-tree";
|
|
43
44
|
import { WorkspaceSwitcher } from "./workspace-switcher";
|
|
44
45
|
|
|
45
46
|
export type WorkspaceShellUser = {
|
|
@@ -185,43 +186,40 @@ export function WorkspaceShell({
|
|
|
185
186
|
<WorkspaceSwitcher workspaces={visible} activeId={activeId} onSelect={handleSelect} />
|
|
186
187
|
);
|
|
187
188
|
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
// app/tree-providers-context.tsx) — App-Author registriert seine
|
|
196
|
-
// clientFeatures.treeProvider via createKumikoApp, kein zusätzlicher
|
|
197
|
-
// Prop hier nötig. Workspace-ID wird durchgereicht für die
|
|
198
|
-
// localStorage-Persistenz des expanded-Set.
|
|
199
|
-
// Siehe docs/plans/architecture/visual-tree.md A1 + V.1.1-A.
|
|
189
|
+
// EINE Nav: immer NavTree. Dynamische Knoten (r.nav({ provider: true }))
|
|
190
|
+
// ziehen ihre Children client-seitig aus den registrierten navProviders —
|
|
191
|
+
// der frühere navigation:"tree"/VisualTree-Zweig ist hier eingefaltet.
|
|
192
|
+
// Content-Bereich: ein aktives target (aus den nav.searchParams, gesetzt
|
|
193
|
+
// beim Klick auf einen target-Knoten) rendert den EditorPanel statt des
|
|
194
|
+
// RoutedScreen — sonst die normalen screen-children.
|
|
195
|
+
// (WorkspaceDefinition.navigation ist seitdem ein No-op; deferred removal.)
|
|
200
196
|
const resolvers = useResolvers();
|
|
201
|
-
const
|
|
197
|
+
const activeTarget = parseTargetFromSearchParams(nav.searchParams);
|
|
202
198
|
|
|
203
|
-
const sidebarContent =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
{...(allowedNavQns !== undefined && { allowedNavQns })}
|
|
211
|
-
/>
|
|
212
|
-
);
|
|
199
|
+
const sidebarContent = (
|
|
200
|
+
<NavTree
|
|
201
|
+
schema={app}
|
|
202
|
+
{...(user !== undefined && { user })}
|
|
203
|
+
{...(allowedNavQns !== undefined && { allowedNavQns })}
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
213
206
|
|
|
207
|
+
// SidebarProvider: NavTree rendert vendored shadcn-SidebarMenuButtons, die
|
|
208
|
+
// useSidebar() rufen. WorkspaceShell behält sein eigenes Topbar-Layout
|
|
209
|
+
// (AppLayout + Sidebar), braucht aber den Context-Wrapper drumherum.
|
|
214
210
|
return (
|
|
215
|
-
<
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
{
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
211
|
+
<SidebarProvider>
|
|
212
|
+
<AppLayout
|
|
213
|
+
topbar={<Topbar start={brand} center={switcher || undefined} end={topbarActions} />}
|
|
214
|
+
sidebar={
|
|
215
|
+
<Sidebar {...(sidebarFooter !== undefined && { footer: sidebarFooter })}>
|
|
216
|
+
{sidebarContent}
|
|
217
|
+
</Sidebar>
|
|
218
|
+
}
|
|
219
|
+
>
|
|
220
|
+
{activeTarget !== undefined ? <EditorPanel resolvers={resolvers} /> : children}
|
|
221
|
+
</AppLayout>
|
|
222
|
+
</SidebarProvider>
|
|
225
223
|
);
|
|
226
224
|
}
|
|
227
225
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// File/Image-Upload-Widget für die Auto-Edit-Form. Lädt eine Datei per
|
|
2
|
+
// multipart-POST an /api/files (vom Stack gemountet wenn ein storageProvider
|
|
3
|
+
// gesetzt ist), bekommt die FileRef-UUID zurück und gibt sie als Field-Wert
|
|
4
|
+
// hoch. Image → runde Avatar-Preview (GET /api/files/:id), file → Dateiname.
|
|
5
|
+
|
|
6
|
+
import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
7
|
+
import { ImageIcon, Loader2, Upload } from "lucide-react";
|
|
8
|
+
import { type ChangeEvent, type ReactNode, useRef, useState } from "react";
|
|
9
|
+
import { Button as UiButton } from "../ui/button";
|
|
10
|
+
|
|
11
|
+
export type FileUploadInputProps = {
|
|
12
|
+
readonly kind: "file" | "image";
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly value: string | null;
|
|
15
|
+
readonly onChange: (fileId: string | null) => void;
|
|
16
|
+
readonly accept?: readonly string[];
|
|
17
|
+
readonly disabled?: boolean;
|
|
18
|
+
readonly entityType?: string;
|
|
19
|
+
readonly fieldName?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// "jpg" → ".jpg", "image/png" bleibt. Leere Liste → kein accept-Attribut.
|
|
23
|
+
function toAcceptAttr(accept?: readonly string[]): string | undefined {
|
|
24
|
+
if (accept === undefined || accept.length === 0) return undefined;
|
|
25
|
+
return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function FileUploadInput({
|
|
29
|
+
kind,
|
|
30
|
+
id,
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
accept,
|
|
34
|
+
disabled,
|
|
35
|
+
entityType,
|
|
36
|
+
fieldName,
|
|
37
|
+
}: FileUploadInputProps): ReactNode {
|
|
38
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
39
|
+
const [uploading, setUploading] = useState(false);
|
|
40
|
+
const [error, setError] = useState<string | null>(null);
|
|
41
|
+
|
|
42
|
+
async function onPick(e: ChangeEvent<HTMLInputElement>): Promise<void> {
|
|
43
|
+
const file = e.target.files?.[0];
|
|
44
|
+
if (file === undefined) return;
|
|
45
|
+
setUploading(true);
|
|
46
|
+
setError(null);
|
|
47
|
+
try {
|
|
48
|
+
const fd = new FormData();
|
|
49
|
+
fd.append("file", file);
|
|
50
|
+
if (entityType !== undefined) fd.append("entityType", entityType);
|
|
51
|
+
if (fieldName !== undefined) fd.append("fieldName", fieldName);
|
|
52
|
+
// Double-Submit-CSRF wie der Dispatcher: kumiko_csrf-Cookie → Header.
|
|
53
|
+
const csrf = readCsrfToken();
|
|
54
|
+
const res = await fetch("/api/files", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
body: fd,
|
|
57
|
+
...(csrf !== undefined && { headers: { [CSRF_HEADER_NAME]: csrf } }),
|
|
58
|
+
});
|
|
59
|
+
const json = (await res.json().catch(() => ({}))) as { id?: string; error?: string };
|
|
60
|
+
if (!res.ok || json.id === undefined) {
|
|
61
|
+
setError(json.error ?? "upload_failed");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
onChange(json.id);
|
|
65
|
+
} catch {
|
|
66
|
+
setError("upload_failed");
|
|
67
|
+
} finally {
|
|
68
|
+
setUploading(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const acceptAttr = toAcceptAttr(accept);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex items-center gap-4">
|
|
76
|
+
{kind === "image" &&
|
|
77
|
+
(value !== null ? (
|
|
78
|
+
<img
|
|
79
|
+
src={`/api/files/${value}`}
|
|
80
|
+
alt=""
|
|
81
|
+
className="size-16 rounded-full border object-cover"
|
|
82
|
+
/>
|
|
83
|
+
) : (
|
|
84
|
+
<div className="bg-muted text-muted-foreground flex size-16 items-center justify-center rounded-full border">
|
|
85
|
+
<ImageIcon className="size-6" />
|
|
86
|
+
</div>
|
|
87
|
+
))}
|
|
88
|
+
{kind === "file" && value !== null && (
|
|
89
|
+
<span className="text-muted-foreground text-sm">File attached</span>
|
|
90
|
+
)}
|
|
91
|
+
<input
|
|
92
|
+
ref={inputRef}
|
|
93
|
+
id={id}
|
|
94
|
+
type="file"
|
|
95
|
+
className="hidden"
|
|
96
|
+
{...(acceptAttr !== undefined && { accept: acceptAttr })}
|
|
97
|
+
onChange={(e) => void onPick(e)}
|
|
98
|
+
disabled={disabled}
|
|
99
|
+
/>
|
|
100
|
+
<UiButton
|
|
101
|
+
type="button"
|
|
102
|
+
variant="outline"
|
|
103
|
+
size="sm"
|
|
104
|
+
disabled={disabled === true || uploading}
|
|
105
|
+
onClick={() => inputRef.current?.click()}
|
|
106
|
+
>
|
|
107
|
+
{uploading ? <Loader2 className="size-4 animate-spin" /> : <Upload className="size-4" />}
|
|
108
|
+
{value !== null ? "Change" : "Upload"}
|
|
109
|
+
</UiButton>
|
|
110
|
+
{value !== null && (
|
|
111
|
+
<UiButton type="button" variant="ghost" size="sm" onClick={() => onChange(null)}>
|
|
112
|
+
Remove
|
|
113
|
+
</UiButton>
|
|
114
|
+
)}
|
|
115
|
+
{error !== null && <span className="text-destructive text-xs">{error}</span>}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|