@anchrd/intel-ui 0.2.1 → 0.4.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/components.json +7 -1
- package/package.json +3 -1
- package/src/app/app-sidebar/app-sidebar.tsx +56 -0
- package/src/app/app-tree/app-tree.tsx +427 -0
- package/src/app/app.tsx +84 -54
- package/src/app/header-actions/header-actions.tsx +15 -0
- package/src/app/header-search/header-search.tsx +291 -0
- package/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +85 -0
- package/src/app/user-footer/user-footer.tsx +76 -0
- package/src/components/ui/breadcrumb.tsx +102 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/collapsible.tsx +20 -0
- package/src/components/ui/command.tsx +160 -0
- package/src/components/ui/dialog.tsx +143 -0
- package/src/components/ui/dropdown-menu.tsx +84 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +136 -0
- package/src/components/ui/sidebar.tsx +693 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +58 -39
- package/src/data/intel-data-provider/intel-data-provider.types.ts +17 -8
- package/src/flows/flows.tsx +59 -142
- package/src/hooks/use-mobile.ts +19 -0
- package/src/i18n/en.json +48 -29
- package/src/knowledge/knowledge.tsx +133 -423
- package/src/router/router.tsx +4 -0
- package/src/router/selection-search.ts +19 -0
- package/src/styles.css +54 -51
- package/src/tools/tools.tsx +175 -158
package/components.json
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
"style": "new-york",
|
|
4
4
|
"rsc": false,
|
|
5
5
|
"tsx": true,
|
|
6
|
-
"tailwind": {
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "src/styles.css",
|
|
9
|
+
"baseColor": "neutral",
|
|
10
|
+
"cssVariables": true,
|
|
11
|
+
"prefix": ""
|
|
12
|
+
},
|
|
7
13
|
"aliases": {
|
|
8
14
|
"components": "@/components",
|
|
9
15
|
"utils": "@/lib/utils",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -40,9 +40,11 @@
|
|
|
40
40
|
"@xyflow/react": "^12.11.2",
|
|
41
41
|
"class-variance-authority": "^0.7.1",
|
|
42
42
|
"clsx": "^2.1.1",
|
|
43
|
+
"cmdk": "^1.1.1",
|
|
43
44
|
"colorjs.io": "^0.7.1",
|
|
44
45
|
"graphology": "^0.26.0",
|
|
45
46
|
"lucide-react": "^1.25.0",
|
|
47
|
+
"radix-ui": "^1.6.7",
|
|
46
48
|
"react": "^19.2.0",
|
|
47
49
|
"react-aria-components": "^1.19.0",
|
|
48
50
|
"react-dom": "^19.2.0",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import { AppTree } from "@/app/app-tree/app-tree.tsx";
|
|
3
|
+
import { SidebarResizeHandle } from "@/app/sidebar-resize-handle/sidebar-resize-handle.tsx";
|
|
4
|
+
import { UserFooter } from "@/app/user-footer/user-footer.tsx";
|
|
5
|
+
import { AppLogo } from "@/branding/branding.tsx";
|
|
6
|
+
import {
|
|
7
|
+
Sidebar,
|
|
8
|
+
SidebarContent,
|
|
9
|
+
SidebarFooter,
|
|
10
|
+
SidebarHeader,
|
|
11
|
+
SidebarMenu,
|
|
12
|
+
SidebarMenuButton,
|
|
13
|
+
SidebarMenuItem,
|
|
14
|
+
} from "@/components/ui/sidebar";
|
|
15
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
16
|
+
|
|
17
|
+
// One tree is the whole navigation: documents and flows side by side in the folder they share, with
|
|
18
|
+
// no Knowledge or Flows entry above them (ADR-0004). Tools stay below, next to the user, because
|
|
19
|
+
// they come live from the portal and have no tree.
|
|
20
|
+
export function AppSidebar({
|
|
21
|
+
width,
|
|
22
|
+
onWidthChange,
|
|
23
|
+
}: {
|
|
24
|
+
width: number;
|
|
25
|
+
onWidthChange(width: number): void;
|
|
26
|
+
}) {
|
|
27
|
+
const { i18n } = useIntelRouterContext();
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Sidebar collapsible="icon" variant="inset">
|
|
31
|
+
<SidebarHeader>
|
|
32
|
+
<SidebarMenu>
|
|
33
|
+
<SidebarMenuItem>
|
|
34
|
+
<SidebarMenuButton size="lg" asChild>
|
|
35
|
+
<Link to="/knowledge">
|
|
36
|
+
<AppLogo className="size-8" />
|
|
37
|
+
<span className="font-semibold">{i18n.t("app.name")}</span>
|
|
38
|
+
</Link>
|
|
39
|
+
</SidebarMenuButton>
|
|
40
|
+
</SidebarMenuItem>
|
|
41
|
+
</SidebarMenu>
|
|
42
|
+
</SidebarHeader>
|
|
43
|
+
<SidebarContent aria-label={i18n.t("nav.primary")}>
|
|
44
|
+
<AppTree />
|
|
45
|
+
</SidebarContent>
|
|
46
|
+
<SidebarFooter>
|
|
47
|
+
<UserFooter />
|
|
48
|
+
</SidebarFooter>
|
|
49
|
+
<SidebarResizeHandle
|
|
50
|
+
width={width}
|
|
51
|
+
label={i18n.t("shell.resizeSidebar")}
|
|
52
|
+
onWidthChange={onWidthChange}
|
|
53
|
+
/>
|
|
54
|
+
</Sidebar>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
3
|
+
import {
|
|
4
|
+
ChevronRight,
|
|
5
|
+
FileText,
|
|
6
|
+
Folder,
|
|
7
|
+
FolderOpen,
|
|
8
|
+
Paperclip,
|
|
9
|
+
Plus,
|
|
10
|
+
Upload,
|
|
11
|
+
Workflow,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import { useRef, useState } from "react";
|
|
14
|
+
import {
|
|
15
|
+
DropdownMenu,
|
|
16
|
+
DropdownMenuContent,
|
|
17
|
+
DropdownMenuItem,
|
|
18
|
+
DropdownMenuTrigger,
|
|
19
|
+
} from "@/components/ui/dropdown-menu";
|
|
20
|
+
import {
|
|
21
|
+
SidebarGroup,
|
|
22
|
+
SidebarMenu,
|
|
23
|
+
SidebarMenuButton,
|
|
24
|
+
SidebarMenuItem,
|
|
25
|
+
SidebarMenuSkeleton,
|
|
26
|
+
} from "@/components/ui/sidebar";
|
|
27
|
+
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
28
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
29
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
30
|
+
import { selectedFrom } from "@/router/selection-search.ts";
|
|
31
|
+
|
|
32
|
+
type NewKind = "folder" | "document" | "flow";
|
|
33
|
+
type Creating = { parentId: string | null; kind: NewKind };
|
|
34
|
+
|
|
35
|
+
const icons = {
|
|
36
|
+
folder: Folder,
|
|
37
|
+
document: FileText,
|
|
38
|
+
attachment: Paperclip,
|
|
39
|
+
flow: Workflow,
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
// Attachments are inlined as base64, so the browser holds the file twice while it uploads.
|
|
43
|
+
const MaxUploadBytes = 15_000_000;
|
|
44
|
+
|
|
45
|
+
async function fileBase64(file: File): Promise<string> {
|
|
46
|
+
if (file.size > MaxUploadBytes) throw new Error("Attachment exceeds the 15 MB upload limit");
|
|
47
|
+
return await new Promise((resolve, reject) => {
|
|
48
|
+
const reader = new FileReader();
|
|
49
|
+
reader.onerror = () => reject(reader.error ?? new Error("Attachment could not be read"));
|
|
50
|
+
reader.onload = () => {
|
|
51
|
+
const result = reader.result;
|
|
52
|
+
if (typeof result !== "string" || !result.includes(",")) {
|
|
53
|
+
reject(new Error("Attachment could not be encoded"));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
resolve(result.slice(result.indexOf(",") + 1));
|
|
57
|
+
};
|
|
58
|
+
reader.readAsDataURL(file);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// One key per level of the tree. `null` is the root; every expanded folder adds one of its own, and
|
|
63
|
+
// nothing else is ever asked for.
|
|
64
|
+
function levelKey(parentId: string | null): readonly unknown[] {
|
|
65
|
+
return ["tree", parentId];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function AppTree() {
|
|
69
|
+
const { data, i18n } = useIntelRouterContext();
|
|
70
|
+
const queryClient = useQueryClient();
|
|
71
|
+
const navigate = useNavigate();
|
|
72
|
+
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
|
|
73
|
+
const [creating, setCreating] = useState<Creating | null>(null);
|
|
74
|
+
const [uploadTo, setUploadTo] = useState<string | null>(null);
|
|
75
|
+
const uploadInput = useRef<HTMLInputElement>(null);
|
|
76
|
+
|
|
77
|
+
const location = useRouterState({
|
|
78
|
+
select: (state) => ({
|
|
79
|
+
pathname: state.location.pathname,
|
|
80
|
+
select: selectedFrom(state.location.search),
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ⚠️ The whole point of #11: one query per level, and a level only exists while its folder is
|
|
85
|
+
// open. A recursive load would put an N+1 on every page of the app, because the tree is now on
|
|
86
|
+
// every page.
|
|
87
|
+
const parents: Array<string | null> = [null, ...expanded];
|
|
88
|
+
const levels = useQueries({
|
|
89
|
+
queries: parents.map((parentId) => ({
|
|
90
|
+
queryKey: levelKey(parentId),
|
|
91
|
+
queryFn: () => data.listTreeChildren(parentId),
|
|
92
|
+
})),
|
|
93
|
+
});
|
|
94
|
+
const levelFor = (parentId: string | null) => levels[parents.indexOf(parentId)];
|
|
95
|
+
const root = levels[0];
|
|
96
|
+
|
|
97
|
+
function toggle(id: string, open: boolean) {
|
|
98
|
+
setExpanded((current) => {
|
|
99
|
+
const next = new Set(current);
|
|
100
|
+
if (open) next.add(id);
|
|
101
|
+
else next.delete(id);
|
|
102
|
+
return next;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ⚠️ The level a row appears in is not the only thing that goes stale. The header search reads
|
|
107
|
+
// the whole flow list, and the graph is what the link picker and the flow editor's Knowledge step
|
|
108
|
+
// offer — a row created here has to reach those too, or another screen keeps showing yesterday.
|
|
109
|
+
async function reveal(
|
|
110
|
+
parentId: string | null,
|
|
111
|
+
collection: "flows" | "knowledge-graph",
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
if (parentId !== null) toggle(parentId, true);
|
|
114
|
+
await Promise.all([
|
|
115
|
+
queryClient.invalidateQueries({ queryKey: levelKey(parentId) }),
|
|
116
|
+
queryClient.invalidateQueries({ queryKey: [collection] }),
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const create = useMutation({
|
|
121
|
+
mutationFn: async ({ parentId, kind, title }: Creating & { title: string }) => {
|
|
122
|
+
if (kind === "flow") {
|
|
123
|
+
const flow = await data.createFlow({
|
|
124
|
+
parentId,
|
|
125
|
+
title,
|
|
126
|
+
description: null,
|
|
127
|
+
idempotencyKey: crypto.randomUUID(),
|
|
128
|
+
});
|
|
129
|
+
return { area: "/flows" as const, id: flow.id, parentId, collection: "flows" as const };
|
|
130
|
+
}
|
|
131
|
+
const node = await data.createKnowledge({
|
|
132
|
+
parentId,
|
|
133
|
+
kind,
|
|
134
|
+
title,
|
|
135
|
+
description: null,
|
|
136
|
+
contextPolicy: "relevant",
|
|
137
|
+
idempotencyKey: crypto.randomUUID(),
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
area: "/knowledge" as const,
|
|
141
|
+
id: node.id,
|
|
142
|
+
parentId,
|
|
143
|
+
collection: "knowledge-graph" as const,
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
onSuccess: async (created) => {
|
|
147
|
+
setCreating(null);
|
|
148
|
+
await reveal(created.parentId, created.collection);
|
|
149
|
+
await navigate({ to: created.area, search: { select: created.id } });
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const upload = useMutation({
|
|
154
|
+
mutationFn: async ({ parentId, file }: { parentId: string | null; file: File }) => {
|
|
155
|
+
const contentBase64 = await fileBase64(file);
|
|
156
|
+
const node = await data.createKnowledge({
|
|
157
|
+
parentId,
|
|
158
|
+
kind: "attachment",
|
|
159
|
+
title: file.name,
|
|
160
|
+
description: null,
|
|
161
|
+
contextPolicy: "relevant",
|
|
162
|
+
idempotencyKey: crypto.randomUUID(),
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
await data.saveKnowledgeAttachment({
|
|
166
|
+
nodeId: node.id,
|
|
167
|
+
baseVersionId: null,
|
|
168
|
+
contentBase64,
|
|
169
|
+
mediaType: file.type || "application/octet-stream",
|
|
170
|
+
idempotencyKey: crypto.randomUUID(),
|
|
171
|
+
});
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// An attachment node without bytes is a row nobody can open. Take it back rather than
|
|
174
|
+
// leaving it in the tree, but never let the cleanup hide the real failure.
|
|
175
|
+
const current = await data.getKnowledge(node.id).catch(() => null);
|
|
176
|
+
if (current?.version === null && current.node.updatedAt === node.updatedAt) {
|
|
177
|
+
await data
|
|
178
|
+
.archiveKnowledge({
|
|
179
|
+
nodeId: node.id,
|
|
180
|
+
baseUpdatedAt: current.node.updatedAt,
|
|
181
|
+
archived: true,
|
|
182
|
+
idempotencyKey: crypto.randomUUID(),
|
|
183
|
+
})
|
|
184
|
+
.catch(() => undefined);
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
return { id: node.id, parentId };
|
|
189
|
+
},
|
|
190
|
+
onSuccess: async (created) => {
|
|
191
|
+
await reveal(created.parentId, "knowledge-graph");
|
|
192
|
+
await navigate({ to: "/knowledge", search: { select: created.id } });
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
function startUpload(parentId: string | null) {
|
|
197
|
+
setUploadTo(parentId);
|
|
198
|
+
uploadInput.current?.click();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function renderLevel(parentId: string | null, ancestors: ReadonlySet<string>, label?: string) {
|
|
202
|
+
const level = levelFor(parentId);
|
|
203
|
+
if (!level || level.isPending) {
|
|
204
|
+
return (
|
|
205
|
+
<SidebarMenu aria-label={label}>
|
|
206
|
+
{[0, 1, 2].map((row) => (
|
|
207
|
+
<SidebarMenuItem key={row}>
|
|
208
|
+
<SidebarMenuSkeleton showIcon />
|
|
209
|
+
</SidebarMenuItem>
|
|
210
|
+
))}
|
|
211
|
+
</SidebarMenu>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (level.isError) {
|
|
215
|
+
return (
|
|
216
|
+
<div role="alert" className="space-y-2 px-2 py-1.5 text-sm">
|
|
217
|
+
<p className="text-destructive">{i18n.t("tree.failed")}</p>
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={() => void level.refetch()}
|
|
221
|
+
className="rounded-md border px-2 py-1 text-xs outline-none hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
|
222
|
+
>
|
|
223
|
+
{i18n.t("common.retry")}
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
// ⚠️ The cycle guard, in the form a per-level load needs: a broken `parentId` that points back
|
|
229
|
+
// up would otherwise open the same folder inside itself for as long as anyone keeps clicking.
|
|
230
|
+
// The recursion is gone, so the depth limit that used to save us is gone with it; what stops it
|
|
231
|
+
// now is that a row already on the path from the root is not rendered again.
|
|
232
|
+
const entries = level.data?.filter((entry) => !ancestors.has(entry.id)) ?? [];
|
|
233
|
+
if (entries.length === 0) {
|
|
234
|
+
return (
|
|
235
|
+
<p className="px-2 py-1.5 text-sm text-muted-foreground">
|
|
236
|
+
{parentId === null ? i18n.t("tree.empty") : i18n.t("tree.emptyFolder")}
|
|
237
|
+
</p>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return (
|
|
241
|
+
<SidebarMenu aria-label={label}>
|
|
242
|
+
{entries.map((entry) => renderRow(entry, ancestors))}
|
|
243
|
+
</SidebarMenu>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderRow(entry: TreeEntry, ancestors: ReadonlySet<string>) {
|
|
248
|
+
const isFolder = entry.kind === "folder";
|
|
249
|
+
const isOpen = isFolder && expanded.has(entry.id);
|
|
250
|
+
const Icon = isOpen ? FolderOpen : icons[entry.kind];
|
|
251
|
+
const area = entry.type === "flow" ? "/flows" : "/knowledge";
|
|
252
|
+
const isActive = location.select === entry.id && location.pathname === area;
|
|
253
|
+
// A row's plus files into that row's place: inside a folder, beside anything else.
|
|
254
|
+
const target = isFolder
|
|
255
|
+
? entry.id
|
|
256
|
+
: entry.type === "knowledge"
|
|
257
|
+
? entry.node.parentId
|
|
258
|
+
: entry.flow.parentId;
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<SidebarMenuItem key={entry.id} className="group/row">
|
|
262
|
+
<div className="flex items-center gap-0.5">
|
|
263
|
+
{isFolder ? (
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
aria-expanded={isOpen}
|
|
267
|
+
aria-label={i18n.t(isOpen ? "tree.collapse" : "tree.expand", { title: entry.title })}
|
|
268
|
+
onClick={() => toggle(entry.id, !isOpen)}
|
|
269
|
+
className="shrink-0 rounded-md p-1 text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
|
270
|
+
>
|
|
271
|
+
<ChevronRight
|
|
272
|
+
aria-hidden="true"
|
|
273
|
+
className={`size-3.5 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
|
274
|
+
/>
|
|
275
|
+
</button>
|
|
276
|
+
) : (
|
|
277
|
+
<span aria-hidden="true" className="size-5 shrink-0" />
|
|
278
|
+
)}
|
|
279
|
+
<SidebarMenuButton
|
|
280
|
+
isActive={isActive}
|
|
281
|
+
className="min-w-0 flex-1"
|
|
282
|
+
onClick={() => void navigate({ to: area, search: { select: entry.id } })}
|
|
283
|
+
>
|
|
284
|
+
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
|
285
|
+
<span className="truncate">{entry.title}</span>
|
|
286
|
+
{/* The icon carries the type on screen; this carries it everywhere else, and it is why
|
|
287
|
+
a flow needs no suffix in its name to be told from a document. */}
|
|
288
|
+
<span className="sr-only">{i18n.t(`tree.kind.${entry.kind}`)}</span>
|
|
289
|
+
</SidebarMenuButton>
|
|
290
|
+
<AddMenu
|
|
291
|
+
label={i18n.t("tree.add", { title: entry.title })}
|
|
292
|
+
onSelect={(kind) => {
|
|
293
|
+
if (kind === "upload") startUpload(target);
|
|
294
|
+
else setCreating({ parentId: target, kind });
|
|
295
|
+
}}
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
{/* The same rows one indent deeper: one row component for every depth, so the plus on the
|
|
299
|
+
fourth level is the same plus as on the first. */}
|
|
300
|
+
{isOpen ? (
|
|
301
|
+
<div className="ml-3.5 border-l border-sidebar-border pl-1.5">
|
|
302
|
+
{renderLevel(entry.id, new Set([...ancestors, entry.id]))}
|
|
303
|
+
</div>
|
|
304
|
+
) : null}
|
|
305
|
+
</SidebarMenuItem>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<SidebarGroup className="min-h-0 flex-1 overflow-y-auto">
|
|
311
|
+
{/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
|
|
312
|
+
named for assistive technology instead. */}
|
|
313
|
+
{renderLevel(null, new Set(), i18n.t("tree.label"))}
|
|
314
|
+
{create.isError || upload.isError ? (
|
|
315
|
+
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
316
|
+
{upload.isError ? i18n.t("tree.uploadFailed") : i18n.t("tree.createFailed")}
|
|
317
|
+
</p>
|
|
318
|
+
) : null}
|
|
319
|
+
{/* The root has no row of its own, so its plus lives here — otherwise a tree could only ever
|
|
320
|
+
grow inside the folders it already has. */}
|
|
321
|
+
{root?.isPending ? null : (
|
|
322
|
+
<div className="mt-1 flex items-center px-2">
|
|
323
|
+
<AddMenu
|
|
324
|
+
label={i18n.t("tree.addRoot")}
|
|
325
|
+
alwaysVisible
|
|
326
|
+
onSelect={(kind) => {
|
|
327
|
+
if (kind === "upload") startUpload(null);
|
|
328
|
+
else setCreating({ parentId: null, kind });
|
|
329
|
+
}}
|
|
330
|
+
/>
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
<input
|
|
334
|
+
ref={uploadInput}
|
|
335
|
+
type="file"
|
|
336
|
+
className="sr-only"
|
|
337
|
+
aria-label={i18n.t("tree.upload")}
|
|
338
|
+
onChange={(event) => {
|
|
339
|
+
const file = event.target.files?.[0];
|
|
340
|
+
if (file) upload.mutate({ parentId: uploadTo, file });
|
|
341
|
+
event.currentTarget.value = "";
|
|
342
|
+
}}
|
|
343
|
+
/>
|
|
344
|
+
{creating ? (
|
|
345
|
+
<Modal title={i18n.t(`tree.new.${creating.kind}`)} close={() => setCreating(null)}>
|
|
346
|
+
<CreateForm
|
|
347
|
+
pending={create.isPending}
|
|
348
|
+
submit={(title) => create.mutate({ ...creating, title })}
|
|
349
|
+
/>
|
|
350
|
+
</Modal>
|
|
351
|
+
) : null}
|
|
352
|
+
</SidebarGroup>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ⚠️ Hover alone would hide this from a keyboard and from touch entirely, which is the same as not
|
|
357
|
+
// having it. It stays in the tab order, focus makes it visible, and below `md` it is always shown.
|
|
358
|
+
function AddMenu({
|
|
359
|
+
label,
|
|
360
|
+
alwaysVisible = false,
|
|
361
|
+
onSelect,
|
|
362
|
+
}: {
|
|
363
|
+
label: string;
|
|
364
|
+
alwaysVisible?: boolean;
|
|
365
|
+
onSelect(kind: NewKind | "upload"): void;
|
|
366
|
+
}) {
|
|
367
|
+
const { i18n } = useIntelRouterContext();
|
|
368
|
+
const items: Array<{ kind: NewKind | "upload"; labelKey: string; Icon: typeof Plus }> = [
|
|
369
|
+
{ kind: "folder", labelKey: "tree.new.folder", Icon: Folder },
|
|
370
|
+
{ kind: "document", labelKey: "tree.new.document", Icon: FileText },
|
|
371
|
+
{ kind: "upload", labelKey: "tree.new.upload", Icon: Upload },
|
|
372
|
+
{ kind: "flow", labelKey: "tree.new.flow", Icon: Workflow },
|
|
373
|
+
];
|
|
374
|
+
return (
|
|
375
|
+
<DropdownMenu>
|
|
376
|
+
<DropdownMenuTrigger
|
|
377
|
+
aria-label={label}
|
|
378
|
+
className={`shrink-0 rounded-md p-1 text-sidebar-foreground outline-none transition-opacity hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:opacity-100 ${
|
|
379
|
+
alwaysVisible
|
|
380
|
+
? ""
|
|
381
|
+
: "group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
|
|
382
|
+
}`}
|
|
383
|
+
>
|
|
384
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
385
|
+
</DropdownMenuTrigger>
|
|
386
|
+
<DropdownMenuContent align="start" className="w-44">
|
|
387
|
+
{items.map((item) => (
|
|
388
|
+
<DropdownMenuItem key={item.kind} onSelect={() => onSelect(item.kind)}>
|
|
389
|
+
<item.Icon aria-hidden="true" />
|
|
390
|
+
{i18n.t(item.labelKey)}
|
|
391
|
+
</DropdownMenuItem>
|
|
392
|
+
))}
|
|
393
|
+
</DropdownMenuContent>
|
|
394
|
+
</DropdownMenu>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function CreateForm({ pending, submit }: { pending: boolean; submit(title: string): void }) {
|
|
399
|
+
const { i18n } = useIntelRouterContext();
|
|
400
|
+
const [title, setTitle] = useState("");
|
|
401
|
+
return (
|
|
402
|
+
<form
|
|
403
|
+
className="space-y-4"
|
|
404
|
+
onSubmit={(event) => {
|
|
405
|
+
event.preventDefault();
|
|
406
|
+
if (!pending) submit(title.trim());
|
|
407
|
+
}}
|
|
408
|
+
>
|
|
409
|
+
<label className="block text-sm font-medium">
|
|
410
|
+
{i18n.t("common.title")}
|
|
411
|
+
<input
|
|
412
|
+
required
|
|
413
|
+
value={title}
|
|
414
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
415
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
416
|
+
/>
|
|
417
|
+
</label>
|
|
418
|
+
<button
|
|
419
|
+
type="submit"
|
|
420
|
+
disabled={pending || title.trim().length === 0}
|
|
421
|
+
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
422
|
+
>
|
|
423
|
+
{pending ? i18n.t("common.saving") : i18n.t("common.create")}
|
|
424
|
+
</button>
|
|
425
|
+
</form>
|
|
426
|
+
);
|
|
427
|
+
}
|
package/src/app/app.tsx
CHANGED
|
@@ -1,62 +1,92 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
|
+
import { AppSidebar } from "@/app/app-sidebar/app-sidebar.tsx";
|
|
4
|
+
import { HeaderSearch } from "@/app/header-search/header-search.tsx";
|
|
5
|
+
import { createSidebarPreferences } from "@/app/sidebar-preferences/sidebar-preferences.ts";
|
|
6
|
+
import {
|
|
7
|
+
Breadcrumb,
|
|
8
|
+
BreadcrumbItem,
|
|
9
|
+
BreadcrumbLink,
|
|
10
|
+
BreadcrumbList,
|
|
11
|
+
BreadcrumbPage,
|
|
12
|
+
BreadcrumbSeparator,
|
|
13
|
+
} from "@/components/ui/breadcrumb";
|
|
14
|
+
import { Separator } from "@/components/ui/separator";
|
|
15
|
+
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
|
6
16
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
7
17
|
|
|
18
|
+
const sections = [
|
|
19
|
+
{ path: "/knowledge", labelKey: "nav.knowledge" },
|
|
20
|
+
{ path: "/flows", labelKey: "nav.flows" },
|
|
21
|
+
{ path: "/tools", labelKey: "nav.tools" },
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
8
24
|
export function App() {
|
|
9
|
-
const {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
const { i18n } = useIntelRouterContext();
|
|
26
|
+
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
27
|
+
const current = sections.find(
|
|
28
|
+
(section) => pathname === section.path || pathname.startsWith(`${section.path}/`),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Read once: the stored preference is the starting point, after which the shell's own state is
|
|
32
|
+
// the truth. Re-reading on every render would fight the drag.
|
|
33
|
+
const preferences = useRef(createSidebarPreferences()).current;
|
|
34
|
+
const stored = useRef(preferences.read()).current;
|
|
35
|
+
const [open, setOpen] = useState(stored.open);
|
|
36
|
+
const [width, setWidth] = useState(stored.width);
|
|
19
37
|
|
|
20
38
|
return (
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
39
|
+
<SidebarProvider
|
|
40
|
+
open={open}
|
|
41
|
+
onOpenChange={(next) => {
|
|
42
|
+
setOpen(next);
|
|
43
|
+
preferences.write({ open: next, width });
|
|
44
|
+
}}
|
|
45
|
+
style={{ "--sidebar-width": `${width}px` } as React.CSSProperties}
|
|
46
|
+
>
|
|
47
|
+
<AppSidebar
|
|
48
|
+
width={width}
|
|
49
|
+
onWidthChange={(next) => {
|
|
50
|
+
setWidth(next);
|
|
51
|
+
preferences.write({ open, width: next });
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
{/* ⚠️ `min-w-0` is not cosmetic: the inset is a flex item beside the sidebar and carries
|
|
55
|
+
`min-width: auto`, so it never shrinks below its content's min-content width. One wide
|
|
56
|
+
child (a flow canvas, a table of raw JSON) would widen the page instead of scrolling
|
|
57
|
+
inside its own container. Learned in gate, GATE-76 D. */}
|
|
58
|
+
<SidebarInset className="min-w-0 md:border">
|
|
59
|
+
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
|
|
60
|
+
<SidebarTrigger className="-ml-1" aria-label={i18n.t("shell.toggleSidebar")} />
|
|
61
|
+
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
|
62
|
+
<Breadcrumb>
|
|
63
|
+
<BreadcrumbList>
|
|
64
|
+
<BreadcrumbItem>
|
|
65
|
+
<BreadcrumbLink asChild>
|
|
66
|
+
<Link to="/knowledge">{i18n.t("app.name")}</Link>
|
|
67
|
+
</BreadcrumbLink>
|
|
68
|
+
</BreadcrumbItem>
|
|
69
|
+
{current ? (
|
|
70
|
+
<>
|
|
71
|
+
<BreadcrumbSeparator />
|
|
72
|
+
<BreadcrumbItem>
|
|
73
|
+
<BreadcrumbPage>{i18n.t(current.labelKey)}</BreadcrumbPage>
|
|
74
|
+
</BreadcrumbItem>
|
|
75
|
+
</>
|
|
76
|
+
) : null}
|
|
77
|
+
</BreadcrumbList>
|
|
78
|
+
</Breadcrumb>
|
|
79
|
+
{/* The bar for area-owned actions. A screen renders into it through `HeaderActions`;
|
|
80
|
+
search is the shell's own, because it has to open from every screen alike and belongs
|
|
81
|
+
to none of them. It stays first, so a screen's actions line up to its right. */}
|
|
82
|
+
<div data-slot="header-actions" className="ml-auto flex items-center gap-2">
|
|
83
|
+
<HeaderSearch />
|
|
84
|
+
</div>
|
|
85
|
+
</header>
|
|
86
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-auto">
|
|
87
|
+
<Outlet />
|
|
55
88
|
</div>
|
|
56
|
-
</
|
|
57
|
-
|
|
58
|
-
<Outlet />
|
|
59
|
-
</main>
|
|
60
|
-
</div>
|
|
89
|
+
</SidebarInset>
|
|
90
|
+
</SidebarProvider>
|
|
61
91
|
);
|
|
62
92
|
}
|