@gogitcms/design-system 0.15.0-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +77 -0
  2. package/css/components.css +312 -0
  3. package/css/tokens.css +212 -0
  4. package/package.json +65 -0
  5. package/src/ThemeProvider.tsx +86 -0
  6. package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  7. package/src/__tests__/BranchImport.test.tsx +46 -0
  8. package/src/__tests__/Button.test.tsx +45 -0
  9. package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  10. package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  11. package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  12. package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  13. package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  14. package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  15. package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  16. package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  17. package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  18. package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  19. package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  20. package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  21. package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  22. package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  23. package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  24. package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  25. package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  26. package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  27. package/src/__tests__/MediaBrowser.test.tsx +353 -0
  28. package/src/__tests__/MediaField.test.tsx +185 -0
  29. package/src/__tests__/Notifications.test.tsx +69 -0
  30. package/src/__tests__/Onboarding.test.tsx +287 -0
  31. package/src/__tests__/cssTokens.test.ts +201 -0
  32. package/src/__tests__/fieldComponents.test.ts +43 -0
  33. package/src/__tests__/reorder.test.ts +58 -0
  34. package/src/components/ApplyChangesModal.tsx +348 -0
  35. package/src/components/BranchImport.tsx +157 -0
  36. package/src/components/BranchMenu.tsx +192 -0
  37. package/src/components/Button.tsx +131 -0
  38. package/src/components/ChangeDetail.tsx +472 -0
  39. package/src/components/ChangeRequestSummary.tsx +173 -0
  40. package/src/components/CollabField.tsx +388 -0
  41. package/src/components/ContentBrowser.tsx +5073 -0
  42. package/src/components/Icon.tsx +28 -0
  43. package/src/components/Icon.web.tsx +31 -0
  44. package/src/components/Input.tsx +106 -0
  45. package/src/components/MediaBrowser.tsx +766 -0
  46. package/src/components/MediaField.tsx +670 -0
  47. package/src/components/MediaPreview.tsx +91 -0
  48. package/src/components/MediaPreview.web.tsx +169 -0
  49. package/src/components/NavRow.tsx +105 -0
  50. package/src/components/Notifications.tsx +301 -0
  51. package/src/components/Onboarding.tsx +751 -0
  52. package/src/components/ProjectMenu.tsx +124 -0
  53. package/src/components/Segment.tsx +87 -0
  54. package/src/components/Skeleton.tsx +216 -0
  55. package/src/components/Spinner.tsx +44 -0
  56. package/src/components/Text.tsx +85 -0
  57. package/src/components/documentDrafts.ts +213 -0
  58. package/src/components/layout.tsx +284 -0
  59. package/src/components/primitives.tsx +143 -0
  60. package/src/components/reorder.ts +40 -0
  61. package/src/fieldComponents.ts +63 -0
  62. package/src/icons.ts +102 -0
  63. package/src/index.ts +198 -0
  64. package/src/media.ts +229 -0
  65. package/src/theme.ts +116 -0
  66. package/src/web/Button.tsx +110 -0
  67. package/src/web/Icon.tsx +52 -0
  68. package/src/web/Input.tsx +39 -0
  69. package/src/web/index.ts +43 -0
  70. package/src/web/primitives.tsx +119 -0
