@iloveagents/foundry-web-ui 0.1.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/AGENTS.md +59 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +61 -0
- package/src/__tests__/no-spaces-imports.test.ts +59 -0
- package/src/__tests__/open-core-guards.test.ts +307 -0
- package/src/__tests__/theme-runtime.test.ts +81 -0
- package/src/__tests__/tool-fallback.test.tsx +109 -0
- package/src/components/ag-ui-runtime-provider.tsx +59 -0
- package/src/components/app-brand.tsx +38 -0
- package/src/components/assistant-chat.tsx +423 -0
- package/src/components/chat-attachments.tsx +68 -0
- package/src/components/chat-bubble.tsx +339 -0
- package/src/components/chat-header.tsx +44 -0
- package/src/components/client-tool-executor.tsx +230 -0
- package/src/components/collection-empty-state.tsx +37 -0
- package/src/components/collection-filter-bar.tsx +168 -0
- package/src/components/collection-search-input.tsx +41 -0
- package/src/components/collection-skeleton.tsx +55 -0
- package/src/components/collection-sort-menu.tsx +62 -0
- package/src/components/collection-surface.tsx +46 -0
- package/src/components/collection-toolbar.tsx +33 -0
- package/src/components/collection-view-toggle.tsx +61 -0
- package/src/components/confirm-dialog.tsx +49 -0
- package/src/components/confirmation-card.tsx +32 -0
- package/src/components/context-badges.tsx +124 -0
- package/src/components/context-bar.tsx +202 -0
- package/src/components/form-dialog.tsx +56 -0
- package/src/components/global-selection-popover.tsx +135 -0
- package/src/components/infinite-scroll-sentinel.tsx +48 -0
- package/src/components/json-viewer.tsx +232 -0
- package/src/components/loading-indicator.tsx +40 -0
- package/src/components/markdown-text.tsx +379 -0
- package/src/components/name-dialog.tsx +87 -0
- package/src/components/selection-popover.tsx +156 -0
- package/src/components/show-document-tool-ui.tsx +90 -0
- package/src/components/sidebar.test.tsx +182 -0
- package/src/components/sidebar.tsx +1174 -0
- package/src/components/surface-card.tsx +45 -0
- package/src/components/theme-runtime-provider.tsx +93 -0
- package/src/components/theme-toggle.tsx +27 -0
- package/src/components/tool-call-card.tsx +126 -0
- package/src/components/tool-fallback.tsx +169 -0
- package/src/components/tool-panel-layout.tsx +36 -0
- package/src/components/tool-panel.tsx +233 -0
- package/src/components/tooltip-icon-button.tsx +28 -0
- package/src/components/user-menu.tsx +60 -0
- package/src/index.ts +170 -0
- package/src/lib/__tests__/ag-ui-adapter.test.ts +182 -0
- package/src/lib/__tests__/app-store.test.ts +330 -0
- package/src/lib/__tests__/attachment-adapter.test.ts +48 -0
- package/src/lib/__tests__/chat-bubble-store.test.ts +67 -0
- package/src/lib/__tests__/client-tools.test.ts +124 -0
- package/src/lib/__tests__/dev-store.test.ts +78 -0
- package/src/lib/__tests__/nav-config.test.ts +135 -0
- package/src/lib/__tests__/nav-dnd.test.ts +24 -0
- package/src/lib/__tests__/nav-store.test.ts +59 -0
- package/src/lib/__tests__/sidebar-store.test.ts +144 -0
- package/src/lib/__tests__/theme-store.test.ts +83 -0
- package/src/lib/__tests__/tool-panel-store.test.ts +61 -0
- package/src/lib/__tests__/use-page-context.test.ts +58 -0
- package/src/lib/ag-ui-adapter.ts +362 -0
- package/src/lib/app-store.ts +294 -0
- package/src/lib/attachment-adapter.ts +92 -0
- package/src/lib/auth-provider.tsx +144 -0
- package/src/lib/chat-bubble-store.ts +46 -0
- package/src/lib/client-tools.ts +293 -0
- package/src/lib/dev-store.ts +69 -0
- package/src/lib/nav-config.ts +292 -0
- package/src/lib/nav-dnd.ts +14 -0
- package/src/lib/nav-store.ts +76 -0
- package/src/lib/sidebar-store.ts +113 -0
- package/src/lib/theme-runtime.ts +660 -0
- package/src/lib/theme-store.ts +67 -0
- package/src/lib/tool-panel-store.ts +121 -0
- package/src/lib/use-new-conversation.test.tsx +124 -0
- package/src/lib/use-new-conversation.ts +33 -0
- package/src/lib/use-page-tools.ts +40 -0
- package/src/ui/collapsible.tsx +7 -0
- package/src/ui/dialog.tsx +109 -0
- package/src/ui/dropdown-menu.tsx +71 -0
- package/src/ui/popover.tsx +34 -0
- package/src/ui/select.tsx +15 -0
- package/src/ui/tooltip.tsx +30 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Configuration — types, default config, and lookup utility.
|
|
3
|
+
*
|
|
4
|
+
* This is the single source of truth for the sidebar and agent context projection.
|
|
5
|
+
* The sidebar and app-layout read from the nav store (nav-store.ts), which is
|
|
6
|
+
* initialized with DEFAULT_NAV_CONFIG. Dynamic items (e.g., from a database)
|
|
7
|
+
* can be merged at runtime via useNavStore.getState().addGroup().
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { LucideIcon } from "lucide-react";
|
|
11
|
+
import {
|
|
12
|
+
MessageSquare,
|
|
13
|
+
// FileEdit,
|
|
14
|
+
// FolderOpen,
|
|
15
|
+
// BarChart3,
|
|
16
|
+
// ClipboardList,
|
|
17
|
+
// FileText,
|
|
18
|
+
} from "lucide-react";
|
|
19
|
+
|
|
20
|
+
export interface NavItemAction {
|
|
21
|
+
icon: LucideIcon;
|
|
22
|
+
label: string;
|
|
23
|
+
handler: () => void | Promise<void>;
|
|
24
|
+
/** Render a separator line before this action. */
|
|
25
|
+
separator?: boolean;
|
|
26
|
+
/** Style as destructive (red text). */
|
|
27
|
+
destructive?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Drag-and-drop behavior for a nav item. Provided by feature modules (e.g., SPACES). */
|
|
31
|
+
export interface NavItemDnd {
|
|
32
|
+
/** MIME type for dataTransfer */
|
|
33
|
+
type: string;
|
|
34
|
+
/** Data to set on drag start (e.g., entity ID) */
|
|
35
|
+
getData: () => string | null;
|
|
36
|
+
/** Whether this item can be dragged */
|
|
37
|
+
canDrag: () => boolean;
|
|
38
|
+
/** Whether this item accepts drops */
|
|
39
|
+
canDrop: () => boolean;
|
|
40
|
+
/** Whether this item accepts external file drops (e.g. from Finder) */
|
|
41
|
+
canDropFiles?: () => boolean;
|
|
42
|
+
/** Handle a drop event — receives dragged data string and returns a promise */
|
|
43
|
+
onDrop: (draggedData: string) => Promise<void>;
|
|
44
|
+
/** Handle external file drop — receives File objects from the browser */
|
|
45
|
+
onFileDrop?: (files: File[]) => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface NavItem {
|
|
49
|
+
to: string;
|
|
50
|
+
label: string;
|
|
51
|
+
icon: LucideIcon;
|
|
52
|
+
/** When true, the item is visible but not clickable (ghost state for label scope). */
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
/** Render with reduced opacity (e.g., hidden files). */
|
|
55
|
+
dimmed?: boolean;
|
|
56
|
+
/** Small text badge after the label (e.g., "managed", "locked"). */
|
|
57
|
+
badge?: string;
|
|
58
|
+
/** Page description sent to the agent for context */
|
|
59
|
+
description?: string;
|
|
60
|
+
/** Arbitrary metadata sent to the agent (e.g., workspace IDs, entity types) */
|
|
61
|
+
meta?: Record<string, unknown>;
|
|
62
|
+
/** Page-specific instructions automatically sent to the agent when this page is active */
|
|
63
|
+
contextInstructions?: string;
|
|
64
|
+
/** Sub-navigation items (rendered indented under parent) */
|
|
65
|
+
children?: NavItem[];
|
|
66
|
+
/** Inline action buttons rendered next to the nav item (e.g., + document, + folder) */
|
|
67
|
+
actions?: NavItemAction[];
|
|
68
|
+
/** Optional drag-and-drop behavior (provided by feature modules) */
|
|
69
|
+
dnd?: NavItemDnd;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Action shown in the "+" dropdown on a nav group header. */
|
|
73
|
+
export interface NavGroupCreateAction {
|
|
74
|
+
id: string;
|
|
75
|
+
label: string;
|
|
76
|
+
handler: () => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Item rendered on the collapsed sidebar rail. Returned by `NavGroup.collapsedRail`. */
|
|
80
|
+
export interface CollapsedRailItem {
|
|
81
|
+
to: string;
|
|
82
|
+
label: string;
|
|
83
|
+
icon: LucideIcon;
|
|
84
|
+
isActive: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Context passed to `NavGroup.collapsedRail` so the renderer can decide which
|
|
89
|
+
* shortcuts to surface. Modules read their own keys out of `navContextMeta`
|
|
90
|
+
* (e.g., Spaces uses `spacesRootId`).
|
|
91
|
+
*/
|
|
92
|
+
export interface CollapsedRailContext {
|
|
93
|
+
currentPage: string;
|
|
94
|
+
navContextMeta: Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface NavGroup {
|
|
98
|
+
label: string;
|
|
99
|
+
items: NavItem[];
|
|
100
|
+
/** Optional link for the group header label */
|
|
101
|
+
to?: string;
|
|
102
|
+
/** Hide the group header while preserving group semantics. */
|
|
103
|
+
hideLabel?: boolean;
|
|
104
|
+
/** Group description sent to the agent */
|
|
105
|
+
description?: string;
|
|
106
|
+
/** Group-level metadata (e.g., org settings, workspace config, custom instructions) */
|
|
107
|
+
meta?: Record<string, unknown>;
|
|
108
|
+
/** Group-level instructions automatically sent to the agent for all pages in this group */
|
|
109
|
+
contextInstructions?: string;
|
|
110
|
+
/** Create actions shown as a "+" dropdown in the group header */
|
|
111
|
+
createActions?: NavGroupCreateAction[];
|
|
112
|
+
/**
|
|
113
|
+
* Optional override for the collapsed sidebar rail. When present, the
|
|
114
|
+
* shell calls this instead of mapping `items` to icons one-to-one.
|
|
115
|
+
*
|
|
116
|
+
* Useful for groups whose collapsed view is "header link plus the active
|
|
117
|
+
* item's root", not a flat icon list. Returning `[]` hides the group.
|
|
118
|
+
*/
|
|
119
|
+
collapsedRail?: (ctx: CollapsedRailContext) => CollapsedRailItem[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Static default config — the template ships with these pages. */
|
|
123
|
+
export const DEFAULT_NAV_CONFIG: NavGroup[] = [
|
|
124
|
+
{
|
|
125
|
+
label: "Pages",
|
|
126
|
+
hideLabel: true,
|
|
127
|
+
items: [
|
|
128
|
+
{
|
|
129
|
+
to: "/",
|
|
130
|
+
label: "Chat",
|
|
131
|
+
icon: MessageSquare,
|
|
132
|
+
description: "Chat with the AI assistant",
|
|
133
|
+
},
|
|
134
|
+
// {
|
|
135
|
+
// to: "/dashboard",
|
|
136
|
+
// label: "Dashboard",
|
|
137
|
+
// icon: BarChart3,
|
|
138
|
+
// description: "Overview and analytics",
|
|
139
|
+
// },
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
// {
|
|
143
|
+
// label: "Demos",
|
|
144
|
+
// description: "Demo pages for the Acme Corp engagement",
|
|
145
|
+
// meta: {
|
|
146
|
+
// workspaceId: "ws-acme-2026",
|
|
147
|
+
// client: "Acme Corp",
|
|
148
|
+
// region: "eu-west",
|
|
149
|
+
// permissions: ["read", "write", "comment"],
|
|
150
|
+
// },
|
|
151
|
+
// contextInstructions:
|
|
152
|
+
// "This is the Acme Corp demo workspace. Always address the user professionally. " +
|
|
153
|
+
// "When referencing documents or drafts, mention they belong to the Acme Corp project.",
|
|
154
|
+
// items: [
|
|
155
|
+
// {
|
|
156
|
+
// to: "/workspace",
|
|
157
|
+
// label: "Form",
|
|
158
|
+
// icon: ClipboardList,
|
|
159
|
+
// description:
|
|
160
|
+
// "Draft form with fields: title, description, category " +
|
|
161
|
+
// "(Bug/Feature/Task/Improvement), priority (Low/Medium/High/Critical), " +
|
|
162
|
+
// "assignee. Use ui_update_content to fill fields.",
|
|
163
|
+
// meta: { type: "form", formId: "default" },
|
|
164
|
+
// contextInstructions:
|
|
165
|
+
// "Always use ui_update_content to fill all fields in a single call. " +
|
|
166
|
+
// "Ask for confirmation before clearing the form. " +
|
|
167
|
+
// "Suggest appropriate category and priority based on the description.",
|
|
168
|
+
// },
|
|
169
|
+
// {
|
|
170
|
+
// to: "/documents",
|
|
171
|
+
// label: "Documents",
|
|
172
|
+
// icon: FolderOpen,
|
|
173
|
+
// description: "Browse and manage documents",
|
|
174
|
+
// children: [
|
|
175
|
+
// {
|
|
176
|
+
// to: "/documents",
|
|
177
|
+
// label: "All Documents",
|
|
178
|
+
// icon: FolderOpen,
|
|
179
|
+
// description: "Browse all documents",
|
|
180
|
+
// },
|
|
181
|
+
// {
|
|
182
|
+
// to: "/documents",
|
|
183
|
+
// label: "Engineering",
|
|
184
|
+
// icon: FileText,
|
|
185
|
+
// description: "Engineering documents",
|
|
186
|
+
// meta: { category: "Engineering" },
|
|
187
|
+
// },
|
|
188
|
+
// {
|
|
189
|
+
// to: "/documents",
|
|
190
|
+
// label: "Planning",
|
|
191
|
+
// icon: FileEdit,
|
|
192
|
+
// description: "Planning documents",
|
|
193
|
+
// meta: { category: "Planning" },
|
|
194
|
+
// },
|
|
195
|
+
// ],
|
|
196
|
+
// },
|
|
197
|
+
// ],
|
|
198
|
+
// },
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Find the active nav item by path match.
|
|
203
|
+
* Tries exact match first, then longest-prefix match for dynamic routes.
|
|
204
|
+
* Searches top-level items and their children.
|
|
205
|
+
*
|
|
206
|
+
* For dynamic routes loaded from a database, add them to the nav store
|
|
207
|
+
* at runtime — they'll be found here automatically.
|
|
208
|
+
*/
|
|
209
|
+
export interface NavMatch {
|
|
210
|
+
group: string;
|
|
211
|
+
groupMeta?: Record<string, unknown>;
|
|
212
|
+
groupDescription?: string;
|
|
213
|
+
groupInstructions?: string;
|
|
214
|
+
item: NavItem;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function findExactNavItem(items: NavItem[], path: string): NavItem | null {
|
|
218
|
+
for (const item of items) {
|
|
219
|
+
if (item.to === path) {
|
|
220
|
+
return item;
|
|
221
|
+
}
|
|
222
|
+
if (item.children) {
|
|
223
|
+
const nested = findExactNavItem(item.children, path);
|
|
224
|
+
if (nested) return nested;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function findBestPrefixNavItem(
|
|
231
|
+
items: NavItem[],
|
|
232
|
+
path: string,
|
|
233
|
+
currentBest: { item: NavItem; length: number } | null = null,
|
|
234
|
+
): { item: NavItem; length: number } | null {
|
|
235
|
+
let best = currentBest;
|
|
236
|
+
for (const item of items) {
|
|
237
|
+
if (path.startsWith(item.to + "/") && item.to.length > (best?.length ?? 0)) {
|
|
238
|
+
best = { item, length: item.to.length };
|
|
239
|
+
}
|
|
240
|
+
if (item.children) {
|
|
241
|
+
best = findBestPrefixNavItem(item.children, path, best);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return best;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function findNavItem(config: NavGroup[], path: string): NavMatch | null {
|
|
248
|
+
// Pass 1: exact match on items
|
|
249
|
+
for (const group of config) {
|
|
250
|
+
const base = {
|
|
251
|
+
group: group.label,
|
|
252
|
+
groupMeta: group.meta,
|
|
253
|
+
groupDescription: group.description,
|
|
254
|
+
groupInstructions: group.contextInstructions,
|
|
255
|
+
};
|
|
256
|
+
const match = findExactNavItem(group.items, path);
|
|
257
|
+
if (match) {
|
|
258
|
+
return { ...base, item: match };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Pass 2: group-level `to` match (e.g. /spaces matches Workspaces group)
|
|
263
|
+
for (const group of config) {
|
|
264
|
+
if (group.to && group.to === path) {
|
|
265
|
+
return {
|
|
266
|
+
group: group.label,
|
|
267
|
+
groupMeta: group.meta,
|
|
268
|
+
groupDescription: group.description,
|
|
269
|
+
groupInstructions: group.contextInstructions,
|
|
270
|
+
item: { to: group.to, label: group.label, icon: MessageSquare },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Pass 3: longest-prefix match (for dynamic/parameterized routes)
|
|
276
|
+
let best: NavMatch | null = null;
|
|
277
|
+
let bestLen = 0;
|
|
278
|
+
for (const group of config) {
|
|
279
|
+
const base = {
|
|
280
|
+
group: group.label,
|
|
281
|
+
groupMeta: group.meta,
|
|
282
|
+
groupDescription: group.description,
|
|
283
|
+
groupInstructions: group.contextInstructions,
|
|
284
|
+
};
|
|
285
|
+
const match = findBestPrefixNavItem(group.items, path);
|
|
286
|
+
if (match && match.length > bestLen) {
|
|
287
|
+
best = { ...base, item: match.item };
|
|
288
|
+
bestLen = match.length;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return best;
|
|
292
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function hasExternalFilesData(dataTransfer: {
|
|
2
|
+
types: ArrayLike<string>;
|
|
3
|
+
files?: { length: number } | null;
|
|
4
|
+
}): boolean {
|
|
5
|
+
return Array.from(dataTransfer.types).includes("Files") || (dataTransfer.files?.length ?? 0) > 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function hasAnyDropTarget(isDropTarget: boolean, isFileDropTarget: boolean): boolean {
|
|
9
|
+
return isDropTarget || isFileDropTarget;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getContainerDropZoneLabel(hasExternalFiles: boolean): string {
|
|
13
|
+
return hasExternalFiles ? "Upload to root" : "Move to root";
|
|
14
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Store — Zustand store for the live navigation config.
|
|
3
|
+
*
|
|
4
|
+
* Initialized with DEFAULT_NAV_CONFIG (static pages).
|
|
5
|
+
* Dynamic groups/items (e.g., contract workspaces from a database)
|
|
6
|
+
* can be merged at runtime via addGroup() or setConfig().
|
|
7
|
+
*
|
|
8
|
+
* The sidebar and app-layout both subscribe to this store,
|
|
9
|
+
* so changes propagate automatically.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { create } from "zustand";
|
|
13
|
+
import { type NavGroup, DEFAULT_NAV_CONFIG } from "./nav-config.ts";
|
|
14
|
+
|
|
15
|
+
/** A toggle or action rendered at the bottom of the sidebar. */
|
|
16
|
+
export interface NavFooterAction {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
activeLabel?: string;
|
|
20
|
+
active: boolean;
|
|
21
|
+
onToggle: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface NavStoreState {
|
|
25
|
+
config: NavGroup[];
|
|
26
|
+
footerActions: NavFooterAction[];
|
|
27
|
+
/** Replace the entire config */
|
|
28
|
+
setConfig: (config: NavGroup[]) => void;
|
|
29
|
+
/** Add a group (replaces existing group with the same label) */
|
|
30
|
+
addGroup: (group: NavGroup) => void;
|
|
31
|
+
/** Remove a group by label */
|
|
32
|
+
removeGroup: (label: string) => void;
|
|
33
|
+
/** Register a footer toggle (replaces existing by id) */
|
|
34
|
+
setFooterAction: (action: NavFooterAction) => void;
|
|
35
|
+
/** Remove a footer action by id */
|
|
36
|
+
removeFooterAction: (id: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const useNavStore = create<NavStoreState>((set, get) => ({
|
|
40
|
+
config: DEFAULT_NAV_CONFIG,
|
|
41
|
+
footerActions: [],
|
|
42
|
+
|
|
43
|
+
setConfig: (config) => set({ config }),
|
|
44
|
+
|
|
45
|
+
addGroup: (group) => {
|
|
46
|
+
const config = get().config;
|
|
47
|
+
const idx = config.findIndex((g) => g.label === group.label);
|
|
48
|
+
if (idx >= 0) {
|
|
49
|
+
const next = [...config];
|
|
50
|
+
next[idx] = group;
|
|
51
|
+
set({ config: next });
|
|
52
|
+
} else {
|
|
53
|
+
set({ config: [...config, group] });
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
removeGroup: (label) => {
|
|
58
|
+
set({ config: get().config.filter((g) => g.label !== label) });
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
setFooterAction: (action) => {
|
|
62
|
+
const actions = get().footerActions;
|
|
63
|
+
const idx = actions.findIndex((a) => a.id === action.id);
|
|
64
|
+
if (idx >= 0) {
|
|
65
|
+
const next = [...actions];
|
|
66
|
+
next[idx] = action;
|
|
67
|
+
set({ footerActions: next });
|
|
68
|
+
} else {
|
|
69
|
+
set({ footerActions: [...actions, action] });
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
removeFooterAction: (id) => {
|
|
74
|
+
set({ footerActions: get().footerActions.filter((a) => a.id !== id) });
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar State Management
|
|
3
|
+
*
|
|
4
|
+
* Zustand store for the collapsible left sidebar.
|
|
5
|
+
* Persists expanded/collapsed state to localStorage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { create } from "zustand";
|
|
9
|
+
|
|
10
|
+
export const SIDEBAR_WIDTH = 272;
|
|
11
|
+
export const RAIL_WIDTH = 52;
|
|
12
|
+
export const SIDEBAR_MIN_WIDTH = 248;
|
|
13
|
+
export const SIDEBAR_MAX_WIDTH = 440;
|
|
14
|
+
const MOBILE_BREAKPOINT = 768;
|
|
15
|
+
const STORAGE_KEY = "sidebar";
|
|
16
|
+
const WIDTH_STORAGE_KEY = "sidebar-width";
|
|
17
|
+
const WIDTH_VERSION_STORAGE_KEY = "sidebar-width-version";
|
|
18
|
+
const WIDTH_PERSIST_DELAY_MS = 120;
|
|
19
|
+
const SIDEBAR_WIDTH_VERSION = "2026-03-27-narrow";
|
|
20
|
+
let persistWidthTimer: ReturnType<typeof setTimeout> | null = null;
|
|
21
|
+
|
|
22
|
+
function clampWidth(width: number): number {
|
|
23
|
+
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, width));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getStoredOpen(): boolean {
|
|
27
|
+
if (typeof window === "undefined") return true;
|
|
28
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
29
|
+
if (stored === "true" || stored === "false") return stored === "true";
|
|
30
|
+
// Default: collapsed rail — users expand when they want it
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getStoredWidth(): number {
|
|
35
|
+
if (typeof window === "undefined") return SIDEBAR_WIDTH;
|
|
36
|
+
const version = localStorage.getItem(WIDTH_VERSION_STORAGE_KEY);
|
|
37
|
+
if (version !== SIDEBAR_WIDTH_VERSION) return SIDEBAR_WIDTH;
|
|
38
|
+
const raw = localStorage.getItem(WIDTH_STORAGE_KEY);
|
|
39
|
+
if (raw === null || raw.trim() === "") return SIDEBAR_WIDTH;
|
|
40
|
+
const stored = Number(raw);
|
|
41
|
+
if (!Number.isFinite(stored)) return SIDEBAR_WIDTH;
|
|
42
|
+
return clampWidth(stored);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function persistWidth(width: number): void {
|
|
46
|
+
if (typeof window === "undefined") return;
|
|
47
|
+
if (persistWidthTimer) window.clearTimeout(persistWidthTimer);
|
|
48
|
+
persistWidthTimer = window.setTimeout(() => {
|
|
49
|
+
localStorage.setItem(WIDTH_STORAGE_KEY, String(width));
|
|
50
|
+
localStorage.setItem(WIDTH_VERSION_STORAGE_KEY, SIDEBAR_WIDTH_VERSION);
|
|
51
|
+
persistWidthTimer = null;
|
|
52
|
+
}, WIDTH_PERSIST_DELAY_MS);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SidebarState {
|
|
56
|
+
isOpen: boolean;
|
|
57
|
+
isMobileOpen: boolean;
|
|
58
|
+
width: number;
|
|
59
|
+
|
|
60
|
+
toggle: () => void;
|
|
61
|
+
open: () => void;
|
|
62
|
+
close: () => void;
|
|
63
|
+
toggleMobile: () => void;
|
|
64
|
+
closeMobile: () => void;
|
|
65
|
+
setWidth: (width: number) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const useSidebarStore = create<SidebarState>((set, get) => ({
|
|
69
|
+
isOpen: getStoredOpen(),
|
|
70
|
+
isMobileOpen: false,
|
|
71
|
+
width: getStoredWidth(),
|
|
72
|
+
|
|
73
|
+
toggle: () => {
|
|
74
|
+
const next = !get().isOpen;
|
|
75
|
+
localStorage.setItem(STORAGE_KEY, String(next));
|
|
76
|
+
set({ isOpen: next });
|
|
77
|
+
},
|
|
78
|
+
open: () => {
|
|
79
|
+
localStorage.setItem(STORAGE_KEY, "true");
|
|
80
|
+
set({ isOpen: true });
|
|
81
|
+
},
|
|
82
|
+
close: () => {
|
|
83
|
+
localStorage.setItem(STORAGE_KEY, "false");
|
|
84
|
+
set({ isOpen: false });
|
|
85
|
+
},
|
|
86
|
+
toggleMobile: () => set({ isMobileOpen: !get().isMobileOpen }),
|
|
87
|
+
closeMobile: () => set({ isMobileOpen: false }),
|
|
88
|
+
setWidth: (width) => {
|
|
89
|
+
const next = clampWidth(width);
|
|
90
|
+
persistWidth(next);
|
|
91
|
+
set({ width: next });
|
|
92
|
+
},
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
// Auto-close mobile drawer when resizing above breakpoint
|
|
96
|
+
if (typeof window !== "undefined") {
|
|
97
|
+
const version = localStorage.getItem(WIDTH_VERSION_STORAGE_KEY);
|
|
98
|
+
if (version !== SIDEBAR_WIDTH_VERSION) {
|
|
99
|
+
localStorage.setItem(WIDTH_STORAGE_KEY, String(SIDEBAR_WIDTH));
|
|
100
|
+
localStorage.setItem(WIDTH_VERSION_STORAGE_KEY, SIDEBAR_WIDTH_VERSION);
|
|
101
|
+
useSidebarStore.setState({ width: SIDEBAR_WIDTH });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let timeout: ReturnType<typeof setTimeout>;
|
|
105
|
+
window.addEventListener("resize", () => {
|
|
106
|
+
clearTimeout(timeout);
|
|
107
|
+
timeout = setTimeout(() => {
|
|
108
|
+
if (window.innerWidth >= MOBILE_BREAKPOINT) {
|
|
109
|
+
useSidebarStore.getState().closeMobile();
|
|
110
|
+
}
|
|
111
|
+
}, 100);
|
|
112
|
+
});
|
|
113
|
+
}
|