@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.
@@ -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
+ }
@@ -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
+ }