@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.
- package/README.md +9 -10
- package/dist/{Chip-Cq3AiAji.d.mts → Chip-BzuNgJIa.d.mts} +81 -23
- package/dist/{Chip-Cq3AiAji.d.ts → Chip-BzuNgJIa.d.ts} +81 -23
- package/dist/{ai-message-blocknote-WABTHFY7.mjs → ai-message-blocknote-D4Z6DUCJ.mjs} +9 -3
- package/dist/chunk-2EO5VCXP.mjs +3912 -0
- package/dist/chunk-APKRZQRO.mjs +251 -0
- package/dist/chunk-BS7BZI5W.mjs +19 -0
- package/dist/chunk-IULORI3A.mjs +8 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +997 -576
- package/dist/index.mjs +49 -45
- package/dist/suite.d.mts +157 -55
- package/dist/suite.d.ts +157 -55
- package/dist/suite.js +4249 -801
- package/dist/suite.mjs +2578 -594
- package/dist/theme-boot-P-oqKVZF.d.mts +74 -0
- package/dist/theme-boot-P-oqKVZF.d.ts +74 -0
- package/dist/theme-boot.d.mts +2 -0
- package/dist/theme-boot.d.ts +2 -0
- package/dist/theme-boot.js +44 -0
- package/dist/theme-boot.mjs +8 -0
- package/dist/ui.d.mts +5 -29
- package/dist/ui.d.ts +5 -29
- package/dist/ui.js +647 -352
- package/dist/ui.mjs +10 -12
- package/docs/README.md +34 -0
- package/docs/ai-quick-reference.md +56 -0
- package/docs/component-library-improvement-plan.md +751 -0
- package/docs/component-reference.md +81 -0
- package/docs/components.md +48 -0
- package/docs/demos.md +19 -0
- package/docs/installation.md +117 -0
- package/docs/suite-audit.md +34 -0
- package/docs/theme-token-reference.md +67 -0
- package/docs/themes.md +164 -0
- package/package.json +22 -21
- package/public/fonts/OpenRunde-Bold.woff2 +0 -0
- package/public/fonts/OpenRunde-Medium.woff2 +0 -0
- package/public/fonts/OpenRunde-Regular.woff2 +0 -0
- package/public/fonts/OpenRunde-Semibold.woff2 +0 -0
- package/scripts/package-smoke.mjs +43 -0
- package/src/app/globals.css +2 -1
- package/src/components/ui/Accordion.tsx +20 -6
- package/src/components/ui/Avatar.tsx +5 -3
- package/src/components/ui/Calendar.tsx +187 -63
- package/src/components/ui/Card.test.tsx +38 -0
- package/src/components/ui/Card.tsx +77 -14
- package/src/components/ui/CollapsibleSection.tsx +12 -4
- package/src/components/ui/CommandPalette.test.tsx +91 -4
- package/src/components/ui/CommandPalette.tsx +138 -128
- package/src/components/ui/EmptyState.tsx +2 -2
- package/src/components/ui/FormField.tsx +1 -1
- package/src/components/ui/Input.tsx +42 -18
- package/src/components/ui/Kbd.tsx +18 -3
- package/src/components/ui/Modal.tsx +3 -1
- package/src/components/ui/Progress.tsx +14 -5
- package/src/components/ui/SegmentedControl.tsx +2 -2
- package/src/components/ui/SettingsLayout.tsx +1 -1
- package/src/components/ui/Stat.tsx +3 -2
- package/src/components/ui/Table.tsx +8 -3
- package/src/components/ui/Tooltip.test.tsx +70 -1
- package/src/components/ui/Tooltip.tsx +53 -29
- package/src/components/ui/Typography.tsx +103 -49
- package/src/components/ui/avatar-stat.test.tsx +23 -0
- package/src/components/ui/calendar.test.tsx +88 -0
- package/src/components/ui/disclosure.test.tsx +39 -0
- package/src/components/ui/index.ts +44 -6
- package/src/components/ui/progress.test.tsx +15 -0
- package/src/components/ui/typography.test.tsx +26 -1
- package/src/styles/suite-skin.css +8 -2
- package/src/styles/themes.css +184 -68
- package/src/suite/components/ai-message-blocknote.tsx +13 -2
- package/src/suite/components/ai-panel.tsx +41 -10
- package/src/suite/components/app-switcher.test.tsx +21 -6
- package/src/suite/components/app-switcher.tsx +7 -11
- package/src/suite/components/suite-app-layout.tsx +12 -3
- package/src/suite/components/suite-bottom-nav.tsx +0 -1
- package/src/suite/components/suite-clients-projects-directory.test.tsx +71 -0
- package/src/suite/components/suite-clients-projects-directory.tsx +943 -0
- package/src/suite/components/suite-integration-picker.test.tsx +224 -0
- package/src/suite/components/suite-integration-picker.tsx +939 -0
- package/src/suite/components/suite-integration-rules.test.tsx +157 -0
- package/src/suite/components/suite-integration-rules.tsx +471 -0
- package/src/suite/components/suite-mobile-drawer.test.tsx +18 -2
- package/src/suite/components/suite-mobile-drawer.tsx +65 -98
- package/src/suite/components/suite-notification-bell.tsx +29 -4
- package/src/suite/components/suite-sidebar.tsx +25 -25
- package/src/suite/components/suite-skeleton.tsx +2 -2
- package/src/suite/components/suite-user-menu.tsx +9 -8
- package/src/suite/components/theme-provider.test.tsx +87 -3
- package/src/suite/components/theme-provider.tsx +144 -23
- package/src/suite/components/today-ui.tsx +79 -68
- package/src/suite/components/workspace-switcher.test.tsx +56 -0
- package/src/suite/components/workspace-switcher.tsx +3 -3
- package/src/suite/icons/app-accents.ts +12 -12
- package/src/suite/icons/glyphs.ts +6 -12
- package/src/suite/index.ts +27 -0
- package/src/suite/lib/apps.test.ts +9 -1
- package/src/suite/lib/apps.ts +14 -8
- package/src/suite/lib/cookies.ts +53 -0
- package/src/suite/lib/theme-boot.ts +32 -0
- package/src/suite/lib/theme-cookie.ts +7 -0
- package/src/suite/lib/use-sidebar-width.ts +5 -51
- package/dist/chunk-KDR6HI6F.mjs +0 -1075
- package/dist/chunk-NOI42JNZ.mjs +0 -151
- package/dist/chunk-VXISSXTB.mjs +0 -2564
- package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
- 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
|
+
}
|