@xenide-io/the-old-ui-theme 0.9.9 → 0.9.11
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/dist/suite.d.mts +119 -2
- package/dist/suite.d.ts +119 -2
- package/dist/suite.js +858 -429
- package/dist/suite.mjs +823 -393
- package/package.json +1 -1
- package/src/suite/components/ai-panel.tsx +1 -10
- package/src/suite/components/suite-clients-projects-directory.test.tsx +5 -13
- package/src/suite/components/suite-clients-projects-directory.tsx +162 -199
- package/src/suite/components/suite-entity-icon.tsx +80 -0
- package/src/suite/components/suite-palette-library.tsx +434 -0
- package/src/suite/index.ts +17 -0
- package/src/suite/lib/palette-api.ts +143 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xenide-io/the-old-ui-theme",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.11",
|
|
4
4
|
"description": "Props-driven React components and theme tokens inspired by classic interface design — optimized for Next.js apps and internal tools.",
|
|
5
5
|
"author": "Xenide",
|
|
6
6
|
"license": "MIT",
|
|
@@ -865,7 +865,7 @@ export function SuiteAiPanel({
|
|
|
865
865
|
data-test="ask-ai-input"
|
|
866
866
|
className="max-h-36 min-h-10 flex-1 resize-none bg-transparent px-1.5 py-2 text-sm leading-relaxed text-ph-ink outline-none placeholder:text-ph-mutedtext"
|
|
867
867
|
/>
|
|
868
|
-
{
|
|
868
|
+
{waiting ? (
|
|
869
869
|
<Tooltip content="Stop generating">
|
|
870
870
|
<button
|
|
871
871
|
type="button"
|
|
@@ -877,15 +877,6 @@ export function SuiteAiPanel({
|
|
|
877
877
|
<Square className="h-3.5 w-3.5 fill-current" />
|
|
878
878
|
</button>
|
|
879
879
|
</Tooltip>
|
|
880
|
-
) : isWaitingForReply ? (
|
|
881
|
-
<span
|
|
882
|
-
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-ph-muted text-ph-subtle"
|
|
883
|
-
data-test="ask-ai-waiting"
|
|
884
|
-
role="status"
|
|
885
|
-
aria-label="Shelly is responding"
|
|
886
|
-
>
|
|
887
|
-
<Spinner className="h-4 w-4 animate-spin" />
|
|
888
|
-
</span>
|
|
889
880
|
) : (
|
|
890
881
|
<Tooltip content="Send message">
|
|
891
882
|
<button
|
|
@@ -6,7 +6,7 @@ import { SuiteClientsProjectsDirectory } from "@/suite/components/suite-clients-
|
|
|
6
6
|
describe("SuiteClientsProjectsDirectory", () => {
|
|
7
7
|
afterEach(cleanup);
|
|
8
8
|
|
|
9
|
-
it("
|
|
9
|
+
it("offers the Palette icon library instead of an RPG radiogroup", async () => {
|
|
10
10
|
const user = userEvent.setup();
|
|
11
11
|
|
|
12
12
|
render(
|
|
@@ -19,19 +19,11 @@ describe("SuiteClientsProjectsDirectory", () => {
|
|
|
19
19
|
);
|
|
20
20
|
|
|
21
21
|
await user.click(screen.getByRole("button", { name: "New client" }));
|
|
22
|
-
const castle = screen.getByRole("radio", { name: "Castle" });
|
|
23
|
-
const castleFlag = screen.getByRole("radio", { name: "Castle flag" });
|
|
24
22
|
|
|
25
|
-
expect(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
await user.keyboard("{ArrowRight}");
|
|
30
|
-
expect(castleFlag).toHaveAttribute("aria-checked", "true");
|
|
31
|
-
expect(castleFlag).toHaveFocus();
|
|
32
|
-
|
|
33
|
-
await user.keyboard("{Home}");
|
|
34
|
-
expect(screen.getByRole("radio", { name: "Arena" })).toHaveFocus();
|
|
23
|
+
expect(
|
|
24
|
+
screen.getByRole("button", { name: "Browse library" }),
|
|
25
|
+
).toBeInTheDocument();
|
|
26
|
+
expect(screen.queryByRole("radio")).toBeNull();
|
|
35
27
|
});
|
|
36
28
|
|
|
37
29
|
it("moves focus between the mobile client list and detail view", async () => {
|
|
@@ -6,12 +6,13 @@ import {
|
|
|
6
6
|
useRef,
|
|
7
7
|
useState,
|
|
8
8
|
type KeyboardEvent,
|
|
9
|
+
type ReactNode,
|
|
9
10
|
} from "react";
|
|
10
11
|
import {
|
|
11
12
|
ArrowLeft,
|
|
12
|
-
MediaImagePlus as ImagePlus,
|
|
13
13
|
MoreHoriz as MoreHorizontal,
|
|
14
14
|
Plus,
|
|
15
|
+
Prohibition,
|
|
15
16
|
Search,
|
|
16
17
|
Trash,
|
|
17
18
|
} from "iconoir-react";
|
|
@@ -26,6 +27,8 @@ import {
|
|
|
26
27
|
Select,
|
|
27
28
|
} from "@/components/ui";
|
|
28
29
|
import { cn } from "@/lib/cn";
|
|
30
|
+
import { SuiteEntityIcon } from "./suite-entity-icon";
|
|
31
|
+
import { SuitePaletteLibrary } from "./suite-palette-library";
|
|
29
32
|
|
|
30
33
|
export interface SuiteDirectoryClient {
|
|
31
34
|
id: string;
|
|
@@ -50,6 +53,8 @@ export interface SuiteDirectoryFields {
|
|
|
50
53
|
icon: string;
|
|
51
54
|
color: string;
|
|
52
55
|
iconImageFile?: File | null;
|
|
56
|
+
/** Icon chosen from a hosted library (e.g. Palette); wins over `iconImageFile`. */
|
|
57
|
+
iconImageUrl?: string | null;
|
|
53
58
|
clearIconImage?: boolean;
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -80,186 +85,81 @@ export interface SuiteClientsProjectsDirectoryProps {
|
|
|
80
85
|
fields: SuiteDirectoryProjectFields,
|
|
81
86
|
) => Promise<SuiteDirectoryProject | void>;
|
|
82
87
|
onDeleteProject?: (project: SuiteDirectoryProject) => Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Optional hosted icon library (e.g. Palette). When provided, the picker
|
|
90
|
+
* shows a "Browse library" button; the render prop receives `onPick`, which
|
|
91
|
+
* sets the chosen icon URL as the item's custom image.
|
|
92
|
+
*/
|
|
93
|
+
renderIconLibrary?: (args: {
|
|
94
|
+
onPick: (icon: { url: string; name?: string }) => void;
|
|
95
|
+
}) => ReactNode;
|
|
83
96
|
dataTest?: string;
|
|
84
97
|
}
|
|
85
98
|
|
|
86
|
-
const DEFAULT_ICON = "
|
|
99
|
+
const DEFAULT_ICON = "";
|
|
87
100
|
const DEFAULT_COLOR = "#6366f1";
|
|
88
101
|
|
|
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
102
|
function DirectoryIconPicker({
|
|
163
|
-
value,
|
|
164
103
|
preview,
|
|
104
|
+
label,
|
|
105
|
+
color,
|
|
165
106
|
onFileChange,
|
|
166
107
|
onClear,
|
|
167
|
-
|
|
108
|
+
onBrowse,
|
|
168
109
|
}: {
|
|
169
|
-
value: string;
|
|
170
110
|
preview?: string | null;
|
|
111
|
+
label?: string;
|
|
112
|
+
color?: string | null;
|
|
171
113
|
onFileChange: (file: File | null) => void;
|
|
172
114
|
onClear: () => void;
|
|
173
|
-
|
|
115
|
+
onBrowse?: () => void;
|
|
174
116
|
}) {
|
|
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
117
|
return (
|
|
198
118
|
<div className="space-y-2">
|
|
199
119
|
<span className="mb-1.5 block text-sm font-medium text-ph-ink">Icon</span>
|
|
200
|
-
<div
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
120
|
+
<div className="rounded-lg border border-ph-border bg-ph-muted p-3">
|
|
121
|
+
<div className="flex items-center gap-3">
|
|
122
|
+
<SuiteEntityIcon
|
|
123
|
+
imageUrl={preview}
|
|
124
|
+
label={label}
|
|
125
|
+
color={color}
|
|
126
|
+
tinted
|
|
127
|
+
className="h-9 w-9 text-base"
|
|
128
|
+
/>
|
|
129
|
+
<div className="min-w-0 flex-1">
|
|
130
|
+
<p className="text-sm font-medium text-ph-ink">
|
|
131
|
+
{preview ? "Custom image" : "No icon yet"}
|
|
132
|
+
</p>
|
|
133
|
+
<p className="text-xs text-ph-mutedtext">
|
|
134
|
+
{preview
|
|
135
|
+
? "Shown on cards and lists."
|
|
136
|
+
: "Browse the library or upload your own."}
|
|
137
|
+
</p>
|
|
138
|
+
</div>
|
|
139
|
+
{onBrowse ? (
|
|
208
140
|
<button
|
|
209
|
-
key={name}
|
|
210
141
|
type="button"
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
)}
|
|
142
|
+
onClick={onBrowse}
|
|
143
|
+
className="rounded-md border border-ph-border px-3 py-1.5 text-xs font-medium text-ph-ink hover:border-ph-brand hover:bg-ph-surface"
|
|
226
144
|
>
|
|
227
|
-
|
|
228
|
-
className={cn("ra", `ra-${name}`, "text-base leading-none")}
|
|
229
|
-
aria-hidden
|
|
230
|
-
/>
|
|
145
|
+
Browse library
|
|
231
146
|
</button>
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
147
|
+
) : null}
|
|
148
|
+
</div>
|
|
149
|
+
<div className="mt-3">
|
|
150
|
+
<FileUpload
|
|
151
|
+
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,.svg"
|
|
152
|
+
onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
|
|
153
|
+
/>
|
|
238
154
|
</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
155
|
{preview ? (
|
|
247
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
className="mt-2 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-ph-mutedtext hover:bg-ph-surface hover:text-ph-ink"
|
|
159
|
+
onClick={onClear}
|
|
160
|
+
>
|
|
161
|
+
<Trash className="h-3.5 w-3.5" aria-hidden /> Remove image
|
|
162
|
+
</button>
|
|
263
163
|
) : null}
|
|
264
164
|
</div>
|
|
265
165
|
</div>
|
|
@@ -271,27 +171,15 @@ function DirectoryIcon({
|
|
|
271
171
|
}: {
|
|
272
172
|
item: SuiteDirectoryClient | SuiteDirectoryProject;
|
|
273
173
|
}) {
|
|
274
|
-
const icon =
|
|
275
|
-
item.icon && isDirectoryIcon(item.icon) ? item.icon : DEFAULT_ICON;
|
|
276
174
|
return (
|
|
277
|
-
<
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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>
|
|
175
|
+
<SuiteEntityIcon
|
|
176
|
+
imageUrl={item.icon_image}
|
|
177
|
+
label={item.name}
|
|
178
|
+
color={item.color}
|
|
179
|
+
tinted
|
|
180
|
+
className="h-8 w-8 text-sm"
|
|
181
|
+
roundedClass="rounded-lg"
|
|
182
|
+
/>
|
|
295
183
|
);
|
|
296
184
|
}
|
|
297
185
|
|
|
@@ -319,6 +207,7 @@ export function SuiteClientsProjectsDirectory({
|
|
|
319
207
|
onCreateProject,
|
|
320
208
|
onUpdateProject,
|
|
321
209
|
onDeleteProject,
|
|
210
|
+
renderIconLibrary,
|
|
322
211
|
dataTest = "suite-clients-projects-directory",
|
|
323
212
|
}: SuiteClientsProjectsDirectoryProps) {
|
|
324
213
|
const [query, setQuery] = useState("");
|
|
@@ -332,6 +221,7 @@ export function SuiteClientsProjectsDirectory({
|
|
|
332
221
|
});
|
|
333
222
|
const [draftClientId, setDraftClientId] = useState<string | null>(null);
|
|
334
223
|
const [preview, setPreview] = useState<string | null>(null);
|
|
224
|
+
const [libraryOpen, setLibraryOpen] = useState(false);
|
|
335
225
|
const [draftArchived, setDraftArchived] = useState(false);
|
|
336
226
|
const [confirming, setConfirming] = useState<Confirming | null>(null);
|
|
337
227
|
const [saving, setSaving] = useState(false);
|
|
@@ -411,6 +301,7 @@ export function SuiteClientsProjectsDirectory({
|
|
|
411
301
|
icon: DEFAULT_ICON,
|
|
412
302
|
color: DEFAULT_COLOR,
|
|
413
303
|
iconImageFile: null,
|
|
304
|
+
iconImageUrl: null,
|
|
414
305
|
clearIconImage: false,
|
|
415
306
|
});
|
|
416
307
|
setPreview(null);
|
|
@@ -426,9 +317,10 @@ export function SuiteClientsProjectsDirectory({
|
|
|
426
317
|
setMutationError(null);
|
|
427
318
|
setDraft({
|
|
428
319
|
name: item.name,
|
|
429
|
-
icon: item.icon
|
|
320
|
+
icon: item.icon ?? DEFAULT_ICON,
|
|
430
321
|
color: item.color || DEFAULT_COLOR,
|
|
431
322
|
iconImageFile: null,
|
|
323
|
+
iconImageUrl: null,
|
|
432
324
|
clearIconImage: false,
|
|
433
325
|
});
|
|
434
326
|
setPreview(item.icon_image || null);
|
|
@@ -454,6 +346,7 @@ export function SuiteClientsProjectsDirectory({
|
|
|
454
346
|
setDraft((current) => ({
|
|
455
347
|
...current,
|
|
456
348
|
iconImageFile: file,
|
|
349
|
+
iconImageUrl: null,
|
|
457
350
|
clearIconImage: false,
|
|
458
351
|
}));
|
|
459
352
|
setPreview(URL.createObjectURL(file));
|
|
@@ -465,10 +358,24 @@ export function SuiteClientsProjectsDirectory({
|
|
|
465
358
|
setDraft((current) => ({
|
|
466
359
|
...current,
|
|
467
360
|
iconImageFile: null,
|
|
361
|
+
iconImageUrl: null,
|
|
468
362
|
clearIconImage: true,
|
|
469
363
|
}));
|
|
470
364
|
}
|
|
471
365
|
|
|
366
|
+
function pickFromLibrary(icon: { url: string; name?: string }) {
|
|
367
|
+
if (preview?.startsWith("blob:")) URL.revokeObjectURL(preview);
|
|
368
|
+
setMutationError(null);
|
|
369
|
+
setPreview(icon.url);
|
|
370
|
+
setDraft((current) => ({
|
|
371
|
+
...current,
|
|
372
|
+
iconImageFile: null,
|
|
373
|
+
iconImageUrl: icon.url,
|
|
374
|
+
clearIconImage: false,
|
|
375
|
+
}));
|
|
376
|
+
setLibraryOpen(false);
|
|
377
|
+
}
|
|
378
|
+
|
|
472
379
|
function closeEditor() {
|
|
473
380
|
if (!saving) setEditing(null);
|
|
474
381
|
}
|
|
@@ -516,10 +423,7 @@ export function SuiteClientsProjectsDirectory({
|
|
|
516
423
|
try {
|
|
517
424
|
await onUpdateProject(project, {
|
|
518
425
|
name: project.name,
|
|
519
|
-
icon:
|
|
520
|
-
project.icon && isDirectoryIcon(project.icon)
|
|
521
|
-
? project.icon
|
|
522
|
-
: DEFAULT_ICON,
|
|
426
|
+
icon: project.icon ?? DEFAULT_ICON,
|
|
523
427
|
color: project.color || DEFAULT_COLOR,
|
|
524
428
|
clientId: project.clientId ?? null,
|
|
525
429
|
isArchived: !project.isArchived,
|
|
@@ -845,20 +749,31 @@ export function SuiteClientsProjectsDirectory({
|
|
|
845
749
|
}
|
|
846
750
|
/>
|
|
847
751
|
<DirectoryIconPicker
|
|
848
|
-
value={isDirectoryIcon(draft.icon) ? draft.icon : DEFAULT_ICON}
|
|
849
752
|
preview={preview}
|
|
753
|
+
label={draft.name}
|
|
754
|
+
color={draft.color}
|
|
850
755
|
onFileChange={chooseImage}
|
|
851
756
|
onClear={clearImage}
|
|
852
|
-
|
|
853
|
-
if (preview) clearImage();
|
|
854
|
-
setDraft((current) => ({ ...current, icon }));
|
|
855
|
-
}}
|
|
757
|
+
onBrowse={() => setLibraryOpen(true)}
|
|
856
758
|
/>
|
|
857
759
|
<div>
|
|
858
760
|
<span className="mb-1.5 block text-sm font-medium text-ph-ink">
|
|
859
761
|
Colour
|
|
860
762
|
</span>
|
|
861
|
-
<div className="flex flex-wrap gap-2">
|
|
763
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
764
|
+
<button
|
|
765
|
+
type="button"
|
|
766
|
+
aria-label="No colour"
|
|
767
|
+
aria-pressed={!draft.color}
|
|
768
|
+
title="No colour"
|
|
769
|
+
onClick={() => setDraft({ ...draft, color: "" })}
|
|
770
|
+
className={cn(
|
|
771
|
+
"flex h-8 w-8 items-center justify-center rounded-full border border-ph-border bg-ph-surface text-ph-mutedtext ring-offset-2",
|
|
772
|
+
!draft.color && "ring-2 ring-ph-brand",
|
|
773
|
+
)}
|
|
774
|
+
>
|
|
775
|
+
<Prohibition className="h-4 w-4" aria-hidden />
|
|
776
|
+
</button>
|
|
862
777
|
{[
|
|
863
778
|
"#6366f1",
|
|
864
779
|
"#0ea5e9",
|
|
@@ -881,16 +796,53 @@ export function SuiteClientsProjectsDirectory({
|
|
|
881
796
|
style={{ backgroundColor: color }}
|
|
882
797
|
/>
|
|
883
798
|
))}
|
|
884
|
-
<
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
799
|
+
<label
|
|
800
|
+
className={cn(
|
|
801
|
+
"relative inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-full ring-1 ring-ph-border ring-offset-2",
|
|
802
|
+
![
|
|
803
|
+
"#6366f1",
|
|
804
|
+
"#0ea5e9",
|
|
805
|
+
"#10b981",
|
|
806
|
+
"#f59e0b",
|
|
807
|
+
"#ef4444",
|
|
808
|
+
"#a855f7",
|
|
809
|
+
"#64748b",
|
|
810
|
+
].includes(draft.color) && "ring-2 ring-ph-brand",
|
|
811
|
+
)}
|
|
812
|
+
style={{
|
|
813
|
+
background:
|
|
814
|
+
"conic-gradient(from 0deg, #ef4444, #f59e0b, #eab308, #22c55e, #06b6d4, #3b82f6, #8b5cf6, #ec4899, #ef4444)",
|
|
815
|
+
}}
|
|
816
|
+
title="Custom colour"
|
|
817
|
+
>
|
|
818
|
+
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-ph-surface">
|
|
819
|
+
{[
|
|
820
|
+
"#6366f1",
|
|
821
|
+
"#0ea5e9",
|
|
822
|
+
"#10b981",
|
|
823
|
+
"#f59e0b",
|
|
824
|
+
"#ef4444",
|
|
825
|
+
"#a855f7",
|
|
826
|
+
"#64748b",
|
|
827
|
+
].includes(draft.color) ? (
|
|
828
|
+
<Plus className="h-3 w-3 text-ph-ink" aria-hidden />
|
|
829
|
+
) : (
|
|
830
|
+
<span
|
|
831
|
+
className="h-2.5 w-2.5 rounded-full"
|
|
832
|
+
style={{ backgroundColor: draft.color }}
|
|
833
|
+
/>
|
|
834
|
+
)}
|
|
835
|
+
</span>
|
|
836
|
+
<input
|
|
837
|
+
type="color"
|
|
838
|
+
aria-label="Custom colour"
|
|
839
|
+
value={draft.color}
|
|
840
|
+
onChange={(event) =>
|
|
841
|
+
setDraft({ ...draft, color: event.target.value })
|
|
842
|
+
}
|
|
843
|
+
className="sr-only"
|
|
844
|
+
/>
|
|
845
|
+
</label>
|
|
894
846
|
</div>
|
|
895
847
|
</div>
|
|
896
848
|
{editing?.kind.includes("project") ? (
|
|
@@ -919,6 +871,17 @@ export function SuiteClientsProjectsDirectory({
|
|
|
919
871
|
) : null}
|
|
920
872
|
</div>
|
|
921
873
|
</Modal>
|
|
874
|
+
{libraryOpen ? (
|
|
875
|
+
<Modal
|
|
876
|
+
open
|
|
877
|
+
onClose={() => setLibraryOpen(false)}
|
|
878
|
+
title="Icon library"
|
|
879
|
+
>
|
|
880
|
+
{renderIconLibrary
|
|
881
|
+
? renderIconLibrary({ onPick: pickFromLibrary })
|
|
882
|
+
: <SuitePaletteLibrary onPick={pickFromLibrary} />}
|
|
883
|
+
</Modal>
|
|
884
|
+
) : null}
|
|
922
885
|
<Modal
|
|
923
886
|
open={confirming !== null}
|
|
924
887
|
onClose={() => setConfirming(null)}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties } from 'react';
|
|
4
|
+
import { paletteIconSrc } from '@/suite/lib/palette-api';
|
|
5
|
+
import { cn } from '@/lib/cn';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A client/project icon.
|
|
9
|
+
*
|
|
10
|
+
* Shows the stored image when there is one; otherwise a neutral placeholder —
|
|
11
|
+
* a tinted rounded square with the entity's initial, or a small dot when no
|
|
12
|
+
* label is available. No icon font is involved.
|
|
13
|
+
*/
|
|
14
|
+
export function SuiteEntityIcon({
|
|
15
|
+
imageUrl,
|
|
16
|
+
label,
|
|
17
|
+
color,
|
|
18
|
+
className,
|
|
19
|
+
roundedClass = 'rounded-md',
|
|
20
|
+
style,
|
|
21
|
+
tinted = false,
|
|
22
|
+
}: {
|
|
23
|
+
imageUrl?: string | null;
|
|
24
|
+
label?: string | null;
|
|
25
|
+
color?: string | null;
|
|
26
|
+
className?: string;
|
|
27
|
+
roundedClass?: string;
|
|
28
|
+
style?: CSSProperties;
|
|
29
|
+
/** Recolour a monochrome icon to `color` (via CSS mask). */
|
|
30
|
+
tinted?: boolean;
|
|
31
|
+
}) {
|
|
32
|
+
const hasColor =
|
|
33
|
+
typeof color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(color);
|
|
34
|
+
const tint = hasColor ? (color as string) : null;
|
|
35
|
+
const neutralBackground = 'var(--ph-muted, rgba(127,127,127,0.08))';
|
|
36
|
+
|
|
37
|
+
if (imageUrl) {
|
|
38
|
+
const src = tinted && tint ? paletteIconSrc(imageUrl, tint) : imageUrl;
|
|
39
|
+
return (
|
|
40
|
+
<span
|
|
41
|
+
className={cn(
|
|
42
|
+
'inline-flex shrink-0 items-center justify-center overflow-hidden leading-none',
|
|
43
|
+
roundedClass,
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
style={{
|
|
47
|
+
backgroundColor: tint ? `${tint}18` : neutralBackground,
|
|
48
|
+
...style,
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
52
|
+
<img src={src ?? undefined} alt="" className="h-full w-full object-cover" />
|
|
53
|
+
</span>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const initial = label?.trim().charAt(0).toUpperCase();
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<span
|
|
61
|
+
className={cn(
|
|
62
|
+
'inline-flex shrink-0 items-center justify-center font-semibold leading-none',
|
|
63
|
+
roundedClass,
|
|
64
|
+
className,
|
|
65
|
+
)}
|
|
66
|
+
style={{
|
|
67
|
+
color: tint ?? 'var(--ph-mutedtext)',
|
|
68
|
+
backgroundColor: tint ? `${tint}18` : neutralBackground,
|
|
69
|
+
...style,
|
|
70
|
+
}}
|
|
71
|
+
aria-hidden="true"
|
|
72
|
+
>
|
|
73
|
+
{initial ? (
|
|
74
|
+
<span className="text-[0.7em]">{initial}</span>
|
|
75
|
+
) : (
|
|
76
|
+
<span className="block h-1.5 w-1.5 rounded-full bg-current opacity-40" />
|
|
77
|
+
)}
|
|
78
|
+
</span>
|
|
79
|
+
);
|
|
80
|
+
}
|