@xenide-io/the-old-ui-theme 0.9.10 → 0.9.12
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 +856 -418
- package/dist/suite.mjs +821 -382
- package/package.json +1 -1
- package/src/suite/components/suite-clients-projects-directory.test.tsx +5 -13
- package/src/suite/components/suite-clients-projects-directory.tsx +163 -200
- 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
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { Filter, FilterList, NavArrowDown, Search } from 'iconoir-react';
|
|
12
|
+
import {
|
|
13
|
+
fetchPaletteCollection,
|
|
14
|
+
fetchPaletteLibrary,
|
|
15
|
+
findPaletteIcons,
|
|
16
|
+
type PaletteCollection,
|
|
17
|
+
type PaletteIcon,
|
|
18
|
+
} from '@/suite/lib/palette-api';
|
|
19
|
+
import { cn } from '@/lib/cn';
|
|
20
|
+
|
|
21
|
+
const PAGE_SIZE = 60;
|
|
22
|
+
const DISCOVER_LIMIT = 120;
|
|
23
|
+
|
|
24
|
+
const ICON_TYPES = [
|
|
25
|
+
{ value: 'all', label: 'All types' },
|
|
26
|
+
{ value: 'line', label: 'Line' },
|
|
27
|
+
{ value: 'solid', label: 'Solid' },
|
|
28
|
+
{ value: 'duotone', label: 'Duotone' },
|
|
29
|
+
{ value: 'color', label: 'Colour' },
|
|
30
|
+
{ value: 'mixed', label: 'Mixed' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export interface SuitePalettePick {
|
|
34
|
+
url: string;
|
|
35
|
+
name?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function FilterSelect({
|
|
39
|
+
icon: Icon,
|
|
40
|
+
label,
|
|
41
|
+
value,
|
|
42
|
+
onChange,
|
|
43
|
+
children,
|
|
44
|
+
}: {
|
|
45
|
+
icon: typeof Filter;
|
|
46
|
+
label: string;
|
|
47
|
+
value: string;
|
|
48
|
+
onChange: (value: string) => void;
|
|
49
|
+
children: ReactNode;
|
|
50
|
+
}) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="relative">
|
|
53
|
+
<Icon
|
|
54
|
+
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ph-mutedtext"
|
|
55
|
+
aria-hidden
|
|
56
|
+
/>
|
|
57
|
+
<select
|
|
58
|
+
aria-label={label}
|
|
59
|
+
value={value}
|
|
60
|
+
onChange={(event) => onChange(event.target.value)}
|
|
61
|
+
className="h-full w-full appearance-none rounded-lg border border-ph-border bg-ph-surface py-2 pl-8 pr-7 text-sm text-ph-ink focus:border-ph-brand focus:outline-none"
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
</select>
|
|
65
|
+
<NavArrowDown
|
|
66
|
+
className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ph-mutedtext"
|
|
67
|
+
aria-hidden
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Browse and search the Palette icon library: a search box plus a library
|
|
75
|
+
* filter and an icon-type filter. Searching is global; the filters narrow it.
|
|
76
|
+
* Drop it in a modal and use `onPick` to receive the chosen icon URL.
|
|
77
|
+
*/
|
|
78
|
+
export function SuitePaletteLibrary({
|
|
79
|
+
onPick,
|
|
80
|
+
}: {
|
|
81
|
+
onPick: (icon: SuitePalettePick) => void;
|
|
82
|
+
}) {
|
|
83
|
+
const [collections, setCollections] = useState<PaletteCollection[]>([]);
|
|
84
|
+
const [typeFilter, setTypeFilter] = useState('all');
|
|
85
|
+
const [collectionFilter, setCollectionFilter] = useState('');
|
|
86
|
+
const [libraryOpen, setLibraryOpen] = useState(false);
|
|
87
|
+
const [libraryQuery, setLibraryQuery] = useState('');
|
|
88
|
+
const libraryRef = useRef<HTMLDivElement>(null);
|
|
89
|
+
const [icons, setIcons] = useState<PaletteIcon[]>([]);
|
|
90
|
+
const [total, setTotal] = useState(0);
|
|
91
|
+
const [page, setPage] = useState(1);
|
|
92
|
+
const [query, setQuery] = useState('');
|
|
93
|
+
const [loading, setLoading] = useState(true);
|
|
94
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
95
|
+
const [error, setError] = useState<string | null>(null);
|
|
96
|
+
const requestId = useRef(0);
|
|
97
|
+
|
|
98
|
+
const libraryOptions = useMemo(() => {
|
|
99
|
+
const list = collections.filter(
|
|
100
|
+
(item) => typeFilter === 'all' || item.style === typeFilter,
|
|
101
|
+
);
|
|
102
|
+
return [...list].sort(
|
|
103
|
+
(a, b) => Number(b.featured) - Number(a.featured) || a.name.localeCompare(b.name),
|
|
104
|
+
);
|
|
105
|
+
}, [collections, typeFilter]);
|
|
106
|
+
|
|
107
|
+
// A pleasant default when browsing "All libraries" with no search.
|
|
108
|
+
const discoverIcons = useMemo<PaletteIcon[]>(() => {
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
const out: PaletteIcon[] = [];
|
|
111
|
+
for (const collection of libraryOptions) {
|
|
112
|
+
for (const url of collection.preview) {
|
|
113
|
+
if (seen.has(url)) continue;
|
|
114
|
+
seen.add(url);
|
|
115
|
+
out.push({ id: url, name: collection.name, tags: [], url });
|
|
116
|
+
if (out.length >= DISCOVER_LIMIT) return out;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}, [libraryOptions]);
|
|
121
|
+
|
|
122
|
+
const loadCollection = useCallback(async (slug: string, nextPage = 1) => {
|
|
123
|
+
const id = ++requestId.current;
|
|
124
|
+
if (nextPage === 1) {
|
|
125
|
+
setLoading(true);
|
|
126
|
+
setIcons([]);
|
|
127
|
+
} else {
|
|
128
|
+
setLoadingMore(true);
|
|
129
|
+
}
|
|
130
|
+
setError(null);
|
|
131
|
+
try {
|
|
132
|
+
const detail = await fetchPaletteCollection(slug, {
|
|
133
|
+
page: nextPage,
|
|
134
|
+
size: PAGE_SIZE,
|
|
135
|
+
});
|
|
136
|
+
if (id !== requestId.current) return;
|
|
137
|
+
setTotal(detail.count);
|
|
138
|
+
setPage(nextPage);
|
|
139
|
+
setIcons((current) =>
|
|
140
|
+
nextPage === 1 ? detail.icons : [...current, ...detail.icons],
|
|
141
|
+
);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (id === requestId.current) {
|
|
144
|
+
setError(err instanceof Error ? err.message : 'Could not load icons.');
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
if (id === requestId.current) {
|
|
148
|
+
setLoading(false);
|
|
149
|
+
setLoadingMore(false);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
let cancelled = false;
|
|
156
|
+
fetchPaletteLibrary()
|
|
157
|
+
.then((library) => {
|
|
158
|
+
if (cancelled) return;
|
|
159
|
+
setCollections(library.collections);
|
|
160
|
+
})
|
|
161
|
+
.catch((err) => {
|
|
162
|
+
if (cancelled) return;
|
|
163
|
+
setError(err instanceof Error ? err.message : 'Could not load the library.');
|
|
164
|
+
setLoading(false);
|
|
165
|
+
});
|
|
166
|
+
return () => {
|
|
167
|
+
cancelled = true;
|
|
168
|
+
};
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
// Drop a library selection that no longer matches the type filter.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!collections.length) return;
|
|
174
|
+
if (collectionFilter && !libraryOptions.some((c) => c.slug === collectionFilter)) {
|
|
175
|
+
setCollectionFilter('');
|
|
176
|
+
}
|
|
177
|
+
}, [libraryOptions, collectionFilter, collections.length]);
|
|
178
|
+
|
|
179
|
+
// Browse: one library, or the discover grid for "All libraries".
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (query.trim()) return;
|
|
182
|
+
if (collectionFilter) {
|
|
183
|
+
void loadCollection(collectionFilter, 1);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
requestId.current += 1;
|
|
187
|
+
setLoading(false);
|
|
188
|
+
setPage(1);
|
|
189
|
+
setIcons(discoverIcons);
|
|
190
|
+
setTotal(discoverIcons.length);
|
|
191
|
+
}, [collectionFilter, query, loadCollection, discoverIcons]);
|
|
192
|
+
|
|
193
|
+
// Search across the whole library, scoped by the active filters.
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
const term = query.trim();
|
|
196
|
+
if (!term) return;
|
|
197
|
+
const id = ++requestId.current;
|
|
198
|
+
setLoading(true);
|
|
199
|
+
setError(null);
|
|
200
|
+
const handle = setTimeout(async () => {
|
|
201
|
+
try {
|
|
202
|
+
const results = await findPaletteIcons({
|
|
203
|
+
q: term,
|
|
204
|
+
collection: collectionFilter || undefined,
|
|
205
|
+
style: typeFilter === 'all' ? undefined : typeFilter,
|
|
206
|
+
limit: 120,
|
|
207
|
+
});
|
|
208
|
+
if (id !== requestId.current) return;
|
|
209
|
+
setTotal(results.total);
|
|
210
|
+
setPage(1);
|
|
211
|
+
setIcons(
|
|
212
|
+
results.results.map((hit) => ({
|
|
213
|
+
id: `${hit.collection}/${hit.id}`,
|
|
214
|
+
name: hit.name,
|
|
215
|
+
tags: hit.tags,
|
|
216
|
+
url: hit.url,
|
|
217
|
+
})),
|
|
218
|
+
);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (id === requestId.current) {
|
|
221
|
+
setError(err instanceof Error ? err.message : 'Search failed.');
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
if (id === requestId.current) setLoading(false);
|
|
225
|
+
}
|
|
226
|
+
}, 250);
|
|
227
|
+
return () => clearTimeout(handle);
|
|
228
|
+
}, [query, collectionFilter, typeFilter]);
|
|
229
|
+
|
|
230
|
+
const searching = Boolean(query.trim());
|
|
231
|
+
const activeLibrary = collections.find((c) => c.slug === collectionFilter) ?? null;
|
|
232
|
+
|
|
233
|
+
const filteredLibraries = useMemo(() => {
|
|
234
|
+
const term = libraryQuery.trim().toLowerCase();
|
|
235
|
+
if (!term) return libraryOptions;
|
|
236
|
+
return libraryOptions.filter((c) => c.name.toLowerCase().includes(term));
|
|
237
|
+
}, [libraryOptions, libraryQuery]);
|
|
238
|
+
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (!libraryOpen) return;
|
|
241
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
242
|
+
if (!libraryRef.current?.contains(event.target as Node)) setLibraryOpen(false);
|
|
243
|
+
};
|
|
244
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
245
|
+
return () => document.removeEventListener('pointerdown', onPointerDown);
|
|
246
|
+
}, [libraryOpen]);
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div className="space-y-3" data-test="palette-icon-library">
|
|
250
|
+
<div ref={libraryRef} className="space-y-2">
|
|
251
|
+
<div className="flex flex-col gap-2 sm:flex-row">
|
|
252
|
+
<div className="relative flex-1">
|
|
253
|
+
<Search
|
|
254
|
+
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ph-mutedtext"
|
|
255
|
+
aria-hidden
|
|
256
|
+
/>
|
|
257
|
+
<input
|
|
258
|
+
type="search"
|
|
259
|
+
value={query}
|
|
260
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
261
|
+
placeholder="Search icons — handshake, rocket, briefcase…"
|
|
262
|
+
aria-label="Search icons"
|
|
263
|
+
className="w-full rounded-lg border border-ph-border bg-ph-surface py-2 pl-8 pr-3 text-sm text-ph-ink placeholder:text-ph-mutedtext focus:border-ph-brand focus:outline-none"
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
aria-haspopup="listbox"
|
|
269
|
+
aria-expanded={libraryOpen}
|
|
270
|
+
onClick={() => setLibraryOpen((open) => !open)}
|
|
271
|
+
className="flex items-center gap-2 rounded-lg border border-ph-border bg-ph-surface px-2.5 py-2 text-sm text-ph-ink hover:border-ph-brand focus:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
|
|
272
|
+
>
|
|
273
|
+
<FilterList className="h-4 w-4 shrink-0 text-ph-mutedtext" aria-hidden />
|
|
274
|
+
<span className="max-w-[10rem] truncate">
|
|
275
|
+
{activeLibrary?.name ?? "All libraries"}
|
|
276
|
+
</span>
|
|
277
|
+
<NavArrowDown
|
|
278
|
+
className={cn(
|
|
279
|
+
"h-3.5 w-3.5 shrink-0 text-ph-mutedtext transition-transform",
|
|
280
|
+
libraryOpen && "rotate-180",
|
|
281
|
+
)}
|
|
282
|
+
aria-hidden
|
|
283
|
+
/>
|
|
284
|
+
</button>
|
|
285
|
+
<FilterSelect
|
|
286
|
+
icon={Filter}
|
|
287
|
+
label="Filter by icon type"
|
|
288
|
+
value={typeFilter}
|
|
289
|
+
onChange={setTypeFilter}
|
|
290
|
+
>
|
|
291
|
+
{ICON_TYPES.map((type) => (
|
|
292
|
+
<option key={type.value} value={type.value}>
|
|
293
|
+
{type.label}
|
|
294
|
+
</option>
|
|
295
|
+
))}
|
|
296
|
+
</FilterSelect>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{libraryOpen ? (
|
|
300
|
+
<div className="rounded-lg border border-ph-border bg-ph-surface p-2">
|
|
301
|
+
<div className="relative mb-2">
|
|
302
|
+
<Search
|
|
303
|
+
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ph-mutedtext"
|
|
304
|
+
aria-hidden
|
|
305
|
+
/>
|
|
306
|
+
<input
|
|
307
|
+
type="search"
|
|
308
|
+
autoFocus
|
|
309
|
+
value={libraryQuery}
|
|
310
|
+
onChange={(event) => setLibraryQuery(event.target.value)}
|
|
311
|
+
placeholder="Search libraries…"
|
|
312
|
+
aria-label="Search libraries"
|
|
313
|
+
className="w-full rounded-md border border-ph-border bg-ph-muted py-1.5 pl-8 pr-3 text-sm text-ph-ink placeholder:text-ph-mutedtext focus:border-ph-brand focus:outline-none"
|
|
314
|
+
/>
|
|
315
|
+
</div>
|
|
316
|
+
<ul
|
|
317
|
+
role="listbox"
|
|
318
|
+
aria-label="Libraries"
|
|
319
|
+
className="max-h-56 overflow-y-auto"
|
|
320
|
+
>
|
|
321
|
+
<li>
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
role="option"
|
|
325
|
+
aria-selected={!collectionFilter}
|
|
326
|
+
onClick={() => {
|
|
327
|
+
setCollectionFilter("");
|
|
328
|
+
setLibraryOpen(false);
|
|
329
|
+
}}
|
|
330
|
+
className={cn(
|
|
331
|
+
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm",
|
|
332
|
+
!collectionFilter
|
|
333
|
+
? "bg-ph-muted text-ph-ink"
|
|
334
|
+
: "text-ph-ink hover:bg-ph-muted",
|
|
335
|
+
)}
|
|
336
|
+
>
|
|
337
|
+
All libraries
|
|
338
|
+
</button>
|
|
339
|
+
</li>
|
|
340
|
+
{filteredLibraries.map((collection) => (
|
|
341
|
+
<li key={collection.slug}>
|
|
342
|
+
<button
|
|
343
|
+
type="button"
|
|
344
|
+
role="option"
|
|
345
|
+
aria-selected={collectionFilter === collection.slug}
|
|
346
|
+
onClick={() => {
|
|
347
|
+
setCollectionFilter(collection.slug);
|
|
348
|
+
setLibraryOpen(false);
|
|
349
|
+
}}
|
|
350
|
+
className={cn(
|
|
351
|
+
"flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm",
|
|
352
|
+
collectionFilter === collection.slug
|
|
353
|
+
? "bg-ph-muted text-ph-ink"
|
|
354
|
+
: "text-ph-ink hover:bg-ph-muted",
|
|
355
|
+
)}
|
|
356
|
+
>
|
|
357
|
+
<span className="truncate">{collection.name}</span>
|
|
358
|
+
<span className="shrink-0 text-xs text-ph-mutedtext">
|
|
359
|
+
{collection.count}
|
|
360
|
+
</span>
|
|
361
|
+
</button>
|
|
362
|
+
</li>
|
|
363
|
+
))}
|
|
364
|
+
{filteredLibraries.length === 0 ? (
|
|
365
|
+
<li className="px-2 py-4 text-center text-sm text-ph-mutedtext">
|
|
366
|
+
No libraries match.
|
|
367
|
+
</li>
|
|
368
|
+
) : null}
|
|
369
|
+
</ul>
|
|
370
|
+
</div>
|
|
371
|
+
) : null}
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
{error ? (
|
|
375
|
+
<p className="text-sm text-ph-danger">{error}</p>
|
|
376
|
+
) : loading ? (
|
|
377
|
+
<p className="py-8 text-center text-sm text-ph-mutedtext">Loading icons…</p>
|
|
378
|
+
) : icons.length === 0 ? (
|
|
379
|
+
<p className="py-8 text-center text-sm text-ph-mutedtext">
|
|
380
|
+
No icons matched. Try another word or filter.
|
|
381
|
+
</p>
|
|
382
|
+
) : (
|
|
383
|
+
<>
|
|
384
|
+
<div className="max-h-[320px] overflow-y-auto rounded-lg border border-ph-border p-2">
|
|
385
|
+
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8">
|
|
386
|
+
{icons.map((icon) => (
|
|
387
|
+
<button
|
|
388
|
+
key={icon.id}
|
|
389
|
+
type="button"
|
|
390
|
+
onClick={() => onPick({ url: icon.url, name: icon.name })}
|
|
391
|
+
title={icon.name}
|
|
392
|
+
aria-label={`Use ${icon.name}`}
|
|
393
|
+
className="flex h-10 items-center justify-center rounded-md border border-transparent p-1.5 hover:border-ph-brand hover:bg-ph-muted"
|
|
394
|
+
>
|
|
395
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
396
|
+
<img
|
|
397
|
+
src={icon.url}
|
|
398
|
+
alt=""
|
|
399
|
+
loading="lazy"
|
|
400
|
+
decoding="async"
|
|
401
|
+
className="h-6 w-6"
|
|
402
|
+
/>
|
|
403
|
+
</button>
|
|
404
|
+
))}
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
<div className="flex items-center justify-between text-xs text-ph-mutedtext">
|
|
408
|
+
<span>
|
|
409
|
+
{icons.length} of {total}
|
|
410
|
+
{searching
|
|
411
|
+
? ' matching'
|
|
412
|
+
: activeLibrary
|
|
413
|
+
? ` in ${activeLibrary.name}`
|
|
414
|
+
: ' across all libraries'}
|
|
415
|
+
</span>
|
|
416
|
+
{!searching && activeLibrary && icons.length < total ? (
|
|
417
|
+
<button
|
|
418
|
+
type="button"
|
|
419
|
+
disabled={loadingMore}
|
|
420
|
+
onClick={() => void loadCollection(activeLibrary.slug, page + 1)}
|
|
421
|
+
className={cn(
|
|
422
|
+
'font-medium text-ph-brand hover:underline',
|
|
423
|
+
loadingMore && 'opacity-50',
|
|
424
|
+
)}
|
|
425
|
+
>
|
|
426
|
+
{loadingMore ? 'Loading…' : 'Load more'}
|
|
427
|
+
</button>
|
|
428
|
+
) : null}
|
|
429
|
+
</div>
|
|
430
|
+
</>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
);
|
|
434
|
+
}
|
package/src/suite/index.ts
CHANGED
|
@@ -174,3 +174,20 @@ export {
|
|
|
174
174
|
type SuiteDirectoryProject,
|
|
175
175
|
type SuiteDirectoryProjectFields,
|
|
176
176
|
} from "./components/suite-clients-projects-directory";
|
|
177
|
+
export {
|
|
178
|
+
SuiteEntityIcon,
|
|
179
|
+
} from "./components/suite-entity-icon";
|
|
180
|
+
export {
|
|
181
|
+
SuitePaletteLibrary,
|
|
182
|
+
type SuitePalettePick,
|
|
183
|
+
} from "./components/suite-palette-library";
|
|
184
|
+
export {
|
|
185
|
+
fetchPaletteLibrary,
|
|
186
|
+
fetchPaletteCollection,
|
|
187
|
+
findPaletteIcons,
|
|
188
|
+
type PaletteCollection,
|
|
189
|
+
type PaletteCollectionDetail,
|
|
190
|
+
type PaletteHit,
|
|
191
|
+
type PaletteIcon,
|
|
192
|
+
type PaletteLibrary,
|
|
193
|
+
} from "./lib/palette-api";
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette — the shared icon library client.
|
|
3
|
+
*
|
|
4
|
+
* Points at the standalone `apps/svgrepo-api` service. Icons are plain SVGs on
|
|
5
|
+
* the public media host, so a picked icon is just a URL to store as
|
|
6
|
+
* `icon_image`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const PALETTE_URL = (
|
|
10
|
+
process.env.NEXT_PUBLIC_PALETTE_URL ?? 'http://localhost:8010'
|
|
11
|
+
).replace(/\/$/, '');
|
|
12
|
+
|
|
13
|
+
export interface PaletteCollection {
|
|
14
|
+
slug: string;
|
|
15
|
+
name: string;
|
|
16
|
+
blurb: string;
|
|
17
|
+
category: string;
|
|
18
|
+
style: string;
|
|
19
|
+
count: number;
|
|
20
|
+
license: string;
|
|
21
|
+
license_owner: string;
|
|
22
|
+
license_link: string;
|
|
23
|
+
featured: boolean;
|
|
24
|
+
preview: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PaletteIcon {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
tags: string[];
|
|
31
|
+
url: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PaletteCollectionDetail {
|
|
35
|
+
slug: string;
|
|
36
|
+
name: string;
|
|
37
|
+
blurb: string;
|
|
38
|
+
category: string;
|
|
39
|
+
style: string;
|
|
40
|
+
count: number;
|
|
41
|
+
page: number;
|
|
42
|
+
page_size: number;
|
|
43
|
+
icons: PaletteIcon[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PaletteHit extends PaletteIcon {
|
|
47
|
+
collection: string;
|
|
48
|
+
collection_name: string;
|
|
49
|
+
category: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PaletteLibrary {
|
|
53
|
+
app: string;
|
|
54
|
+
generated_at: string;
|
|
55
|
+
totals: { collections: number; icons: number };
|
|
56
|
+
collections: PaletteCollection[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toQuery(params?: Record<string, string | number | boolean>): string {
|
|
60
|
+
const query = new URLSearchParams();
|
|
61
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
62
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
63
|
+
query.set(key, String(value));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return query.toString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function paletteGet<T>(
|
|
70
|
+
path: string,
|
|
71
|
+
params?: Record<string, string | number | boolean>,
|
|
72
|
+
): Promise<T> {
|
|
73
|
+
const suffix = toQuery(params);
|
|
74
|
+
const response = await fetch(
|
|
75
|
+
`${PALETTE_URL}${path}${suffix ? `?${suffix}` : ''}`,
|
|
76
|
+
{ headers: { Accept: 'application/json' } },
|
|
77
|
+
);
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(`Palette request failed (${response.status}).`);
|
|
80
|
+
}
|
|
81
|
+
return (await response.json()) as T;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cache = new Map<string, { expires: number; value: unknown }>();
|
|
85
|
+
|
|
86
|
+
async function paletteGetCached<T>(
|
|
87
|
+
path: string,
|
|
88
|
+
params: Record<string, string | number | boolean> | undefined,
|
|
89
|
+
ttlMs: number,
|
|
90
|
+
): Promise<T> {
|
|
91
|
+
const key = `${path}?${toQuery(params)}`;
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const hit = cache.get(key);
|
|
94
|
+
if (hit && hit.expires > now) return hit.value as T;
|
|
95
|
+
const value = await paletteGet<T>(path, params);
|
|
96
|
+
cache.set(key, { expires: now + ttlMs, value });
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function fetchPaletteLibrary(options?: {
|
|
101
|
+
featured?: boolean;
|
|
102
|
+
category?: string;
|
|
103
|
+
q?: string;
|
|
104
|
+
}): Promise<PaletteLibrary> {
|
|
105
|
+
return paletteGetCached<PaletteLibrary>('/library', options, 5 * 60_000);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function fetchPaletteCollection(
|
|
109
|
+
slug: string,
|
|
110
|
+
options?: { page?: number; size?: number },
|
|
111
|
+
): Promise<PaletteCollectionDetail> {
|
|
112
|
+
return paletteGetCached<PaletteCollectionDetail>(
|
|
113
|
+
`/collections/${slug}`,
|
|
114
|
+
options,
|
|
115
|
+
2 * 60_000,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function findPaletteIcons(options: {
|
|
120
|
+
q: string;
|
|
121
|
+
category?: string;
|
|
122
|
+
collection?: string;
|
|
123
|
+
style?: string;
|
|
124
|
+
limit?: number;
|
|
125
|
+
}): Promise<{ query: string; total: number; limit: number; results: PaletteHit[] }> {
|
|
126
|
+
return paletteGet('/find', options);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Recoloured icon URL for a Palette asset.
|
|
131
|
+
*
|
|
132
|
+
* Cross-origin SVGs can't be tinted in the browser (no CORS on the media
|
|
133
|
+
* host), so Palette recolours them server-side. Non-Palette icons (uploads)
|
|
134
|
+
* are returned untouched.
|
|
135
|
+
*/
|
|
136
|
+
export function paletteIconSrc(
|
|
137
|
+
imageUrl: string | null | undefined,
|
|
138
|
+
color: string | null | undefined,
|
|
139
|
+
): string | null | undefined {
|
|
140
|
+
if (!imageUrl || !color) return imageUrl;
|
|
141
|
+
if (!imageUrl.includes('/svgrepo/')) return imageUrl;
|
|
142
|
+
return `${PALETTE_URL}/render?src=${encodeURIComponent(imageUrl)}&color=${encodeURIComponent(color)}`;
|
|
143
|
+
}
|