@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
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
|
|
5
|
+
// The shell owns the header bar, the screen owns what belongs in it: a screen renders its actions
|
|
6
|
+
// here and they appear beside the breadcrumb instead of in a second heading of its own.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ The slot lives in the shell above the outlet, so it is not in the document while the screen
|
|
9
|
+
// first renders. Looking it up in an effect costs one extra render and is the only order that
|
|
10
|
+
// works; reading it during render finds nothing on the first paint.
|
|
11
|
+
export function HeaderActions({ children }: { children: React.ReactNode }) {
|
|
12
|
+
const [slot, setSlot] = useState<Element | null>(null);
|
|
13
|
+
useEffect(() => setSlot(document.querySelector('[data-slot="header-actions"]')), []);
|
|
14
|
+
return slot ? createPortal(children, slot) : null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import type { Flow, ToolCapability } from "@anchrd/intel-contract";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
4
|
+
import { FileText, Search, Workflow, Wrench } from "lucide-react";
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
Command,
|
|
8
|
+
CommandEmpty,
|
|
9
|
+
CommandGroup,
|
|
10
|
+
CommandInput,
|
|
11
|
+
CommandItem,
|
|
12
|
+
CommandList,
|
|
13
|
+
} from "@/components/ui/command";
|
|
14
|
+
import {
|
|
15
|
+
Dialog,
|
|
16
|
+
DialogContent,
|
|
17
|
+
DialogDescription,
|
|
18
|
+
DialogHeader,
|
|
19
|
+
DialogTitle,
|
|
20
|
+
} from "@/components/ui/dialog";
|
|
21
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
22
|
+
|
|
23
|
+
const Kinds = ["knowledge", "flow", "tool"] as const;
|
|
24
|
+
type Kind = (typeof Kinds)[number];
|
|
25
|
+
|
|
26
|
+
// Enough to answer "where was that again" without turning the dialog into a result page.
|
|
27
|
+
const ResultLimit = 8;
|
|
28
|
+
|
|
29
|
+
const areaFor = { knowledge: "/knowledge", flow: "/flows", tool: "/tools" } as const;
|
|
30
|
+
|
|
31
|
+
function isMac(): boolean {
|
|
32
|
+
return typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ⚠️ The deliberate boundary of this ticket, named rather than hidden: Intel has a search endpoint
|
|
36
|
+
// for Knowledge only, so Knowledge hits come from `searchKnowledge()` and the server decides both
|
|
37
|
+
// what matches and what may be seen. Flows and Tools have no such endpoint — they are the same
|
|
38
|
+
// authorized lists the screens render, narrowed here in the client.
|
|
39
|
+
//
|
|
40
|
+
// That is safe in the one direction that matters: `listFlows()` returns what the caller may see
|
|
41
|
+
// (the flow repository's `listVisible`), and `listTools()` is one live `tools/list` with the user's
|
|
42
|
+
// own portal token. The client narrows a permitted set; it can never widen one, and no permission
|
|
43
|
+
// decision is taken here.
|
|
44
|
+
//
|
|
45
|
+
// When either list outgrows what a browser should hold, that is a new ticket for a search endpoint
|
|
46
|
+
// — not a silent regression of this one.
|
|
47
|
+
function matches(query: string, ...fields: (string | null)[]): boolean {
|
|
48
|
+
const needle = query.toLocaleLowerCase();
|
|
49
|
+
return fields.some((field) => field?.toLocaleLowerCase().includes(needle));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function HeaderSearch() {
|
|
53
|
+
const { data, i18n } = useIntelRouterContext();
|
|
54
|
+
const navigate = useNavigate();
|
|
55
|
+
const [open, setOpen] = useState(false);
|
|
56
|
+
const [text, setText] = useState("");
|
|
57
|
+
const [kind, setKind] = useState<Kind | null>(null);
|
|
58
|
+
const query = text.trim();
|
|
59
|
+
const restoreTo = useRef<HTMLElement | null>(null);
|
|
60
|
+
|
|
61
|
+
function change(next: boolean) {
|
|
62
|
+
if (next) restoreTo.current = document.activeElement as HTMLElement | null;
|
|
63
|
+
setOpen(next);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The same combination opens and closes the dialog. Radix does not swallow the key while it is
|
|
67
|
+
// open, so one toggling listener on the window covers both directions from every screen.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
70
|
+
if (event.key.toLowerCase() !== "k" || !(event.metaKey || event.ctrlKey) || event.altKey) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
if (!open) restoreTo.current = document.activeElement as HTMLElement | null;
|
|
75
|
+
setOpen(!open);
|
|
76
|
+
}
|
|
77
|
+
window.addEventListener("keydown", onKeyDown);
|
|
78
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
79
|
+
}, [open]);
|
|
80
|
+
|
|
81
|
+
const shows = (candidate: Kind) => kind === null || kind === candidate;
|
|
82
|
+
// A filter that excludes a kind does not ask for it either: no request, and therefore no group
|
|
83
|
+
// that could show up empty.
|
|
84
|
+
const knowledge = useQuery({
|
|
85
|
+
queryKey: ["knowledge-search", query],
|
|
86
|
+
queryFn: () => data.searchKnowledge({ query, limit: ResultLimit }),
|
|
87
|
+
enabled: open && shows("knowledge") && query.length > 0,
|
|
88
|
+
});
|
|
89
|
+
// ⚠️ Deliberately the whole list, not one folder of the tree: a search that only saw the level
|
|
90
|
+
// someone happened to have open would miss the flow they are looking for. The sidebar tree asks
|
|
91
|
+
// per folder and keeps its own keys; only the Tools key is shared with the screen that fills it.
|
|
92
|
+
const flows = useQuery({
|
|
93
|
+
queryKey: ["flows"],
|
|
94
|
+
queryFn: () => data.listFlows(),
|
|
95
|
+
enabled: open && shows("flow") && query.length > 0,
|
|
96
|
+
});
|
|
97
|
+
const tools = useQuery({
|
|
98
|
+
queryKey: ["tools"],
|
|
99
|
+
queryFn: () => data.listTools(),
|
|
100
|
+
enabled: open && shows("tool") && query.length > 0,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const active = [
|
|
104
|
+
...(shows("knowledge") ? [knowledge] : []),
|
|
105
|
+
...(shows("flow") ? [flows] : []),
|
|
106
|
+
...(shows("tool") ? [tools] : []),
|
|
107
|
+
];
|
|
108
|
+
const searching = query.length > 0 && active.some((source) => source.isPending);
|
|
109
|
+
const failed = query.length > 0 && active.some((source) => source.isError);
|
|
110
|
+
|
|
111
|
+
const knowledgeHits = shows("knowledge") ? (knowledge.data?.items ?? []) : [];
|
|
112
|
+
const flowHits: Flow[] = shows("flow")
|
|
113
|
+
? (flows.data?.items ?? [])
|
|
114
|
+
.filter((flow) => matches(query, flow.title, flow.description))
|
|
115
|
+
.slice(0, ResultLimit)
|
|
116
|
+
: [];
|
|
117
|
+
const toolHits: ToolCapability[] = shows("tool")
|
|
118
|
+
? (tools.data?.items ?? [])
|
|
119
|
+
.filter((tool) => matches(query, tool.name, tool.title, tool.description))
|
|
120
|
+
.slice(0, ResultLimit)
|
|
121
|
+
: [];
|
|
122
|
+
const total = knowledgeHits.length + flowHits.length + toolHits.length;
|
|
123
|
+
|
|
124
|
+
function reach(hit: Kind, select: string) {
|
|
125
|
+
setOpen(false);
|
|
126
|
+
void navigate({ to: areaFor[hit], search: { select } });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={() => change(true)}
|
|
134
|
+
aria-label={i18n.t("search.open")}
|
|
135
|
+
aria-keyshortcuts="Meta+K Control+K"
|
|
136
|
+
className="flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 text-sm text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring sm:w-56"
|
|
137
|
+
>
|
|
138
|
+
<Search aria-hidden="true" className="size-4 shrink-0" />
|
|
139
|
+
<span className="truncate">{i18n.t("search.open")}</span>
|
|
140
|
+
{/* The hint repeats what `aria-keyshortcuts` already announces, so the accessible name
|
|
141
|
+
stays the label above rather than "Search ⌘K". */}
|
|
142
|
+
<kbd className="ml-auto hidden rounded border bg-muted px-1.5 font-sans text-[10px] sm:inline">
|
|
143
|
+
{isMac() ? "⌘K" : "Ctrl K"}
|
|
144
|
+
</kbd>
|
|
145
|
+
</button>
|
|
146
|
+
|
|
147
|
+
{/* ⚠️ Composed from `Dialog` and `Command` rather than from the registry's `CommandDialog`
|
|
148
|
+
for two reasons that both matter here: `CommandDialog` forwards its extra props to the
|
|
149
|
+
dialog root, so `shouldFilter` would never reach cmdk, and it renders its title outside
|
|
150
|
+
`DialogContent`, where it does not name the dialog for a screen reader. */}
|
|
151
|
+
<Dialog open={open} onOpenChange={change}>
|
|
152
|
+
<DialogContent
|
|
153
|
+
className="overflow-hidden p-0"
|
|
154
|
+
// ⚠️ Radix hands focus back to the dialog's trigger. This dialog is usually opened by a
|
|
155
|
+
// shortcut and has no trigger at all, so without this the caret would land on the body
|
|
156
|
+
// and a keyboard user would start over at the top of the page.
|
|
157
|
+
onCloseAutoFocus={(event) => {
|
|
158
|
+
event.preventDefault();
|
|
159
|
+
restoreTo.current?.focus();
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
<DialogHeader className="sr-only">
|
|
163
|
+
<DialogTitle>{i18n.t("search.title")}</DialogTitle>
|
|
164
|
+
<DialogDescription>{i18n.t("search.description")}</DialogDescription>
|
|
165
|
+
</DialogHeader>
|
|
166
|
+
{/* Every source has already decided what matches — the server for Knowledge, the client
|
|
167
|
+
filter above for the two lists. Letting cmdk filter again would hide rows the server
|
|
168
|
+
returned, because a row's value is its identity rather than its text. */}
|
|
169
|
+
<Command shouldFilter={false}>
|
|
170
|
+
<CommandInput
|
|
171
|
+
value={text}
|
|
172
|
+
onValueChange={setText}
|
|
173
|
+
placeholder={i18n.t("search.placeholder")}
|
|
174
|
+
/>
|
|
175
|
+
<fieldset className="flex flex-wrap gap-1 border-b px-3 py-2">
|
|
176
|
+
<legend className="sr-only">{i18n.t("search.filter")}</legend>
|
|
177
|
+
{[null, ...Kinds].map((candidate) => (
|
|
178
|
+
<button
|
|
179
|
+
key={candidate ?? "all"}
|
|
180
|
+
type="button"
|
|
181
|
+
aria-pressed={kind === candidate}
|
|
182
|
+
onClick={() => setKind(candidate)}
|
|
183
|
+
// ⚠️ cmdk turns Enter into "open the highlighted result" on the command root,
|
|
184
|
+
// without looking at where the key came from. Unstopped, Enter on a filter would
|
|
185
|
+
// leave the dialog for a hit instead of narrowing the list.
|
|
186
|
+
onKeyDown={(event) => {
|
|
187
|
+
if (event.key === "Enter" || event.key === " ") event.stopPropagation();
|
|
188
|
+
}}
|
|
189
|
+
className="rounded-full border px-2.5 py-1 text-xs outline-none aria-pressed:bg-primary aria-pressed:text-primary-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
190
|
+
>
|
|
191
|
+
{i18n.t(candidate === null ? "search.all" : `search.kind.${candidate}`)}
|
|
192
|
+
</button>
|
|
193
|
+
))}
|
|
194
|
+
</fieldset>
|
|
195
|
+
<CommandList>
|
|
196
|
+
{query.length === 0 ? (
|
|
197
|
+
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
198
|
+
{i18n.t("search.hint")}
|
|
199
|
+
</p>
|
|
200
|
+
) : null}
|
|
201
|
+
{/* Three states that never read as one another: still asking, could not ask, and asked
|
|
202
|
+
with nothing to show. A failing source is an alert even when another one answered. */}
|
|
203
|
+
{failed ? (
|
|
204
|
+
<div role="alert" className="space-y-3 px-3 py-6 text-center text-sm">
|
|
205
|
+
<p className="text-destructive">{i18n.t("search.failed")}</p>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={() => {
|
|
209
|
+
for (const source of active) if (source.isError) void source.refetch();
|
|
210
|
+
}}
|
|
211
|
+
className="rounded-md border px-2.5 py-1 text-xs outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
212
|
+
>
|
|
213
|
+
{i18n.t("common.retry")}
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
) : null}
|
|
217
|
+
{searching ? (
|
|
218
|
+
<p role="status" className="py-6 text-center text-sm text-muted-foreground">
|
|
219
|
+
{i18n.t("search.searching")}
|
|
220
|
+
</p>
|
|
221
|
+
) : null}
|
|
222
|
+
{!searching && !failed && query.length > 0 && total === 0 ? (
|
|
223
|
+
<CommandEmpty>{i18n.t("search.empty", { query })}</CommandEmpty>
|
|
224
|
+
) : null}
|
|
225
|
+
|
|
226
|
+
{knowledgeHits.length > 0 ? (
|
|
227
|
+
<CommandGroup heading={i18n.t("search.kind.knowledge")}>
|
|
228
|
+
{knowledgeHits.map((citation) => (
|
|
229
|
+
<CommandItem
|
|
230
|
+
key={`${citation.nodeId}:${citation.versionId}`}
|
|
231
|
+
value={`knowledge:${citation.nodeId}:${citation.versionId}`}
|
|
232
|
+
onSelect={() => reach("knowledge", citation.nodeId)}
|
|
233
|
+
>
|
|
234
|
+
<FileText aria-hidden="true" />
|
|
235
|
+
<span className="min-w-0 flex-1">
|
|
236
|
+
<span className="block truncate">{citation.title}</span>
|
|
237
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
238
|
+
{citation.passage}
|
|
239
|
+
</span>
|
|
240
|
+
</span>
|
|
241
|
+
</CommandItem>
|
|
242
|
+
))}
|
|
243
|
+
</CommandGroup>
|
|
244
|
+
) : null}
|
|
245
|
+
{flowHits.length > 0 ? (
|
|
246
|
+
<CommandGroup heading={i18n.t("search.kind.flow")}>
|
|
247
|
+
{flowHits.map((flow) => (
|
|
248
|
+
<CommandItem
|
|
249
|
+
key={flow.id}
|
|
250
|
+
value={`flow:${flow.id}`}
|
|
251
|
+
onSelect={() => reach("flow", flow.id)}
|
|
252
|
+
>
|
|
253
|
+
<Workflow aria-hidden="true" />
|
|
254
|
+
<span className="min-w-0 flex-1">
|
|
255
|
+
<span className="block truncate">{flow.title}</span>
|
|
256
|
+
{flow.description ? (
|
|
257
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
258
|
+
{flow.description}
|
|
259
|
+
</span>
|
|
260
|
+
) : null}
|
|
261
|
+
</span>
|
|
262
|
+
</CommandItem>
|
|
263
|
+
))}
|
|
264
|
+
</CommandGroup>
|
|
265
|
+
) : null}
|
|
266
|
+
{toolHits.length > 0 ? (
|
|
267
|
+
<CommandGroup heading={i18n.t("search.kind.tool")}>
|
|
268
|
+
{toolHits.map((tool) => (
|
|
269
|
+
<CommandItem
|
|
270
|
+
key={tool.name}
|
|
271
|
+
value={`tool:${tool.name}`}
|
|
272
|
+
onSelect={() => reach("tool", tool.name)}
|
|
273
|
+
>
|
|
274
|
+
<Wrench aria-hidden="true" />
|
|
275
|
+
<span className="min-w-0 flex-1">
|
|
276
|
+
<span className="block truncate">{tool.title ?? tool.name}</span>
|
|
277
|
+
<span className="block truncate font-mono text-xs text-muted-foreground">
|
|
278
|
+
{tool.name}
|
|
279
|
+
</span>
|
|
280
|
+
</span>
|
|
281
|
+
</CommandItem>
|
|
282
|
+
))}
|
|
283
|
+
</CommandGroup>
|
|
284
|
+
) : null}
|
|
285
|
+
</CommandList>
|
|
286
|
+
</Command>
|
|
287
|
+
</DialogContent>
|
|
288
|
+
</Dialog>
|
|
289
|
+
</>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SidebarPreferenceDeps,
|
|
3
|
+
SidebarPreferenceStore,
|
|
4
|
+
SidebarPreferences,
|
|
5
|
+
} from "./sidebar-preferences.types.ts";
|
|
6
|
+
|
|
7
|
+
// Below the minimum the tree is unreadable and the sidebar cannot be dragged back open by eye;
|
|
8
|
+
// above the maximum the content box stops being the larger half. Both are the reason the drag
|
|
9
|
+
// exists at all, so neither is configurable.
|
|
10
|
+
export const MinSidebarWidth = 180;
|
|
11
|
+
export const MaxSidebarWidth = 520;
|
|
12
|
+
export const DefaultSidebarWidth = 256;
|
|
13
|
+
|
|
14
|
+
const storageKey = "intel.sidebar";
|
|
15
|
+
const defaults: SidebarPreferences = { open: true, width: DefaultSidebarWidth };
|
|
16
|
+
|
|
17
|
+
export function clampSidebarWidth(width: number): number {
|
|
18
|
+
if (!Number.isFinite(width)) return DefaultSidebarWidth;
|
|
19
|
+
return Math.min(MaxSidebarWidth, Math.max(MinSidebarWidth, Math.round(width)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function browserStorage(): Pick<Storage, "getItem" | "setItem"> | null {
|
|
23
|
+
// Reading `localStorage` throws outright in a few privacy modes, so the guard is a try, not a
|
|
24
|
+
// typeof check. Losing the preference is acceptable; losing the shell is not.
|
|
25
|
+
try {
|
|
26
|
+
return typeof window === "undefined" ? null : window.localStorage;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The shell's own client state: not server data, so it stays out of TanStack Query, but it has to
|
|
33
|
+
// survive a page change — which in a SPA includes a full reload — so it is written through.
|
|
34
|
+
export function createSidebarPreferences(deps: SidebarPreferenceDeps = {}): SidebarPreferenceStore {
|
|
35
|
+
const storage = deps.storage === undefined ? browserStorage() : deps.storage;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
read() {
|
|
39
|
+
let raw: string | null = null;
|
|
40
|
+
try {
|
|
41
|
+
raw = storage?.getItem(storageKey) ?? null;
|
|
42
|
+
} catch {
|
|
43
|
+
return defaults;
|
|
44
|
+
}
|
|
45
|
+
if (raw === null) return defaults;
|
|
46
|
+
let parsed: unknown;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(raw);
|
|
49
|
+
} catch {
|
|
50
|
+
return defaults;
|
|
51
|
+
}
|
|
52
|
+
if (typeof parsed !== "object" || parsed === null) return defaults;
|
|
53
|
+
const value = parsed as Partial<SidebarPreferences>;
|
|
54
|
+
return {
|
|
55
|
+
open: typeof value.open === "boolean" ? value.open : defaults.open,
|
|
56
|
+
width: clampSidebarWidth(typeof value.width === "number" ? value.width : defaults.width),
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
write(preferences) {
|
|
60
|
+
try {
|
|
61
|
+
storage?.setItem(
|
|
62
|
+
storageKey,
|
|
63
|
+
JSON.stringify({ open: preferences.open, width: clampSidebarWidth(preferences.width) }),
|
|
64
|
+
);
|
|
65
|
+
} catch {}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SidebarPreferences {
|
|
2
|
+
open: boolean;
|
|
3
|
+
width: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SidebarPreferenceStore {
|
|
7
|
+
read(): SidebarPreferences;
|
|
8
|
+
write(preferences: SidebarPreferences): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SidebarPreferenceDeps {
|
|
12
|
+
// A `null` storage disables persistence. `undefined` means "use the browser's", which is not the
|
|
13
|
+
// same thing: a test passes null, a browser without localStorage produces null on its own.
|
|
14
|
+
storage?: Pick<Storage, "getItem" | "setItem"> | null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
clampSidebarWidth,
|
|
4
|
+
MaxSidebarWidth,
|
|
5
|
+
MinSidebarWidth,
|
|
6
|
+
} from "@/app/sidebar-preferences/sidebar-preferences.ts";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
|
|
9
|
+
const KeyboardStep = 16;
|
|
10
|
+
|
|
11
|
+
// The registry has no resizable sidebar, so this is the one piece of the shell that is not a
|
|
12
|
+
// primitive. It is an ARIA window splitter rather than a bare div: a drag-only handle exists for
|
|
13
|
+
// nobody on a keyboard, and the tree behind it is the entire navigation.
|
|
14
|
+
export function SidebarResizeHandle({
|
|
15
|
+
width,
|
|
16
|
+
label,
|
|
17
|
+
onWidthChange,
|
|
18
|
+
}: {
|
|
19
|
+
width: number;
|
|
20
|
+
label: string;
|
|
21
|
+
onWidthChange(width: number): void;
|
|
22
|
+
}) {
|
|
23
|
+
const drag = useRef<{ pointerId: number; startX: number; startWidth: number } | null>(null);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
// The WAI-ARIA window-splitter pattern is a focusable separator carrying a value. The rule's
|
|
27
|
+
// suggested `<hr>` cannot take focus, which would remove the keyboard operation this handle
|
|
28
|
+
// exists to provide.
|
|
29
|
+
// biome-ignore lint/a11y/useSemanticElements: a focusable window splitter cannot be an <hr>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
role="separator"
|
|
33
|
+
aria-orientation="vertical"
|
|
34
|
+
aria-label={label}
|
|
35
|
+
aria-valuenow={width}
|
|
36
|
+
aria-valuemin={MinSidebarWidth}
|
|
37
|
+
aria-valuemax={MaxSidebarWidth}
|
|
38
|
+
className={cn(
|
|
39
|
+
"absolute inset-y-0 -right-1 z-20 hidden w-2 cursor-col-resize outline-none md:block",
|
|
40
|
+
"after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2",
|
|
41
|
+
"hover:after:bg-sidebar-ring focus-visible:after:bg-sidebar-ring",
|
|
42
|
+
"focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
|
43
|
+
// Collapsed the sidebar is a fixed icon rail; there is no width left to drag.
|
|
44
|
+
"group-data-[state=collapsed]:hidden",
|
|
45
|
+
)}
|
|
46
|
+
onPointerDown={(event) => {
|
|
47
|
+
drag.current = {
|
|
48
|
+
pointerId: event.pointerId,
|
|
49
|
+
startX: event.clientX,
|
|
50
|
+
startWidth: width,
|
|
51
|
+
};
|
|
52
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
53
|
+
}}
|
|
54
|
+
onPointerMove={(event) => {
|
|
55
|
+
const active = drag.current;
|
|
56
|
+
if (!active || active.pointerId !== event.pointerId) return;
|
|
57
|
+
// Measured against the width the drag started from, never against the live value: reading
|
|
58
|
+
// back the clamped width would make the handle drift away from the cursor at the limits.
|
|
59
|
+
onWidthChange(clampSidebarWidth(active.startWidth + (event.clientX - active.startX)));
|
|
60
|
+
}}
|
|
61
|
+
onPointerUp={(event) => {
|
|
62
|
+
drag.current = null;
|
|
63
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
64
|
+
}}
|
|
65
|
+
onPointerCancel={() => {
|
|
66
|
+
drag.current = null;
|
|
67
|
+
}}
|
|
68
|
+
onKeyDown={(event) => {
|
|
69
|
+
const next =
|
|
70
|
+
event.key === "ArrowLeft"
|
|
71
|
+
? width - KeyboardStep
|
|
72
|
+
: event.key === "ArrowRight"
|
|
73
|
+
? width + KeyboardStep
|
|
74
|
+
: event.key === "Home"
|
|
75
|
+
? MinSidebarWidth
|
|
76
|
+
: event.key === "End"
|
|
77
|
+
? MaxSidebarWidth
|
|
78
|
+
: null;
|
|
79
|
+
if (next === null) return;
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
onWidthChange(clampSidebarWidth(next));
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { Link, useRouterState } from "@tanstack/react-router";
|
|
3
|
+
import { LogOut, User, Wrench } from "lucide-react";
|
|
4
|
+
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
|
|
5
|
+
import { loginPath } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
6
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
7
|
+
|
|
8
|
+
// Everything that belongs to the person rather than to the content: the tool catalogue — a live
|
|
9
|
+
// portal query with the signed-in user's own token (ADR-0003), so what it shows depends on who is
|
|
10
|
+
// asking — then the user, then the sign-out.
|
|
11
|
+
export function UserFooter() {
|
|
12
|
+
const { data, i18n } = useIntelRouterContext();
|
|
13
|
+
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
14
|
+
const session = useQuery({ queryKey: ["session"], queryFn: () => data.getSession() });
|
|
15
|
+
const logout = useMutation({
|
|
16
|
+
mutationFn: () => data.logout(),
|
|
17
|
+
onSuccess: () => window.location.assign(loginPath()),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const user = session.data ?? null;
|
|
21
|
+
const primary = user ? (user.name ?? user.email) : null;
|
|
22
|
+
const secondary = user?.name ? user.email : null;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<SidebarMenu>
|
|
26
|
+
<SidebarMenuItem>
|
|
27
|
+
<SidebarMenuButton asChild isActive={pathname === "/tools"} tooltip={i18n.t("nav.tools")}>
|
|
28
|
+
<Link to="/tools">
|
|
29
|
+
<Wrench aria-hidden="true" />
|
|
30
|
+
<span>{i18n.t("nav.tools")}</span>
|
|
31
|
+
</Link>
|
|
32
|
+
</SidebarMenuButton>
|
|
33
|
+
</SidebarMenuItem>
|
|
34
|
+
<SidebarMenuItem>
|
|
35
|
+
<div
|
|
36
|
+
data-slot="signed-in-user"
|
|
37
|
+
className="flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm group-data-[collapsible=icon]:p-0"
|
|
38
|
+
>
|
|
39
|
+
<span className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-accent text-sidebar-accent-foreground">
|
|
40
|
+
<User aria-hidden="true" className="size-4" />
|
|
41
|
+
</span>
|
|
42
|
+
{/* Collapsed to icons the name is not on screen, so the label has to carry it for a
|
|
43
|
+
screen reader. It is read out in both states; sighted users see it in neither. */}
|
|
44
|
+
<span className="sr-only">{i18n.t("shell.signedInUser")}</span>
|
|
45
|
+
<span className="grid flex-1 leading-tight group-data-[collapsible=icon]:hidden">
|
|
46
|
+
<span className="truncate font-medium">
|
|
47
|
+
{session.isPending
|
|
48
|
+
? i18n.t("common.loading")
|
|
49
|
+
: (primary ?? i18n.t("shell.userUnavailable"))}
|
|
50
|
+
</span>
|
|
51
|
+
{secondary ? (
|
|
52
|
+
<span className="truncate text-xs text-muted-foreground">{secondary}</span>
|
|
53
|
+
) : null}
|
|
54
|
+
</span>
|
|
55
|
+
</div>
|
|
56
|
+
</SidebarMenuItem>
|
|
57
|
+
{logout.isError ? (
|
|
58
|
+
<SidebarMenuItem>
|
|
59
|
+
<p role="alert" className="px-2 text-sm text-destructive">
|
|
60
|
+
{i18n.t("auth.signOutFailed")}
|
|
61
|
+
</p>
|
|
62
|
+
</SidebarMenuItem>
|
|
63
|
+
) : null}
|
|
64
|
+
<SidebarMenuItem>
|
|
65
|
+
<SidebarMenuButton
|
|
66
|
+
onClick={() => logout.mutate()}
|
|
67
|
+
disabled={logout.isPending}
|
|
68
|
+
tooltip={i18n.t("auth.signOut")}
|
|
69
|
+
>
|
|
70
|
+
<LogOut aria-hidden="true" />
|
|
71
|
+
<span>{i18n.t("auth.signOut")}</span>
|
|
72
|
+
</SidebarMenuButton>
|
|
73
|
+
</SidebarMenuItem>
|
|
74
|
+
</SidebarMenu>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
|
2
|
+
import { Slot } from "radix-ui";
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
|
8
|
+
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|
12
|
+
return (
|
|
13
|
+
<ol
|
|
14
|
+
data-slot="breadcrumb-list"
|
|
15
|
+
className={cn(
|
|
16
|
+
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
|
25
|
+
return (
|
|
26
|
+
<li
|
|
27
|
+
data-slot="breadcrumb-item"
|
|
28
|
+
className={cn("inline-flex items-center gap-1.5", className)}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function BreadcrumbLink({
|
|
35
|
+
asChild,
|
|
36
|
+
className,
|
|
37
|
+
...props
|
|
38
|
+
}: React.ComponentProps<"a"> & {
|
|
39
|
+
asChild?: boolean;
|
|
40
|
+
}) {
|
|
41
|
+
const Comp = asChild ? Slot.Root : "a";
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Comp
|
|
45
|
+
data-slot="breadcrumb-link"
|
|
46
|
+
className={cn("transition-colors hover:text-foreground", className)}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
|
53
|
+
return (
|
|
54
|
+
<span
|
|
55
|
+
data-slot="breadcrumb-page"
|
|
56
|
+
role="link"
|
|
57
|
+
aria-disabled="true"
|
|
58
|
+
aria-current="page"
|
|
59
|
+
className={cn("font-normal text-foreground", className)}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
|
66
|
+
return (
|
|
67
|
+
<li
|
|
68
|
+
data-slot="breadcrumb-separator"
|
|
69
|
+
role="presentation"
|
|
70
|
+
aria-hidden="true"
|
|
71
|
+
className={cn("[&>svg]:size-3.5", className)}
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
74
|
+
{children ?? <ChevronRight />}
|
|
75
|
+
</li>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
|
80
|
+
return (
|
|
81
|
+
<span
|
|
82
|
+
data-slot="breadcrumb-ellipsis"
|
|
83
|
+
role="presentation"
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
className={cn("flex size-9 items-center justify-center", className)}
|
|
86
|
+
{...props}
|
|
87
|
+
>
|
|
88
|
+
<MoreHorizontal className="size-4" />
|
|
89
|
+
<span className="sr-only">More</span>
|
|
90
|
+
</span>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export {
|
|
95
|
+
Breadcrumb,
|
|
96
|
+
BreadcrumbEllipsis,
|
|
97
|
+
BreadcrumbItem,
|
|
98
|
+
BreadcrumbLink,
|
|
99
|
+
BreadcrumbList,
|
|
100
|
+
BreadcrumbPage,
|
|
101
|
+
BreadcrumbSeparator,
|
|
102
|
+
};
|