@xenide-io/the-old-ui-theme 0.7.0 → 0.9.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 (109) hide show
  1. package/README.md +9 -10
  2. package/dist/{Chip-Cq3AiAji.d.mts → Chip-BzuNgJIa.d.mts} +81 -23
  3. package/dist/{Chip-Cq3AiAji.d.ts → Chip-BzuNgJIa.d.ts} +81 -23
  4. package/dist/{ai-message-blocknote-WABTHFY7.mjs → ai-message-blocknote-D4Z6DUCJ.mjs} +9 -3
  5. package/dist/chunk-2EO5VCXP.mjs +3912 -0
  6. package/dist/chunk-APKRZQRO.mjs +251 -0
  7. package/dist/chunk-BS7BZI5W.mjs +19 -0
  8. package/dist/chunk-IULORI3A.mjs +8 -0
  9. package/dist/index.d.mts +3 -3
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.js +997 -576
  12. package/dist/index.mjs +49 -45
  13. package/dist/suite.d.mts +157 -55
  14. package/dist/suite.d.ts +157 -55
  15. package/dist/suite.js +4249 -801
  16. package/dist/suite.mjs +2578 -594
  17. package/dist/theme-boot-P-oqKVZF.d.mts +74 -0
  18. package/dist/theme-boot-P-oqKVZF.d.ts +74 -0
  19. package/dist/theme-boot.d.mts +2 -0
  20. package/dist/theme-boot.d.ts +2 -0
  21. package/dist/theme-boot.js +44 -0
  22. package/dist/theme-boot.mjs +8 -0
  23. package/dist/ui.d.mts +5 -29
  24. package/dist/ui.d.ts +5 -29
  25. package/dist/ui.js +647 -352
  26. package/dist/ui.mjs +10 -12
  27. package/docs/README.md +34 -0
  28. package/docs/ai-quick-reference.md +56 -0
  29. package/docs/component-library-improvement-plan.md +751 -0
  30. package/docs/component-reference.md +81 -0
  31. package/docs/components.md +48 -0
  32. package/docs/demos.md +19 -0
  33. package/docs/installation.md +117 -0
  34. package/docs/suite-audit.md +34 -0
  35. package/docs/theme-token-reference.md +67 -0
  36. package/docs/themes.md +164 -0
  37. package/package.json +22 -21
  38. package/public/fonts/OpenRunde-Bold.woff2 +0 -0
  39. package/public/fonts/OpenRunde-Medium.woff2 +0 -0
  40. package/public/fonts/OpenRunde-Regular.woff2 +0 -0
  41. package/public/fonts/OpenRunde-Semibold.woff2 +0 -0
  42. package/scripts/package-smoke.mjs +43 -0
  43. package/src/app/globals.css +2 -1
  44. package/src/components/ui/Accordion.tsx +20 -6
  45. package/src/components/ui/Avatar.tsx +5 -3
  46. package/src/components/ui/Calendar.tsx +187 -63
  47. package/src/components/ui/Card.test.tsx +38 -0
  48. package/src/components/ui/Card.tsx +77 -14
  49. package/src/components/ui/CollapsibleSection.tsx +12 -4
  50. package/src/components/ui/CommandPalette.test.tsx +91 -4
  51. package/src/components/ui/CommandPalette.tsx +138 -128
  52. package/src/components/ui/EmptyState.tsx +2 -2
  53. package/src/components/ui/FormField.tsx +1 -1
  54. package/src/components/ui/Input.tsx +42 -18
  55. package/src/components/ui/Kbd.tsx +18 -3
  56. package/src/components/ui/Modal.tsx +3 -1
  57. package/src/components/ui/Progress.tsx +14 -5
  58. package/src/components/ui/SegmentedControl.tsx +2 -2
  59. package/src/components/ui/SettingsLayout.tsx +1 -1
  60. package/src/components/ui/Stat.tsx +3 -2
  61. package/src/components/ui/Table.tsx +8 -3
  62. package/src/components/ui/Tooltip.test.tsx +70 -1
  63. package/src/components/ui/Tooltip.tsx +53 -29
  64. package/src/components/ui/Typography.tsx +103 -49
  65. package/src/components/ui/avatar-stat.test.tsx +23 -0
  66. package/src/components/ui/calendar.test.tsx +88 -0
  67. package/src/components/ui/disclosure.test.tsx +39 -0
  68. package/src/components/ui/index.ts +44 -6
  69. package/src/components/ui/progress.test.tsx +15 -0
  70. package/src/components/ui/typography.test.tsx +26 -1
  71. package/src/styles/suite-skin.css +8 -2
  72. package/src/styles/themes.css +184 -68
  73. package/src/suite/components/ai-message-blocknote.tsx +13 -2
  74. package/src/suite/components/ai-panel.tsx +41 -10
  75. package/src/suite/components/app-switcher.test.tsx +21 -6
  76. package/src/suite/components/app-switcher.tsx +7 -11
  77. package/src/suite/components/suite-app-layout.tsx +12 -3
  78. package/src/suite/components/suite-bottom-nav.tsx +0 -1
  79. package/src/suite/components/suite-clients-projects-directory.test.tsx +71 -0
  80. package/src/suite/components/suite-clients-projects-directory.tsx +943 -0
  81. package/src/suite/components/suite-integration-picker.test.tsx +224 -0
  82. package/src/suite/components/suite-integration-picker.tsx +939 -0
  83. package/src/suite/components/suite-integration-rules.test.tsx +157 -0
  84. package/src/suite/components/suite-integration-rules.tsx +471 -0
  85. package/src/suite/components/suite-mobile-drawer.test.tsx +18 -2
  86. package/src/suite/components/suite-mobile-drawer.tsx +65 -98
  87. package/src/suite/components/suite-notification-bell.tsx +29 -4
  88. package/src/suite/components/suite-sidebar.tsx +25 -25
  89. package/src/suite/components/suite-skeleton.tsx +2 -2
  90. package/src/suite/components/suite-user-menu.tsx +9 -8
  91. package/src/suite/components/theme-provider.test.tsx +87 -3
  92. package/src/suite/components/theme-provider.tsx +144 -23
  93. package/src/suite/components/today-ui.tsx +79 -68
  94. package/src/suite/components/workspace-switcher.test.tsx +56 -0
  95. package/src/suite/components/workspace-switcher.tsx +3 -3
  96. package/src/suite/icons/app-accents.ts +12 -12
  97. package/src/suite/icons/glyphs.ts +6 -12
  98. package/src/suite/index.ts +27 -0
  99. package/src/suite/lib/apps.test.ts +9 -1
  100. package/src/suite/lib/apps.ts +14 -8
  101. package/src/suite/lib/cookies.ts +53 -0
  102. package/src/suite/lib/theme-boot.ts +32 -0
  103. package/src/suite/lib/theme-cookie.ts +7 -0
  104. package/src/suite/lib/use-sidebar-width.ts +5 -51
  105. package/dist/chunk-KDR6HI6F.mjs +0 -1075
  106. package/dist/chunk-NOI42JNZ.mjs +0 -151
  107. package/dist/chunk-VXISSXTB.mjs +0 -2564
  108. package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
  109. package/src/suite/lib/use-lock-mobile-page-zoom.ts +0 -51
