@anchrd/intel-ui 0.3.0 → 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.
Files changed (33) hide show
  1. package/components.json +7 -1
  2. package/package.json +3 -1
  3. package/src/app/app-sidebar/app-sidebar.tsx +56 -0
  4. package/src/app/app-tree/app-tree.tsx +427 -0
  5. package/src/app/app.tsx +84 -54
  6. package/src/app/header-actions/header-actions.tsx +15 -0
  7. package/src/app/header-search/header-search.tsx +291 -0
  8. package/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  9. package/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  10. package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +85 -0
  11. package/src/app/user-footer/user-footer.tsx +76 -0
  12. package/src/components/ui/breadcrumb.tsx +102 -0
  13. package/src/components/ui/button.tsx +64 -0
  14. package/src/components/ui/collapsible.tsx +20 -0
  15. package/src/components/ui/command.tsx +160 -0
  16. package/src/components/ui/dialog.tsx +143 -0
  17. package/src/components/ui/dropdown-menu.tsx +84 -0
  18. package/src/components/ui/input.tsx +21 -0
  19. package/src/components/ui/separator.tsx +26 -0
  20. package/src/components/ui/sheet.tsx +136 -0
  21. package/src/components/ui/sidebar.tsx +693 -0
  22. package/src/components/ui/skeleton.tsx +13 -0
  23. package/src/components/ui/tooltip.tsx +51 -0
  24. package/src/data/intel-data-provider/intel-data-provider.ts +58 -39
  25. package/src/data/intel-data-provider/intel-data-provider.types.ts +17 -8
  26. package/src/flows/flows.tsx +59 -142
  27. package/src/hooks/use-mobile.ts +19 -0
  28. package/src/i18n/en.json +48 -29
  29. package/src/knowledge/knowledge.tsx +133 -423
  30. package/src/router/router.tsx +4 -0
  31. package/src/router/selection-search.ts +19 -0
  32. package/src/styles.css +54 -51
  33. 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
+ };