@@ -0,0 +1,766 @@
1
+ import React from "react";
2
+ import { View, Pressable, FlatList, ActivityIndicator, TextInput, ScrollView } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
6
+ import { IconButton } from "./Button";
7
+ import { MediaPreview, MediaChip, fileName } from "./MediaPreview";
8
+ import { useMediaApi } from "./MediaField";
9
+ import {
10
+ MediaAsset,
11
+ MediaCondition,
12
+ MediaFacets,
13
+ MediaKind,
14
+ formatBytes,
15
+ kindIcon,
16
+ } from "../media";
17
+
18
+ // The Media browser: the pane that replaces the entry list + document editor
19
+ // when a media set is selected in the sidebar.
20
+ //
21
+ // It owns its own querying rather than taking rows as props, because its filter
22
+ // state (facets, search, paging) is view state the host has no reason to model —
23
+ // unlike document selection, which is bound to a URL.
24
+
25
+ const PAGE_SIZE = 60;
26
+
27
+ export type MediaBrowserProps = {
28
+ // The media set being browsed, from the nav key.
29
+ set: string;
30
+ // Display name for the header.
31
+ label?: string;
32
+ variant?: "desktop" | "mobile";
33
+ };
34
+
35
+ // FacetState is the panel's working selection. Kept as plain sets/strings and
36
+ // converted to MediaConditions on the way out, so the panel never has to think
37
+ // about operators.
38
+ type FacetState = {
39
+ extensions: string[];
40
+ mimeTypes: string[];
41
+ kinds: string[];
42
+ minSize: string;
43
+ maxSize: string;
44
+ };
45
+
46
+ const EMPTY_FACETS: FacetState = { extensions: [], mimeTypes: [], kinds: [], minSize: "", maxSize: "" };
47
+
48
+ function facetsActive(f: FacetState): boolean {
49
+ return (
50
+ f.extensions.length > 0 ||
51
+ f.mimeTypes.length > 0 ||
52
+ f.kinds.length > 0 ||
53
+ f.minSize.trim() !== "" ||
54
+ f.maxSize.trim() !== ""
55
+ );
56
+ }
57
+
58
+ // conditionsFrom turns the panel's selection into the server's filter language.
59
+ // Multi-select facets become a single `in` (an OR within the facet); different
60
+ // facets AND together — the behaviour people expect from faceted search.
61
+ export function conditionsFrom(f: FacetState): MediaCondition[] {
62
+ const out: MediaCondition[] = [];
63
+ if (f.extensions.length) out.push({ field: "extension", op: "in", values: f.extensions });
64
+ if (f.mimeTypes.length) out.push({ field: "mimeType", op: "in", values: f.mimeTypes });
65
+ if (f.kinds.length) out.push({ field: "kind", op: "in", values: f.kinds });
66
+ // Size is entered in KB because bytes are unreadable at a glance; the server
67
+ // filters in bytes.
68
+ const min = parseKB(f.minSize);
69
+ if (min != null) out.push({ field: "size", op: "gte", value: String(min) });
70
+ const max = parseKB(f.maxSize);
71
+ if (max != null) out.push({ field: "size", op: "lte", value: String(max) });
72
+ return out;
73
+ }
74
+
75
+ function parseKB(v: string): number | null {
76
+ const n = Number(v.trim());
77
+ if (!v.trim() || !Number.isFinite(n) || n < 0) return null;
78
+ return Math.round(n * 1024);
79
+ }
80
+
81
+ export function MediaBrowser({ set, label, variant = "desktop" }: MediaBrowserProps) {
82
+ const t = useTheme();
83
+ const api = useMediaApi();
84
+ const isDesktop = variant === "desktop";
85
+
86
+ const [view, setView] = React.useState<"grid" | "list">("grid");
87
+ const [search, setSearch] = React.useState("");
88
+ const [applied, setApplied] = React.useState<FacetState>(EMPTY_FACETS);
89
+ const [draft, setDraft] = React.useState<FacetState>(EMPTY_FACETS);
90
+ const [panelOpen, setPanelOpen] = React.useState(false);
91
+ const [facets, setFacets] = React.useState<MediaFacets | null>(null);
92
+
93
+ const [items, setItems] = React.useState<MediaAsset[]>([]);
94
+ const [total, setTotal] = React.useState<number | null>(null);
95
+ const [loading, setLoading] = React.useState(false);
96
+ const [loadingMore, setLoadingMore] = React.useState(false);
97
+ const [exhausted, setExhausted] = React.useState(false);
98
+ const [selected, setSelected] = React.useState<MediaAsset | null>(null);
99
+
100
+ // Switching sets is a different library: reset everything view-related rather
101
+ // than carrying one set's filters onto another where they may match nothing.
102
+ React.useEffect(() => {
103
+ setSearch("");
104
+ setApplied(EMPTY_FACETS);
105
+ setDraft(EMPTY_FACETS);
106
+ setSelected(null);
107
+ setPanelOpen(false);
108
+ }, [set]);
109
+
110
+ React.useEffect(() => {
111
+ if (!api?.facets) return;
112
+ let cancelled = false;
113
+ api
114
+ .facets(set)
115
+ .then((f) => {
116
+ if (!cancelled) setFacets(f);
117
+ })
118
+ .catch(() => {
119
+ // No facets is a degraded panel, not a broken browser — search still works.
120
+ if (!cancelled) setFacets(null);
121
+ });
122
+ return () => {
123
+ cancelled = true;
124
+ };
125
+ }, [api, set]);
126
+
127
+ const conditions = React.useMemo(() => conditionsFrom(applied), [applied]);
128
+ // Serialized so the effect below re-runs on value changes rather than on every
129
+ // new array identity.
130
+ const conditionsKey = JSON.stringify(conditions);
131
+
132
+ // First page (and every re-query when the filters change), debounced so typing
133
+ // in the search box doesn't fire a request per keystroke.
134
+ React.useEffect(() => {
135
+ if (!api) return;
136
+ let cancelled = false;
137
+ setLoading(true);
138
+ const timer = setTimeout(() => {
139
+ const params = { set, search: search.trim() || undefined, filters: conditions, limit: PAGE_SIZE, offset: 0 };
140
+ Promise.all([api.list(params), api.count ? api.count(params) : Promise.resolve(null)])
141
+ .then(([rows, n]) => {
142
+ if (cancelled) return;
143
+ setItems(rows);
144
+ setTotal(n);
145
+ setExhausted(rows.length < PAGE_SIZE);
146
+ })
147
+ .catch(() => {
148
+ if (cancelled) return;
149
+ setItems([]);
150
+ setTotal(null);
151
+ setExhausted(true);
152
+ })
153
+ .finally(() => {
154
+ if (!cancelled) setLoading(false);
155
+ });
156
+ }, 250);
157
+ return () => {
158
+ cancelled = true;
159
+ clearTimeout(timer);
160
+ };
161
+ }, [api, set, search, conditionsKey]);
162
+
163
+ const loadMore = () => {
164
+ if (!api || loading || loadingMore || exhausted) return;
165
+ setLoadingMore(true);
166
+ api
167
+ .list({ set, search: search.trim() || undefined, filters: conditions, limit: PAGE_SIZE, offset: items.length })
168
+ .then((rows) => {
169
+ setItems((prev) => [...prev, ...rows]);
170
+ setExhausted(rows.length < PAGE_SIZE);
171
+ })
172
+ .catch(() => setExhausted(true))
173
+ .finally(() => setLoadingMore(false));
174
+ };
175
+
176
+ const clearAll = () => {
177
+ setSearch("");
178
+ setApplied(EMPTY_FACETS);
179
+ setDraft(EMPTY_FACETS);
180
+ };
181
+
182
+ const filtered = facetsActive(applied) || search.trim() !== "";
183
+ const countLabel =
184
+ total == null
185
+ ? `${items.length}${exhausted ? "" : "+"}`
186
+ : filtered && facets
187
+ ? `${total} of ${facets.total}`
188
+ : `${total}`;
189
+
190
+ if (!api) {
191
+ return (
192
+ <EmptyPane
193
+ icon="image"
194
+ title="Media is unavailable"
195
+ detail="This server has no object store configured, so there is nothing to browse."
196
+ />
197
+ );
198
+ }
199
+
200
+ return (
201
+ <View style={{ flex: 1, minWidth: 0, flexDirection: "row" }}>
202
+ <View style={{ flex: 1, minWidth: 0 }}>
203
+ {/* header: title, count, search, view toggle, filter toggle */}
204
+ <View
205
+ style={{
206
+ gap: t.space(3),
207
+ paddingHorizontal: t.space(4),
208
+ paddingVertical: t.space(3),
209
+ borderBottomWidth: 1,
210
+ borderBottomColor: t.color.borderSubtle,
211
+ }}
212
+ >
213
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(3) }}>
214
+ <Text variant="h3" style={{ flex: 1 }} numberOfLines={1}>
215
+ {label || set}
216
+ </Text>
217
+ <Text variant="xs" color="tertiary" testID="media-count">
218
+ {`${countLabel} files`}
219
+ </Text>
220
+ <ViewToggle view={view} onChange={setView} />
221
+ {api.facets ? (
222
+ <IconButton
223
+ name="filter"
224
+ size="sm"
225
+ label="Filter media"
226
+ onPress={() => {
227
+ setDraft(applied);
228
+ setPanelOpen((v) => !v);
229
+ }}
230
+ testID="media-filter-toggle"
231
+ />
232
+ ) : null}
233
+ </View>
234
+
235
+ <TextInput
236
+ value={search}
237
+ onChangeText={setSearch}
238
+ placeholder="Search by file name or folder"
239
+ placeholderTextColor={t.color.textTertiary}
240
+ testID="media-search"
241
+ // @ts-expect-error react-native-web accepts outlineStyle
242
+ style={{
243
+ height: t.control.md,
244
+ paddingHorizontal: t.space(3),
245
+ borderWidth: 1,
246
+ borderColor: t.color.borderDefault,
247
+ borderRadius: t.radius.md,
248
+ backgroundColor: t.color.surfaceRaised,
249
+ color: t.color.textPrimary,
250
+ fontSize: 13,
251
+ outlineStyle: "none",
252
+ }}
253
+ />
254
+
255
+ {filtered ? (
256
+ <Pressable onPress={clearAll} testID="media-clear-filters" style={{ alignSelf: "flex-start" }}>
257
+ <Text variant="xs" color="tertiary">Clear filters</Text>
258
+ </Pressable>
259
+ ) : null}
260
+ </View>
261
+
262
+ {panelOpen && facets ? (
263
+ <FacetPanel
264
+ facets={facets}
265
+ draft={draft}
266
+ onChange={setDraft}
267
+ onApply={() => {
268
+ setApplied(draft);
269
+ setPanelOpen(false);
270
+ }}
271
+ onReset={() => setDraft(EMPTY_FACETS)}
272
+ />
273
+ ) : null}
274
+
275
+ {loading ? (
276
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
277
+ <ActivityIndicator size="small" color={t.color.textTertiary} />
278
+ </View>
279
+ ) : items.length === 0 ? (
280
+ <EmptyPane
281
+ icon={filtered ? "search" : "image"}
282
+ title={filtered ? "Nothing matches those filters" : "This set has no files yet"}
283
+ detail={
284
+ filtered
285
+ ? "Try widening the search or clearing a facet."
286
+ : "Files land here when they are imported from the repository."
287
+ }
288
+ />
289
+ ) : view === "grid" ? (
290
+ <MediaGrid
291
+ items={items}
292
+ selectedId={selected?.id ?? null}
293
+ onSelect={setSelected}
294
+ onEndReached={loadMore}
295
+ loadingMore={loadingMore}
296
+ />
297
+ ) : (
298
+ <MediaList
299
+ items={items}
300
+ selectedId={selected?.id ?? null}
301
+ onSelect={setSelected}
302
+ onEndReached={loadMore}
303
+ loadingMore={loadingMore}
304
+ />
305
+ )}
306
+ </View>
307
+
308
+ {/* Detail: a side pane on desktop, an overlay sheet on mobile. */}
309
+ {selected ? (
310
+ isDesktop ? (
311
+ <View
312
+ style={{
313
+ width: 320,
314
+ borderLeftWidth: 1,
315
+ borderLeftColor: t.color.borderSubtle,
316
+ backgroundColor: t.color.surfacePage,
317
+ }}
318
+ >
319
+ <MediaDetail media={selected} onClose={() => setSelected(null)} />
320
+ </View>
321
+ ) : (
322
+ <View
323
+ style={{
324
+ position: "absolute",
325
+ top: 0,
326
+ left: 0,
327
+ right: 0,
328
+ bottom: 0,
329
+ backgroundColor: t.color.surfacePage,
330
+ }}
331
+ >
332
+ <MediaDetail media={selected} onClose={() => setSelected(null)} />
333
+ </View>
334
+ )
335
+ ) : null}
336
+ </View>
337
+ );
338
+ }
339
+
340
+ // ---- results ---------------------------------------------------------------
341
+
342
+ // numColumns is fixed rather than measured: FlatList requires a stable column
343
+ // count, and re-keying the list on every resize would reset the scroll position.
344
+ const GRID_COLUMNS = 4;
345
+
346
+ function MediaGrid({
347
+ items,
348
+ selectedId,
349
+ onSelect,
350
+ onEndReached,
351
+ loadingMore,
352
+ }: {
353
+ items: MediaAsset[];
354
+ selectedId: string | null;
355
+ onSelect: (m: MediaAsset) => void;
356
+ onEndReached: () => void;
357
+ loadingMore: boolean;
358
+ }) {
359
+ const t = useTheme();
360
+ return (
361
+ <FlatList
362
+ testID="media-grid"
363
+ data={items}
364
+ keyExtractor={(m) => m.id}
365
+ numColumns={GRID_COLUMNS}
366
+ onEndReached={onEndReached}
367
+ onEndReachedThreshold={0.5}
368
+ contentContainerStyle={{ padding: t.space(3), gap: t.space(3) }}
369
+ columnWrapperStyle={{ gap: t.space(3) }}
370
+ ListFooterComponent={loadingMore ? <ListSpinner /> : null}
371
+ renderItem={({ item }) => (
372
+ <Pressable
373
+ onPress={() => onSelect(item)}
374
+ testID={`media-tile-${item.id}`}
375
+ style={{
376
+ flex: 1 / GRID_COLUMNS,
377
+ borderWidth: 1,
378
+ borderColor: item.id === selectedId ? t.color.borderActive : t.color.borderSubtle,
379
+ borderRadius: t.radius.md,
380
+ backgroundColor: t.color.surfaceRaised,
381
+ overflow: "hidden",
382
+ }}
383
+ >
384
+ <View
385
+ style={{
386
+ height: 96,
387
+ alignItems: "center",
388
+ justifyContent: "center",
389
+ backgroundColor: t.color.surfaceSunken,
390
+ }}
391
+ >
392
+ {item.kind === "image" || item.kind === "svg" ? (
393
+ <MediaPreview media={item} maxHeight={96} />
394
+ ) : (
395
+ <Icon name={kindIcon(item.kind)} size={24} color={t.color.textSecondary} />
396
+ )}
397
+ </View>
398
+ <View style={{ padding: t.space(2), gap: 2 }}>
399
+ <Text variant="xs" numberOfLines={1}>{fileName(item.repoPath)}</Text>
400
+ <Text variant="xs" color="tertiary" numberOfLines={1}>
401
+ {[item.extension.toUpperCase(), formatBytes(item.size)].filter(Boolean).join(" · ")}
402
+ </Text>
403
+ </View>
404
+ </Pressable>
405
+ )}
406
+ />
407
+ );
408
+ }
409
+
410
+ function MediaList({
411
+ items,
412
+ selectedId,
413
+ onSelect,
414
+ onEndReached,
415
+ loadingMore,
416
+ }: {
417
+ items: MediaAsset[];
418
+ selectedId: string | null;
419
+ onSelect: (m: MediaAsset) => void;
420
+ onEndReached: () => void;
421
+ loadingMore: boolean;
422
+ }) {
423
+ const t = useTheme();
424
+ return (
425
+ <FlatList
426
+ testID="media-list"
427
+ data={items}
428
+ keyExtractor={(m) => m.id}
429
+ onEndReached={onEndReached}
430
+ onEndReachedThreshold={0.5}
431
+ contentContainerStyle={{ padding: t.space(3), gap: t.space(2) }}
432
+ ListFooterComponent={loadingMore ? <ListSpinner /> : null}
433
+ renderItem={({ item }) => (
434
+ <Pressable
435
+ onPress={() => onSelect(item)}
436
+ testID={`media-row-${item.id}`}
437
+ style={{
438
+ flexDirection: "row",
439
+ alignItems: "center",
440
+ gap: t.space(3),
441
+ padding: t.space(2),
442
+ borderWidth: 1,
443
+ borderColor: item.id === selectedId ? t.color.borderActive : t.color.borderSubtle,
444
+ borderRadius: t.radius.md,
445
+ backgroundColor: t.color.surfaceRaised,
446
+ }}
447
+ >
448
+ <View
449
+ style={{
450
+ width: 40,
451
+ height: 40,
452
+ borderRadius: t.radius.sm,
453
+ alignItems: "center",
454
+ justifyContent: "center",
455
+ backgroundColor: t.color.surfaceSunken,
456
+ overflow: "hidden",
457
+ }}
458
+ >
459
+ {item.kind === "image" || item.kind === "svg" ? (
460
+ <MediaPreview media={item} maxHeight={40} />
461
+ ) : (
462
+ <Icon name={kindIcon(item.kind)} size={18} color={t.color.textSecondary} />
463
+ )}
464
+ </View>
465
+ <View style={{ flex: 1, minWidth: 0 }}>
466
+ <Text variant="body" numberOfLines={1}>{fileName(item.repoPath)}</Text>
467
+ <Text variant="xs" color="tertiary" numberOfLines={1}>{item.repoPath}</Text>
468
+ </View>
469
+ <Text variant="xs" color="tertiary">{item.mimeType}</Text>
470
+ <Text variant="xs" color="tertiary">{formatBytes(item.size)}</Text>
471
+ </Pressable>
472
+ )}
473
+ />
474
+ );
475
+ }
476
+
477
+ function ListSpinner() {
478
+ const t = useTheme();
479
+ return (
480
+ <View style={{ padding: t.space(4), alignItems: "center" }}>
481
+ <ActivityIndicator size="small" color={t.color.textTertiary} />
482
+ </View>
483
+ );
484
+ }
485
+
486
+ // ---- detail ----------------------------------------------------------------
487
+
488
+ function MediaDetail({ media, onClose }: { media: MediaAsset; onClose: () => void }) {
489
+ const t = useTheme();
490
+ const rows: [string, string][] = [
491
+ ["Path", media.repoPath],
492
+ ["Type", media.mimeType],
493
+ ["Size", formatBytes(media.size)],
494
+ ...(media.width && media.height ? ([["Dimensions", `${media.width} × ${media.height}`]] as [string, string][]) : []),
495
+ ...(media.publicPath ? ([["Public path", media.publicPath]] as [string, string][]) : []),
496
+ ...(media.cdnPath ? ([["CDN", media.cdnPath]] as [string, string][]) : []),
497
+ ];
498
+ return (
499
+ <View style={{ flex: 1 }}>
500
+ <View
501
+ style={{
502
+ flexDirection: "row",
503
+ alignItems: "center",
504
+ gap: t.space(2),
505
+ padding: t.space(3),
506
+ borderBottomWidth: 1,
507
+ borderBottomColor: t.color.borderSubtle,
508
+ }}
509
+ >
510
+ <Text variant="body" style={{ flex: 1 }} numberOfLines={1}>{fileName(media.repoPath)}</Text>
511
+ <IconButton name="x" size="sm" label="Close details" onPress={onClose} testID="media-detail-close" />
512
+ </View>
513
+ <ScrollView contentContainerStyle={{ padding: t.space(3), gap: t.space(3) }}>
514
+ <MediaPreview media={media} />
515
+ {media.pending ? (
516
+ <View
517
+ style={{
518
+ padding: t.space(2),
519
+ borderRadius: t.radius.md,
520
+ backgroundColor: t.color.surfaceActive,
521
+ }}
522
+ >
523
+ <Text variant="xs" color="tertiary">
524
+ Uploaded but not in git yet — the next export commits it to the repository.
525
+ </Text>
526
+ </View>
527
+ ) : null}
528
+ <View style={{ gap: t.space(2) }}>
529
+ {rows.map(([k, v]) => (
530
+ <View key={k} style={{ gap: 2 }}>
531
+ <Text variant="label" color="tertiary">{k}</Text>
532
+ <Text variant="monoSm">{v}</Text>
533
+ </View>
534
+ ))}
535
+ </View>
536
+ </ScrollView>
537
+ </View>
538
+ );
539
+ }
540
+
541
+ // ---- controls --------------------------------------------------------------
542
+
543
+ function ViewToggle({ view, onChange }: { view: "grid" | "list"; onChange: (v: "grid" | "list") => void }) {
544
+ const t = useTheme();
545
+ const btn = (which: "grid" | "list", icon: "grid" | "rows") => (
546
+ <Pressable
547
+ onPress={() => onChange(which)}
548
+ testID={`media-view-${which}`}
549
+ accessibilityLabel={which === "grid" ? "Grid view" : "List view"}
550
+ style={{
551
+ width: t.control.sm,
552
+ height: t.control.sm,
553
+ alignItems: "center",
554
+ justifyContent: "center",
555
+ borderRadius: t.radius.sm,
556
+ backgroundColor: view === which ? t.color.surfaceActive : "transparent",
557
+ }}
558
+ >
559
+ <Icon name={icon} size={16} color={view === which ? t.color.textPrimary : t.color.textSecondary} />
560
+ </Pressable>
561
+ );
562
+ return (
563
+ <View
564
+ style={{
565
+ flexDirection: "row",
566
+ borderWidth: 1,
567
+ borderColor: t.color.borderDefault,
568
+ borderRadius: t.radius.md,
569
+ padding: 1,
570
+ }}
571
+ >
572
+ {btn("grid", "grid")}
573
+ {btn("list", "rows")}
574
+ </View>
575
+ );
576
+ }
577
+
578
+ function FacetPanel({
579
+ facets,
580
+ draft,
581
+ onChange,
582
+ onApply,
583
+ onReset,
584
+ }: {
585
+ facets: MediaFacets;
586
+ draft: FacetState;
587
+ onChange: (f: FacetState) => void;
588
+ onApply: () => void;
589
+ onReset: () => void;
590
+ }) {
591
+ const t = useTheme();
592
+ const toggle = (key: "extensions" | "mimeTypes" | "kinds", value: string) => {
593
+ const cur = draft[key];
594
+ onChange({ ...draft, [key]: cur.includes(value) ? cur.filter((v) => v !== value) : [...cur, value] });
595
+ };
596
+
597
+ return (
598
+ <ScrollView
599
+ testID="media-facets"
600
+ style={{ maxHeight: 320, borderBottomWidth: 1, borderBottomColor: t.color.borderSubtle }}
601
+ contentContainerStyle={{ padding: t.space(4), gap: t.space(4) }}
602
+ >
603
+ <FacetGroup
604
+ title="Kind"
605
+ values={facets.kinds}
606
+ selected={draft.kinds}
607
+ onToggle={(v) => toggle("kinds", v)}
608
+ testIDPrefix="facet-kind"
609
+ />
610
+ <FacetGroup
611
+ title="Extension"
612
+ values={facets.extensions}
613
+ selected={draft.extensions}
614
+ onToggle={(v) => toggle("extensions", v)}
615
+ testIDPrefix="facet-ext"
616
+ />
617
+ <FacetGroup
618
+ title="Mime type"
619
+ values={facets.mimeTypes}
620
+ selected={draft.mimeTypes}
621
+ onToggle={(v) => toggle("mimeTypes", v)}
622
+ testIDPrefix="facet-mime"
623
+ />
624
+
625
+ <View style={{ gap: t.space(2) }}>
626
+ <Text variant="label" color="tertiary">
627
+ {`Size (KB) · this set holds ${formatBytes(facets.minSize)}–${formatBytes(facets.maxSize)}`}
628
+ </Text>
629
+ <View style={{ flexDirection: "row", gap: t.space(2) }}>
630
+ <SizeInput
631
+ value={draft.minSize}
632
+ onChange={(v) => onChange({ ...draft, minSize: v })}
633
+ placeholder="Min"
634
+ testID="facet-size-min"
635
+ />
636
+ <SizeInput
637
+ value={draft.maxSize}
638
+ onChange={(v) => onChange({ ...draft, maxSize: v })}
639
+ placeholder="Max"
640
+ testID="facet-size-max"
641
+ />
642
+ </View>
643
+ </View>
644
+
645
+ <View style={{ flexDirection: "row", gap: t.space(2) }}>
646
+ <Pressable
647
+ onPress={onApply}
648
+ testID="media-facets-apply"
649
+ style={{
650
+ paddingHorizontal: t.space(4),
651
+ height: t.control.sm,
652
+ justifyContent: "center",
653
+ borderRadius: t.radius.md,
654
+ backgroundColor: t.color.surfaceInverted,
655
+ }}
656
+ >
657
+ <Text variant="xs" color="inverted">Apply filters</Text>
658
+ </Pressable>
659
+ <Pressable
660
+ onPress={onReset}
661
+ testID="media-facets-reset"
662
+ style={{ paddingHorizontal: t.space(3), height: t.control.sm, justifyContent: "center" }}
663
+ >
664
+ <Text variant="xs" color="tertiary">Reset</Text>
665
+ </Pressable>
666
+ </View>
667
+ </ScrollView>
668
+ );
669
+ }
670
+
671
+ function FacetGroup({
672
+ title,
673
+ values,
674
+ selected,
675
+ onToggle,
676
+ testIDPrefix,
677
+ }: {
678
+ title: string;
679
+ values: { value: string; count: number }[];
680
+ selected: string[];
681
+ onToggle: (v: string) => void;
682
+ testIDPrefix: string;
683
+ }) {
684
+ const t = useTheme();
685
+ if (values.length === 0) return null;
686
+ return (
687
+ <View style={{ gap: t.space(2) }}>
688
+ <Text variant="label" color="tertiary">{title}</Text>
689
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: t.space(2) }}>
690
+ {values.map((v) => {
691
+ const on = selected.includes(v.value);
692
+ return (
693
+ <Pressable
694
+ key={v.value}
695
+ onPress={() => onToggle(v.value)}
696
+ testID={`${testIDPrefix}-${v.value}`}
697
+ style={{
698
+ paddingHorizontal: t.space(3),
699
+ height: t.control.sm,
700
+ justifyContent: "center",
701
+ borderRadius: t.radius.pill,
702
+ borderWidth: 1,
703
+ borderColor: on ? t.color.borderActive : t.color.borderDefault,
704
+ backgroundColor: on ? t.color.surfaceActive : "transparent",
705
+ }}
706
+ >
707
+ <Text variant="xs">{`${v.value} (${v.count})`}</Text>
708
+ </Pressable>
709
+ );
710
+ })}
711
+ </View>
712
+ </View>
713
+ );
714
+ }
715
+
716
+ function SizeInput({
717
+ value,
718
+ onChange,
719
+ placeholder,
720
+ testID,
721
+ }: {
722
+ value: string;
723
+ onChange: (v: string) => void;
724
+ placeholder: string;
725
+ testID: string;
726
+ }) {
727
+ const t = useTheme();
728
+ return (
729
+ <TextInput
730
+ value={value}
731
+ onChangeText={onChange}
732
+ placeholder={placeholder}
733
+ placeholderTextColor={t.color.textTertiary}
734
+ keyboardType="numeric"
735
+ testID={testID}
736
+ // @ts-expect-error react-native-web accepts outlineStyle
737
+ style={{
738
+ flex: 1,
739
+ height: t.control.sm,
740
+ paddingHorizontal: t.space(3),
741
+ borderWidth: 1,
742
+ borderColor: t.color.borderDefault,
743
+ borderRadius: t.radius.md,
744
+ backgroundColor: t.color.surfaceRaised,
745
+ color: t.color.textPrimary,
746
+ fontSize: 13,
747
+ outlineStyle: "none",
748
+ }}
749
+ />
750
+ );
751
+ }
752
+
753
+ function EmptyPane({ icon, title, detail }: { icon: "image" | "search"; title: string; detail: string }) {
754
+ const t = useTheme();
755
+ return (
756
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6), gap: t.space(2) }}>
757
+ <Icon name={icon} size={24} color={t.color.textTertiary} />
758
+ <Text variant="body" color="tertiary">{title}</Text>
759
+ <Text variant="xs" color="tertiary" align="center">{detail}</Text>
760
+ </View>
761
+ );
762
+ }
763
+
764
+ // Re-exported so a host can render the same chip outside the browser.
765
+ export { MediaChip };
766
+ export type { MediaKind };