@@ -0,0 +1,943 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type KeyboardEvent,
9
+ } from "react";
10
+ import {
11
+ ArrowLeft,
12
+ MediaImagePlus as ImagePlus,
13
+ MoreHoriz as MoreHorizontal,
14
+ Plus,
15
+ Search,
16
+ Trash,
17
+ } from "iconoir-react";
18
+ import {
19
+ Alert,
20
+ Button,
21
+ DropdownItem,
22
+ DropdownMenu,
23
+ FileUpload,
24
+ Input,
25
+ Modal,
26
+ Select,
27
+ } from "@/components/ui";
28
+ import { cn } from "@/lib/cn";
29
+
30
+ export interface SuiteDirectoryClient {
31
+ id: string;
32
+ name: string;
33
+ icon?: string | null;
34
+ color?: string | null;
35
+ icon_image?: string | null;
36
+ }
37
+
38
+ export interface SuiteDirectoryProject {
39
+ id: string;
40
+ name: string;
41
+ icon?: string | null;
42
+ color?: string | null;
43
+ clientId?: string | null;
44
+ isArchived?: boolean;
45
+ icon_image?: string | null;
46
+ }
47
+
48
+ export interface SuiteDirectoryFields {
49
+ name: string;
50
+ icon: string;
51
+ color: string;
52
+ iconImageFile?: File | null;
53
+ clearIconImage?: boolean;
54
+ }
55
+
56
+ export interface SuiteDirectoryProjectFields extends SuiteDirectoryFields {
57
+ clientId: string | null;
58
+ isArchived?: boolean;
59
+ }
60
+
61
+ export interface SuiteClientsProjectsDirectoryProps {
62
+ clients: SuiteDirectoryClient[];
63
+ projects: SuiteDirectoryProject[];
64
+ canEdit?: boolean;
65
+ loading?: boolean;
66
+ error?: string | null;
67
+ onCreateClient?: (
68
+ fields: SuiteDirectoryFields,
69
+ ) => Promise<SuiteDirectoryClient | void>;
70
+ onUpdateClient?: (
71
+ client: SuiteDirectoryClient,
72
+ fields: SuiteDirectoryFields,
73
+ ) => Promise<SuiteDirectoryClient | void>;
74
+ onDeleteClient?: (client: SuiteDirectoryClient) => Promise<void>;
75
+ onCreateProject?: (
76
+ fields: SuiteDirectoryProjectFields,
77
+ ) => Promise<SuiteDirectoryProject | void>;
78
+ onUpdateProject?: (
79
+ project: SuiteDirectoryProject,
80
+ fields: SuiteDirectoryProjectFields,
81
+ ) => Promise<SuiteDirectoryProject | void>;
82
+ onDeleteProject?: (project: SuiteDirectoryProject) => Promise<void>;
83
+ dataTest?: string;
84
+ }
85
+
86
+ const DEFAULT_ICON = "castle-emblem";
87
+ const DEFAULT_COLOR = "#6366f1";
88
+
89
+ const DIRECTORY_ICON_OPTIONS = [
90
+ ["arena", "Arena"],
91
+ ["bridge", "Bridge"],
92
+ ["capitol", "Capitol"],
93
+ ["castle-emblem", "Castle"],
94
+ ["castle-flag", "Castle flag"],
95
+ ["lighthouse", "Lighthouse"],
96
+ ["tower", "Tower"],
97
+ ["wooden-sign", "Sign"],
98
+ ["anvil", "Anvil"],
99
+ ["book", "Book"],
100
+ ["chessboard", "Chessboard"],
101
+ ["cog", "Settings"],
102
+ ["hammer", "Build"],
103
+ ["light-bulb", "Idea"],
104
+ ["microphone", "Communication"],
105
+ ["quill-ink", "Writing"],
106
+ ["scroll-unfurled", "Notes"],
107
+ ["hourglass", "Planning"],
108
+ ["wrench", "Tools"],
109
+ ["archer", "Archer"],
110
+ ["archery-target", "Target"],
111
+ ["axe", "Axe"],
112
+ ["campfire", "Campfire"],
113
+ ["compass", "Compass"],
114
+ ["crossed-swords", "Battle"],
115
+ ["knight-helmet", "Knight"],
116
+ ["mountains", "Mountains"],
117
+ ["shield", "Shield"],
118
+ ["ship-emblem", "Voyage"],
119
+ ["sword", "Sword"],
120
+ ["trophy", "Trophy"],
121
+ ["apple", "Apple"],
122
+ ["butterfly", "Butterfly"],
123
+ ["clover", "Clover"],
124
+ ["dragon", "Dragon"],
125
+ ["fire", "Fire"],
126
+ ["flower", "Flower"],
127
+ ["flowers", "Flowers"],
128
+ ["grass", "Grass"],
129
+ ["leaf", "Leaf"],
130
+ ["pine-tree", "Pine tree"],
131
+ ["sun", "Sun"],
132
+ ["water-drop", "Water"],
133
+ ["aura", "Aura"],
134
+ ["crystal-ball", "Crystal ball"],
135
+ ["diamond", "Diamond"],
136
+ ["explosion", "Explosion"],
137
+ ["fire-symbol", "Fire symbol"],
138
+ ["gem", "Gem"],
139
+ ["lightning-trio", "Lightning"],
140
+ ["potion", "Potion"],
141
+ ["rune-stone", "Rune stone"],
142
+ ["skull", "Skull"],
143
+ ["tentacle", "Tentacle"],
144
+ ["wolf-head", "Wolf"],
145
+ ["anchor", "Anchor"],
146
+ ["beer", "Beer"],
147
+ ["carrot", "Carrot"],
148
+ ["cat", "Cat"],
149
+ ["crown", "Crown"],
150
+ ["gold-bar", "Gold bar"],
151
+ ["helmet", "Helmet"],
152
+ ["key", "Key"],
153
+ ["three-keys", "Keys"],
154
+ ] as const;
155
+
156
+ type DirectoryIconOption = (typeof DIRECTORY_ICON_OPTIONS)[number][0];
157
+
158
+ function isDirectoryIcon(value: string): value is DirectoryIconOption {
159
+ return DIRECTORY_ICON_OPTIONS.some(([name]) => name === value);
160
+ }
161
+
162
+ function DirectoryIconPicker({
163
+ value,
164
+ preview,
165
+ onFileChange,
166
+ onClear,
167
+ onChange,
168
+ }: {
169
+ value: string;
170
+ preview?: string | null;
171
+ onFileChange: (file: File | null) => void;
172
+ onClear: () => void;
173
+ onChange: (value: string) => void;
174
+ }) {
175
+ const iconRefs = useRef<Record<string, HTMLButtonElement | null>>({});
176
+
177
+ function moveSelection(event: KeyboardEvent<HTMLButtonElement>, index: number) {
178
+ let nextIndex: number | null = null;
179
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
180
+ nextIndex = (index + 1) % DIRECTORY_ICON_OPTIONS.length;
181
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
182
+ nextIndex =
183
+ (index - 1 + DIRECTORY_ICON_OPTIONS.length) % DIRECTORY_ICON_OPTIONS.length;
184
+ } else if (event.key === "Home") {
185
+ nextIndex = 0;
186
+ } else if (event.key === "End") {
187
+ nextIndex = DIRECTORY_ICON_OPTIONS.length - 1;
188
+ }
189
+
190
+ if (nextIndex === null) return;
191
+ event.preventDefault();
192
+ const [nextIcon] = DIRECTORY_ICON_OPTIONS[nextIndex];
193
+ onChange(nextIcon);
194
+ iconRefs.current[nextIcon]?.focus();
195
+ }
196
+
197
+ return (
198
+ <div className="space-y-2">
199
+ <span className="mb-1.5 block text-sm font-medium text-ph-ink">Icon</span>
200
+ <div
201
+ role="radiogroup"
202
+ aria-label="Icon"
203
+ className="grid grid-cols-6 gap-1.5 rounded-lg border border-ph-border p-1.5 sm:grid-cols-8"
204
+ >
205
+ {DIRECTORY_ICON_OPTIONS.map(([name, label], index) => {
206
+ const selected = value === name;
207
+ return (
208
+ <button
209
+ key={name}
210
+ type="button"
211
+ role="radio"
212
+ aria-label={label}
213
+ aria-checked={selected}
214
+ tabIndex={selected ? 0 : -1}
215
+ title={label}
216
+ onClick={() => onChange(name)}
217
+ onKeyDown={(event) => moveSelection(event, index)}
218
+ ref={(element) => {
219
+ iconRefs.current[name] = element;
220
+ }}
221
+ className={cn(
222
+ "flex h-9 items-center justify-center rounded-md text-ph-mutedtext hover:bg-ph-muted hover:text-ph-ink",
223
+ selected &&
224
+ "bg-ph-muted text-ph-ink ring-2 ring-ph-brand ring-inset",
225
+ )}
226
+ >
227
+ <i
228
+ className={cn("ra", `ra-${name}`, "text-base leading-none")}
229
+ aria-hidden
230
+ />
231
+ </button>
232
+ );
233
+ })}
234
+ </div>
235
+ <div className="rounded-lg border border-ph-border bg-ph-muted p-2">
236
+ <div className="flex items-center gap-2 text-sm font-medium text-ph-ink">
237
+ <ImagePlus className="h-4 w-4" aria-hidden /> Custom image
238
+ </div>
239
+ <p className="mt-1 text-xs text-ph-mutedtext">
240
+ PNG, JPG, GIF, WebP, or SVG — max 512 KB.
241
+ </p>
242
+ <FileUpload
243
+ accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,.svg"
244
+ onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
245
+ />
246
+ {preview ? (
247
+ <div className="mt-2 flex items-center gap-2">
248
+ {/* eslint-disable-next-line @next/next/no-img-element */}
249
+ <img
250
+ src={preview}
251
+ alt=""
252
+ className="h-8 w-8 rounded object-cover"
253
+ />
254
+ <button
255
+ type="button"
256
+ className="text-xs text-ph-mutedtext hover:text-ph-ink"
257
+ onClick={onClear}
258
+ >
259
+ <Trash className="mr-1 inline h-3.5 w-3.5" aria-hidden /> Remove
260
+ custom image
261
+ </button>
262
+ </div>
263
+ ) : null}
264
+ </div>
265
+ </div>
266
+ );
267
+ }
268
+
269
+ function DirectoryIcon({
270
+ item,
271
+ }: {
272
+ item: SuiteDirectoryClient | SuiteDirectoryProject;
273
+ }) {
274
+ const icon =
275
+ item.icon && isDirectoryIcon(item.icon) ? item.icon : DEFAULT_ICON;
276
+ return (
277
+ <span
278
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-white"
279
+ style={{ backgroundColor: item.color || "var(--ph-brand)" }}
280
+ >
281
+ {item.icon_image ? (
282
+ // eslint-disable-next-line @next/next/no-img-element
283
+ <img
284
+ src={item.icon_image}
285
+ alt=""
286
+ className="h-full w-full rounded-lg object-cover"
287
+ />
288
+ ) : (
289
+ <i
290
+ className={cn("ra", `ra-${icon}`, "text-base leading-none")}
291
+ aria-hidden
292
+ />
293
+ )}
294
+ </span>
295
+ );
296
+ }
297
+
298
+ type Editing =
299
+ | { kind: "client"; item: SuiteDirectoryClient }
300
+ | { kind: "project"; item: SuiteDirectoryProject }
301
+ | { kind: "new-client" }
302
+ | { kind: "new-project" }
303
+ | null;
304
+
305
+ type Confirming = {
306
+ item: SuiteDirectoryClient | SuiteDirectoryProject;
307
+ kind: "client" | "project";
308
+ };
309
+
310
+ export function SuiteClientsProjectsDirectory({
311
+ clients,
312
+ projects,
313
+ canEdit = false,
314
+ loading = false,
315
+ error,
316
+ onCreateClient,
317
+ onUpdateClient,
318
+ onDeleteClient,
319
+ onCreateProject,
320
+ onUpdateProject,
321
+ onDeleteProject,
322
+ dataTest = "suite-clients-projects-directory",
323
+ }: SuiteClientsProjectsDirectoryProps) {
324
+ const [query, setQuery] = useState("");
325
+ const [selectedClientId, setSelectedClientId] = useState<string | null>(null);
326
+ const [mobileDetail, setMobileDetail] = useState(false);
327
+ const [editing, setEditing] = useState<Editing>(null);
328
+ const [draft, setDraft] = useState<SuiteDirectoryFields>({
329
+ name: "",
330
+ icon: DEFAULT_ICON,
331
+ color: DEFAULT_COLOR,
332
+ });
333
+ const [draftClientId, setDraftClientId] = useState<string | null>(null);
334
+ const [preview, setPreview] = useState<string | null>(null);
335
+ const [draftArchived, setDraftArchived] = useState(false);
336
+ const [confirming, setConfirming] = useState<Confirming | null>(null);
337
+ const [saving, setSaving] = useState(false);
338
+ const [mutationError, setMutationError] = useState<string | null>(null);
339
+ const clientButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
340
+ const backToClientsRef = useRef<HTMLButtonElement>(null);
341
+ const mobileFocusTarget = useRef<"detail" | "list" | null>(null);
342
+
343
+ useEffect(() => {
344
+ if (
345
+ selectedClientId &&
346
+ clients.some((client) => client.id === selectedClientId)
347
+ )
348
+ return;
349
+ setSelectedClientId(clients[0]?.id ?? null);
350
+ }, [clients, selectedClientId]);
351
+
352
+ useEffect(() => {
353
+ const target = mobileFocusTarget.current;
354
+ if (!target) return;
355
+ mobileFocusTarget.current = null;
356
+
357
+ if (target === "detail") backToClientsRef.current?.focus();
358
+ else if (selectedClientId)
359
+ clientButtonRefs.current[selectedClientId]?.focus();
360
+ }, [mobileDetail, selectedClientId]);
361
+
362
+ const filteredClients = useMemo(() => {
363
+ const value = query.trim().toLowerCase();
364
+ return [...clients]
365
+ .sort((a, b) => a.name.localeCompare(b.name))
366
+ .filter((client) => !value || client.name.toLowerCase().includes(value));
367
+ }, [clients, query]);
368
+ const selectedClient =
369
+ clients.find((client) => client.id === selectedClientId) ?? null;
370
+ const selectedProjects = projects
371
+ .filter((project) => project.clientId === selectedClientId)
372
+ .sort((a, b) => a.name.localeCompare(b.name));
373
+
374
+ function isMobileDirectoryView() {
375
+ return window.matchMedia?.("(max-width: 1023px)").matches ?? false;
376
+ }
377
+
378
+ function selectClient(client: SuiteDirectoryClient) {
379
+ setSelectedClientId(client.id);
380
+ setMobileDetail(true);
381
+ if (isMobileDirectoryView()) mobileFocusTarget.current = "detail";
382
+ }
383
+
384
+ function moveClientSelection(
385
+ event: KeyboardEvent<HTMLButtonElement>,
386
+ index: number,
387
+ ) {
388
+ let nextIndex: number | null = null;
389
+ if (event.key === "ArrowDown") {
390
+ nextIndex = (index + 1) % filteredClients.length;
391
+ } else if (event.key === "ArrowUp") {
392
+ nextIndex =
393
+ (index - 1 + filteredClients.length) % filteredClients.length;
394
+ } else if (event.key === "Home") {
395
+ nextIndex = 0;
396
+ } else if (event.key === "End") {
397
+ nextIndex = filteredClients.length - 1;
398
+ }
399
+
400
+ if (nextIndex === null) return;
401
+ event.preventDefault();
402
+ const client = filteredClients[nextIndex];
403
+ selectClient(client);
404
+ if (!isMobileDirectoryView()) clientButtonRefs.current[client.id]?.focus();
405
+ }
406
+
407
+ function openNew(kind: "client" | "project") {
408
+ setMutationError(null);
409
+ setDraft({
410
+ name: "",
411
+ icon: DEFAULT_ICON,
412
+ color: DEFAULT_COLOR,
413
+ iconImageFile: null,
414
+ clearIconImage: false,
415
+ });
416
+ setPreview(null);
417
+ setDraftArchived(false);
418
+ setDraftClientId(kind === "project" ? selectedClientId : null);
419
+ setEditing({ kind: kind === "client" ? "new-client" : "new-project" });
420
+ }
421
+
422
+ function openEdit(
423
+ item: SuiteDirectoryClient | SuiteDirectoryProject,
424
+ kind: "client" | "project",
425
+ ) {
426
+ setMutationError(null);
427
+ setDraft({
428
+ name: item.name,
429
+ icon: item.icon && isDirectoryIcon(item.icon) ? item.icon : DEFAULT_ICON,
430
+ color: item.color || DEFAULT_COLOR,
431
+ iconImageFile: null,
432
+ clearIconImage: false,
433
+ });
434
+ setPreview(item.icon_image || null);
435
+ setDraftArchived(
436
+ kind === "project" && Boolean((item as SuiteDirectoryProject).isArchived),
437
+ );
438
+ setDraftClientId(
439
+ kind === "project"
440
+ ? ((item as SuiteDirectoryProject).clientId ?? null)
441
+ : null,
442
+ );
443
+ setEditing({ kind, item: item as never });
444
+ }
445
+
446
+ function chooseImage(file: File | null) {
447
+ if (!file) return;
448
+ if (file.size > 512 * 1024) {
449
+ setMutationError("Icon must be 512 KB or smaller.");
450
+ return;
451
+ }
452
+ setMutationError(null);
453
+ if (preview?.startsWith("blob:")) URL.revokeObjectURL(preview);
454
+ setDraft((current) => ({
455
+ ...current,
456
+ iconImageFile: file,
457
+ clearIconImage: false,
458
+ }));
459
+ setPreview(URL.createObjectURL(file));
460
+ }
461
+
462
+ function clearImage() {
463
+ if (preview?.startsWith("blob:")) URL.revokeObjectURL(preview);
464
+ setPreview(null);
465
+ setDraft((current) => ({
466
+ ...current,
467
+ iconImageFile: null,
468
+ clearIconImage: true,
469
+ }));
470
+ }
471
+
472
+ function closeEditor() {
473
+ if (!saving) setEditing(null);
474
+ }
475
+
476
+ async function save() {
477
+ if (!editing || !draft.name.trim() || saving) return;
478
+ setSaving(true);
479
+ setMutationError(null);
480
+ try {
481
+ const fields = { ...draft, name: draft.name.trim() };
482
+ if (editing.kind === "new-client") await onCreateClient?.(fields);
483
+ else if (editing.kind === "new-project")
484
+ await onCreateProject?.({
485
+ ...fields,
486
+ clientId: draftClientId,
487
+ isArchived: draftArchived,
488
+ });
489
+ else if (editing.kind === "client")
490
+ await onUpdateClient?.(editing.item, fields);
491
+ else
492
+ await onUpdateProject?.(editing.item, {
493
+ ...fields,
494
+ clientId: draftClientId,
495
+ isArchived: draftArchived,
496
+ });
497
+ setEditing(null);
498
+ } catch (err) {
499
+ setMutationError(
500
+ err instanceof Error ? err.message : "Failed to save directory item.",
501
+ );
502
+ } finally {
503
+ setSaving(false);
504
+ }
505
+ }
506
+
507
+ function remove(
508
+ item: SuiteDirectoryClient | SuiteDirectoryProject,
509
+ kind: "client" | "project",
510
+ ) {
511
+ setConfirming({ item, kind });
512
+ }
513
+
514
+ async function toggleArchive(project: SuiteDirectoryProject) {
515
+ if (!onUpdateProject) return;
516
+ try {
517
+ await onUpdateProject(project, {
518
+ name: project.name,
519
+ icon:
520
+ project.icon && isDirectoryIcon(project.icon)
521
+ ? project.icon
522
+ : DEFAULT_ICON,
523
+ color: project.color || DEFAULT_COLOR,
524
+ clientId: project.clientId ?? null,
525
+ isArchived: !project.isArchived,
526
+ });
527
+ } catch (err) {
528
+ setMutationError(
529
+ err instanceof Error ? err.message : "Failed to update project.",
530
+ );
531
+ }
532
+ }
533
+
534
+ async function confirmRemove() {
535
+ if (!confirming) return;
536
+ const { item, kind } = confirming;
537
+ setConfirming(null);
538
+ try {
539
+ if (kind === "client")
540
+ await onDeleteClient?.(item as SuiteDirectoryClient);
541
+ else await onDeleteProject?.(item as SuiteDirectoryProject);
542
+ } catch (err) {
543
+ setMutationError(
544
+ err instanceof Error ? err.message : "Failed to delete directory item.",
545
+ );
546
+ }
547
+ }
548
+
549
+ return (
550
+ <div data-test={dataTest} className="space-y-3">
551
+ {error || mutationError ? (
552
+ <Alert status="danger" live="assertive">{error || mutationError}</Alert>
553
+ ) : null}
554
+ {loading ? (
555
+ <p className="text-sm text-ph-mutedtext">Loading directory...</p>
556
+ ) : (
557
+ <div className="overflow-hidden rounded-xl border border-ph-border bg-ph-surface lg:grid lg:min-h-[22rem] lg:grid-cols-[minmax(14rem,18rem)_1fr]">
558
+ <section
559
+ className={cn(
560
+ "border-ph-border lg:border-r",
561
+ mobileDetail ? "hidden lg:block" : "block",
562
+ )}
563
+ >
564
+ <div className="flex h-14 items-center gap-2 border-b border-ph-border px-3">
565
+ <div className="relative min-w-0 flex-1">
566
+ <Search
567
+ className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ph-mutedtext"
568
+ aria-hidden
569
+ />
570
+ <Input
571
+ hideLabel
572
+ aria-label="Search clients"
573
+ placeholder="Search clients..."
574
+ value={query}
575
+ onChange={(event) => setQuery(event.target.value)}
576
+ className="pl-9"
577
+ />
578
+ </div>
579
+ {canEdit && onCreateClient ? (
580
+ <Button
581
+ size="sm"
582
+ variant="primary"
583
+ icon={<Plus className="h-4 w-4" />}
584
+ onClick={() => openNew("client")}
585
+ >
586
+ New client
587
+ </Button>
588
+ ) : null}
589
+ </div>
590
+ <ul
591
+ role="listbox"
592
+ aria-label="Clients"
593
+ className="max-h-[28rem] overflow-y-auto p-1.5"
594
+ >
595
+ {filteredClients.length ? (
596
+ filteredClients.map((client, index) => {
597
+ const selected = client.id === selectedClientId;
598
+ const count = projects.filter(
599
+ (project) => project.clientId === client.id,
600
+ ).length;
601
+ return (
602
+ <li key={client.id} role="none">
603
+ <button
604
+ type="button"
605
+ role="option"
606
+ aria-selected={selected}
607
+ tabIndex={selected ? 0 : -1}
608
+ onClick={() => selectClient(client)}
609
+ onKeyDown={(event) => moveClientSelection(event, index)}
610
+ ref={(element) => {
611
+ clientButtonRefs.current[client.id] = element;
612
+ }}
613
+ className={cn(
614
+ "flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left",
615
+ selected ? "bg-ph-muted" : "hover:bg-ph-canvas",
616
+ )}
617
+ >
618
+ <DirectoryIcon item={client} />
619
+ <span className="min-w-0 flex-1">
620
+ <span className="block truncate text-sm font-medium text-ph-ink">
621
+ {client.name}
622
+ </span>
623
+ <span className="text-xs text-ph-mutedtext">
624
+ {count} project{count === 1 ? "" : "s"}
625
+ </span>
626
+ </span>
627
+ </button>
628
+ </li>
629
+ );
630
+ })
631
+ ) : (
632
+ <li className="px-3 py-8 text-center text-sm text-ph-mutedtext">
633
+ {clients.length
634
+ ? "No matches."
635
+ : "Add a client to get started."}
636
+ </li>
637
+ )}
638
+ </ul>
639
+ </section>
640
+ <section
641
+ className={cn(
642
+ "flex min-w-0 flex-col",
643
+ mobileDetail ? "flex" : "hidden lg:flex",
644
+ )}
645
+ >
646
+ <div className="flex min-h-14 items-center gap-2 border-b border-ph-border px-3 py-2">
647
+ {selectedClient ? (
648
+ <>
649
+ <button
650
+ type="button"
651
+ ref={backToClientsRef}
652
+ className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-ph-mutedtext hover:bg-ph-muted lg:hidden"
653
+ onClick={() => {
654
+ mobileFocusTarget.current = "list";
655
+ setMobileDetail(false);
656
+ }}
657
+ aria-label="Back to clients"
658
+ >
659
+ <ArrowLeft className="h-4 w-4" />
660
+ </button>
661
+ <DirectoryIcon item={selectedClient} />
662
+ <div className="min-w-0 flex-1">
663
+ <h3 className="truncate text-sm font-semibold text-ph-ink">
664
+ {selectedClient.name}
665
+ </h3>
666
+ <p className="text-xs text-ph-mutedtext">
667
+ {selectedProjects.length} project
668
+ {selectedProjects.length === 1 ? "" : "s"}
669
+ </p>
670
+ </div>
671
+ {canEdit && (onUpdateClient || onDeleteClient) ? (
672
+ <div onClick={(event) => event.stopPropagation()}>
673
+ <DropdownMenu
674
+ aria-label={`Actions for ${selectedClient.name}`}
675
+ align="end"
676
+ trigger={
677
+ <span className="inline-flex h-8 w-8 items-center justify-center rounded-md text-ph-mutedtext hover:bg-ph-muted hover:text-ph-ink">
678
+ <MoreHorizontal className="h-4 w-4" aria-hidden />
679
+ <span className="sr-only">Client actions</span>
680
+ </span>
681
+ }
682
+ >
683
+ <div className="p-1">
684
+ {onUpdateClient ? (
685
+ <DropdownItem
686
+ onClick={() => openEdit(selectedClient, "client")}
687
+ >
688
+ Edit client
689
+ </DropdownItem>
690
+ ) : null}
691
+ {onDeleteClient ? (
692
+ <DropdownItem
693
+ className="text-red-600"
694
+ onClick={() =>
695
+ void remove(selectedClient, "client")
696
+ }
697
+ >
698
+ Delete client
699
+ </DropdownItem>
700
+ ) : null}
701
+ </div>
702
+ </DropdownMenu>
703
+ </div>
704
+ ) : null}
705
+ </>
706
+ ) : (
707
+ <p className="text-sm text-ph-mutedtext">Select a client</p>
708
+ )}
709
+ </div>
710
+ {canEdit && onCreateProject ? (
711
+ <div className="border-b border-ph-border p-3">
712
+ <Button
713
+ variant="primary"
714
+ icon={<Plus className="h-4 w-4" />}
715
+ onClick={() => openNew("project")}
716
+ >
717
+ Add project
718
+ </Button>
719
+ </div>
720
+ ) : null}
721
+ <ul className="flex-1 p-2">
722
+ {selectedClient && selectedProjects.length ? (
723
+ selectedProjects.map((project) => (
724
+ <li key={project.id}>
725
+ <div
726
+ className="flex items-center gap-3 rounded-lg px-2 py-1 hover:bg-ph-canvas"
727
+ onClick={() => openEdit(project, "project")}
728
+ >
729
+ <button
730
+ type="button"
731
+ className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
732
+ onClick={() => openEdit(project, "project")}
733
+ >
734
+ <DirectoryIcon item={project} />
735
+ <span className="min-w-0 flex-1">
736
+ <span
737
+ className={cn(
738
+ "block truncate text-sm font-medium",
739
+ project.isArchived
740
+ ? "text-ph-mutedtext line-through"
741
+ : "text-ph-ink",
742
+ )}
743
+ >
744
+ {project.name}
745
+ </span>
746
+ <span className="text-xs text-ph-mutedtext">
747
+ {project.isArchived ? "Archived" : "Active"}
748
+ </span>
749
+ </span>
750
+ </button>
751
+ {canEdit && (onUpdateProject || onDeleteProject) ? (
752
+ <div onClick={(event) => event.stopPropagation()}>
753
+ <DropdownMenu
754
+ aria-label={`Actions for ${project.name}`}
755
+ align="end"
756
+ trigger={
757
+ <span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-ph-mutedtext hover:bg-ph-muted hover:text-ph-ink">
758
+ <MoreHorizontal
759
+ className="h-4 w-4"
760
+ aria-hidden
761
+ />
762
+ <span className="sr-only">Project actions</span>
763
+ </span>
764
+ }
765
+ >
766
+ <div className="p-1">
767
+ {onUpdateProject ? (
768
+ <>
769
+ <DropdownItem
770
+ onClick={() => openEdit(project, "project")}
771
+ >
772
+ Edit project
773
+ </DropdownItem>
774
+ <DropdownItem
775
+ onClick={() => void toggleArchive(project)}
776
+ >
777
+ {project.isArchived
778
+ ? "Unarchive project"
779
+ : "Archive project"}
780
+ </DropdownItem>
781
+ </>
782
+ ) : null}
783
+ {onDeleteProject ? (
784
+ <DropdownItem
785
+ className="text-red-600"
786
+ onClick={() =>
787
+ void remove(project, "project")
788
+ }
789
+ >
790
+ Delete project
791
+ </DropdownItem>
792
+ ) : null}
793
+ </div>
794
+ </DropdownMenu>
795
+ </div>
796
+ ) : null}
797
+ </div>
798
+ </li>
799
+ ))
800
+ ) : (
801
+ <li className="px-3 py-8 text-center text-sm text-ph-mutedtext">
802
+ {selectedClient
803
+ ? "No projects yet."
804
+ : "Select a client to see its projects."}
805
+ </li>
806
+ )}
807
+ </ul>
808
+ </section>
809
+ </div>
810
+ )}
811
+ <Modal
812
+ open={editing !== null}
813
+ onClose={closeEditor}
814
+ title={
815
+ editing?.kind.includes("project")
816
+ ? editing.kind === "new-project"
817
+ ? "New project"
818
+ : "Edit project"
819
+ : editing?.kind === "new-client"
820
+ ? "New client"
821
+ : "Edit client"
822
+ }
823
+ footer={
824
+ <div className="flex justify-end gap-2">
825
+ <Button variant="tertiary" onClick={closeEditor} disabled={saving}>
826
+ Cancel
827
+ </Button>
828
+ <Button
829
+ variant="primary"
830
+ onClick={() => void save()}
831
+ disabled={saving || !draft.name.trim()}
832
+ >
833
+ {saving ? "Saving..." : "Save"}
834
+ </Button>
835
+ </div>
836
+ }
837
+ >
838
+ <div className="space-y-3">
839
+ <Input
840
+ label="Name"
841
+ autoFocus
842
+ value={draft.name}
843
+ onChange={(event) =>
844
+ setDraft({ ...draft, name: event.target.value })
845
+ }
846
+ />
847
+ <DirectoryIconPicker
848
+ value={isDirectoryIcon(draft.icon) ? draft.icon : DEFAULT_ICON}
849
+ preview={preview}
850
+ onFileChange={chooseImage}
851
+ onClear={clearImage}
852
+ onChange={(icon) => {
853
+ if (preview) clearImage();
854
+ setDraft((current) => ({ ...current, icon }));
855
+ }}
856
+ />
857
+ <div>
858
+ <span className="mb-1.5 block text-sm font-medium text-ph-ink">
859
+ Colour
860
+ </span>
861
+ <div className="flex flex-wrap gap-2">
862
+ {[
863
+ "#6366f1",
864
+ "#0ea5e9",
865
+ "#10b981",
866
+ "#f59e0b",
867
+ "#ef4444",
868
+ "#a855f7",
869
+ "#64748b",
870
+ ].map((color) => (
871
+ <button
872
+ key={color}
873
+ type="button"
874
+ aria-label={`Colour ${color}`}
875
+ aria-pressed={draft.color === color}
876
+ onClick={() => setDraft({ ...draft, color })}
877
+ className={cn(
878
+ "h-8 w-8 rounded-full ring-offset-2",
879
+ draft.color === color && "ring-2 ring-ph-brand",
880
+ )}
881
+ style={{ backgroundColor: color }}
882
+ />
883
+ ))}
884
+ <Input
885
+ hideLabel
886
+ label="Custom colour"
887
+ type="color"
888
+ value={draft.color}
889
+ onChange={(event) =>
890
+ setDraft({ ...draft, color: event.target.value })
891
+ }
892
+ className="h-8 w-8 p-0"
893
+ />
894
+ </div>
895
+ </div>
896
+ {editing?.kind.includes("project") ? (
897
+ <Select
898
+ label="Client"
899
+ value={draftClientId ?? ""}
900
+ onChange={(event) => setDraftClientId(event.target.value || null)}
901
+ >
902
+ <option value="">No client</option>
903
+ {clients.map((client) => (
904
+ <option key={client.id} value={client.id}>
905
+ {client.name}
906
+ </option>
907
+ ))}
908
+ </Select>
909
+ ) : null}
910
+ {editing?.kind.includes("project") ? (
911
+ <label className="flex items-center gap-2 text-sm text-ph-ink">
912
+ <input
913
+ type="checkbox"
914
+ checked={draftArchived}
915
+ onChange={(event) => setDraftArchived(event.target.checked)}
916
+ />
917
+ Archived
918
+ </label>
919
+ ) : null}
920
+ </div>
921
+ </Modal>
922
+ <Modal
923
+ open={confirming !== null}
924
+ onClose={() => setConfirming(null)}
925
+ title="Confirm deletion"
926
+ footer={
927
+ <div className="flex justify-end gap-2">
928
+ <Button variant="tertiary" onClick={() => setConfirming(null)}>
929
+ Cancel
930
+ </Button>
931
+ <Button variant="primary" onClick={() => void confirmRemove()}>
932
+ Delete
933
+ </Button>
934
+ </div>
935
+ }
936
+ >
937
+ <p className="text-sm text-ph-ink">
938
+ Delete {confirming?.item.name}? This cannot be undone.
939
+ </p>
940
+ </Modal>
941
+ </div>
942
+ );
943
+ }