@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,670 @@
1
+ import React from "react";
2
+ import { View, Pressable, Modal, ScrollView, ActivityIndicator, TextInput } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
6
+ import { Button } from "./Button";
7
+ import { MediaPreview, MediaChip, fileName } from "./MediaPreview";
8
+ import {
9
+ MediaApi,
10
+ MediaAsset,
11
+ MediaKind,
12
+ MediaResolution,
13
+ MediaValue,
14
+ StoreAs,
15
+ isMediaValue,
16
+ formatBytes,
17
+ kindIcon,
18
+ } from "../media";
19
+
20
+ // The media picker control, for both value shapes a media field can hold:
21
+ //
22
+ // string — a path, in whichever form the field's `store_as` declares. Needs
23
+ // resolution to find the asset it names, because the same file can
24
+ // be written as a repo path, a public path, or a CDN URL.
25
+ // object — {id, public_path, cdn_path}. Carries the asset id, so no
26
+ // resolution is needed and no ambiguity is possible.
27
+ //
28
+ // Resolution is a read-only side channel: this component never rewrites a string
29
+ // it merely resolved. The stored value changes only when the author picks a
30
+ // different asset, and then it is written in the field's declared form.
31
+
32
+ // MediaContext carries the host's data seam down to the field controls, which
33
+ // sit several layers inside ContentBrowser's render tree. Null means no host
34
+ // wiring (or no object store), which renders media fields read-only.
35
+ const MediaContext = React.createContext<MediaApi | null>(null);
36
+
37
+ export function MediaProvider({ api, children }: { api?: MediaApi; children: React.ReactNode }) {
38
+ return <MediaContext.Provider value={api ?? null}>{children}</MediaContext.Provider>;
39
+ }
40
+
41
+ export function useMediaApi(): MediaApi | null {
42
+ return React.useContext(MediaContext);
43
+ }
44
+
45
+ // DocumentPathContext carries the open document's file path to media fields at
46
+ // any nesting depth. It is separate from MediaContext because it is per-document
47
+ // and MediaContext is per-repository: the desktop browser has several documents
48
+ // open at once, so a single root value could not be right for all of them.
49
+ const DocumentPathContext = React.createContext<string | undefined>(undefined);
50
+
51
+ export function DocumentPathProvider({ path, children }: { path?: string; children: React.ReactNode }) {
52
+ return <DocumentPathContext.Provider value={path}>{children}</DocumentPathContext.Provider>;
53
+ }
54
+
55
+ export type MediaFieldProps = {
56
+ value: unknown;
57
+ onChange: (value: unknown) => void;
58
+ // Which value shape this field stores, from its schema type. It comes from the
59
+ // config rather than being inferred from the current value, because an empty
60
+ // object field and an empty string field look identical at runtime — and
61
+ // guessing would write the wrong shape into the document on first pick.
62
+ shape: "string" | "object";
63
+ // The media set this field browses, from the config's display.media.
64
+ set?: string;
65
+ // Which form a string field writes. Ignored for the object form, which stores
66
+ // the asset id.
67
+ storeAs?: StoreAs;
68
+ // Restrict the picker to particular render kinds.
69
+ kinds?: MediaKind[];
70
+ // The document's path, needed to interpret relative references correctly.
71
+ // Falls back to DocumentPathProvider, which is how nested fields (inside a
72
+ // group or a list item) get it without every level drilling the prop.
73
+ documentPath?: string;
74
+ readOnly?: boolean;
75
+ error?: boolean;
76
+ };
77
+
78
+ export function MediaField({
79
+ value,
80
+ onChange,
81
+ shape,
82
+ set,
83
+ storeAs = "public",
84
+ kinds,
85
+ documentPath: documentPathProp,
86
+ readOnly,
87
+ error,
88
+ }: MediaFieldProps) {
89
+ const t = useTheme();
90
+ const api = useMediaApi();
91
+ const contextPath = React.useContext(DocumentPathContext);
92
+ const documentPath = documentPathProp ?? contextPath;
93
+ const [picking, setPicking] = React.useState(false);
94
+ const [resolution, setResolution] = React.useState<MediaResolution | null>(null);
95
+ const [resolving, setResolving] = React.useState(false);
96
+ // The object form carries the id, so its asset is fetched by id rather than
97
+ // resolved. Held separately so a switch between shapes cannot show a stale one.
98
+ const [objectAsset, setObjectAsset] = React.useState<MediaAsset | null>(null);
99
+
100
+ const isObject = shape === "object";
101
+ const objectValue = isMediaValue(value) ? value : null;
102
+ const stringValue = !isObject && typeof value === "string" ? value : "";
103
+
104
+ // Resolve the string form. Skipped entirely for the object form and for an
105
+ // empty value — neither has anything to look up.
106
+ React.useEffect(() => {
107
+ if (!api || isObject || !stringValue.trim()) {
108
+ setResolution(null);
109
+ return;
110
+ }
111
+ let cancelled = false;
112
+ setResolving(true);
113
+ api
114
+ .resolve([stringValue], documentPath)
115
+ .then((results) => {
116
+ if (!cancelled) setResolution(results[0] ?? null);
117
+ })
118
+ .catch(() => {
119
+ // A failed resolution is not a failed field: fall back to showing the
120
+ // raw string, the same as an unmanaged reference.
121
+ if (!cancelled) setResolution(null);
122
+ })
123
+ .finally(() => {
124
+ if (!cancelled) setResolving(false);
125
+ });
126
+ return () => {
127
+ cancelled = true;
128
+ };
129
+ }, [api, isObject, stringValue, documentPath]);
130
+
131
+ // The object form's asset, fetched by its id — no resolution involved, because
132
+ // the id is authoritative and cannot be ambiguous.
133
+ React.useEffect(() => {
134
+ if (!api || !isObject || !objectValue) {
135
+ setObjectAsset(null);
136
+ return;
137
+ }
138
+ const id = objectValue.id;
139
+ let cancelled = false;
140
+ api
141
+ .byId(id)
142
+ .then((found) => {
143
+ if (!cancelled) setObjectAsset(found);
144
+ })
145
+ .catch(() => {
146
+ // The asset was removed from the repository since this document
147
+ // referenced it. The stored value is left alone; the field shows the
148
+ // dangling-reference state below.
149
+ if (!cancelled) setObjectAsset(null);
150
+ });
151
+ return () => {
152
+ cancelled = true;
153
+ };
154
+ }, [api, isObject, objectValue?.id]);
155
+
156
+ const select = (asset: MediaAsset) => {
157
+ setPicking(false);
158
+ if (isObject) {
159
+ // Object form: the id is authoritative; the paths are denormalized copies
160
+ // the built site can use without consulting the CMS.
161
+ onChange({
162
+ id: asset.id,
163
+ public_path: asset.publicPath ?? null,
164
+ cdn_path: asset.cdnPath ?? null,
165
+ } satisfies MediaValue);
166
+ return;
167
+ }
168
+ onChange(pathForStoreAs(asset, storeAs));
169
+ };
170
+
171
+ const clear = () => onChange(isObject ? null : "");
172
+
173
+ const asset = isObject ? objectAsset : resolution?.media ?? null;
174
+ // A string that resolved to nothing is unmanaged (external URL or outside
175
+ // every set); an object whose id no longer exists is a dangling reference.
176
+ // Both keep their stored value — neither is a reason to mutate the document.
177
+ const unmanaged = !isObject && !!stringValue.trim() && !resolving && !asset && !resolution?.ambiguous;
178
+ const dangling = isObject && !!objectValue && !asset;
179
+
180
+ return (
181
+ <View style={{ gap: t.space(2) }}>
182
+ {asset ? (
183
+ <MediaPreview media={asset} />
184
+ ) : resolving ? (
185
+ <PlaceholderBox>
186
+ <ActivityIndicator size="small" color={t.color.textTertiary} />
187
+ </PlaceholderBox>
188
+ ) : resolution?.ambiguous ? (
189
+ <AmbiguousNotice resolution={resolution} onPick={select} readOnly={readOnly} />
190
+ ) : unmanaged ? (
191
+ <UnmanagedNotice value={stringValue} />
192
+ ) : dangling ? (
193
+ <MissingNotice id={objectValue!.id} />
194
+ ) : (
195
+ <PlaceholderBox>
196
+ <Icon name="image" size={20} color={t.color.textTertiary} />
197
+ <Text variant="xs" color="tertiary">No media selected</Text>
198
+ </PlaceholderBox>
199
+ )}
200
+
201
+ {/* The stored string stays visible: an author needs to see what actually
202
+ goes into the file, especially when it resolved by a weak basename
203
+ match or did not resolve at all. */}
204
+ {!isObject && stringValue ? (
205
+ <Text variant="xs" color="tertiary" numberOfLines={1}>
206
+ {stringValue}
207
+ {resolution?.aliasKind === "basename" && asset ? " · matched by filename" : ""}
208
+ </Text>
209
+ ) : null}
210
+
211
+ {!readOnly ? (
212
+ <View style={{ flexDirection: "row", gap: t.space(2) }}>
213
+ <Button
214
+ title={asset ? "Replace" : "Choose media"}
215
+ onPress={() => setPicking(true)}
216
+ variant="default"
217
+ size="sm"
218
+ iconLeft="image"
219
+ disabled={!api}
220
+ testID="media-choose"
221
+ />
222
+ {stringValue || isObject ? (
223
+ <Button title="Clear" onPress={clear} variant="ghost" size="sm" iconLeft="x" testID="media-clear" />
224
+ ) : null}
225
+ </View>
226
+ ) : null}
227
+
228
+ {error ? (
229
+ <Text variant="xs" color="tertiary" style={{ color: t.color.diffDelFg }}>
230
+ This field is required.
231
+ </Text>
232
+ ) : null}
233
+
234
+ <MediaPicker
235
+ visible={picking}
236
+ onClose={() => setPicking(false)}
237
+ onSelect={select}
238
+ set={set}
239
+ kinds={kinds}
240
+ />
241
+ </View>
242
+ );
243
+ }
244
+
245
+ // pathForStoreAs writes the asset in the form the field's config declares. This
246
+ // is what keeps the CMS from inventing a path shape the site does not serve —
247
+ // and why reverse resolution is only ever needed for content the CMS did not
248
+ // write.
249
+ export function pathForStoreAs(asset: MediaAsset, storeAs: StoreAs): string {
250
+ switch (storeAs) {
251
+ case "cdn":
252
+ return asset.cdnPath || asset.publicPath || asset.repoPath;
253
+ case "repo":
254
+ return asset.repoPath;
255
+ default:
256
+ return asset.publicPath || asset.repoPath;
257
+ }
258
+ }
259
+
260
+ function PlaceholderBox({ children }: { children: React.ReactNode }) {
261
+ const t = useTheme();
262
+ return (
263
+ <View
264
+ style={{
265
+ height: 96,
266
+ alignItems: "center",
267
+ justifyContent: "center",
268
+ gap: t.space(2),
269
+ borderWidth: 1,
270
+ borderStyle: "dashed",
271
+ borderColor: t.color.borderDefault,
272
+ borderRadius: t.radius.md,
273
+ backgroundColor: t.color.surfaceSunken,
274
+ }}
275
+ >
276
+ {children}
277
+ </View>
278
+ );
279
+ }
280
+
281
+ // UnmanagedNotice covers the honest miss: an external URL, or a path pointing
282
+ // outside every configured media set. The field still works — the value is
283
+ // preserved exactly — there is simply nothing to preview.
284
+ function UnmanagedNotice({ value }: { value: string }) {
285
+ const t = useTheme();
286
+ const external = value.includes("://");
287
+ return (
288
+ <View
289
+ style={{
290
+ flexDirection: "row",
291
+ alignItems: "center",
292
+ gap: t.space(3),
293
+ padding: t.space(3),
294
+ borderWidth: 1,
295
+ borderColor: t.color.borderDefault,
296
+ borderRadius: t.radius.md,
297
+ backgroundColor: t.color.surfaceSunken,
298
+ }}
299
+ >
300
+ <Icon name={external ? "link" : "alert"} size={18} color={t.color.textSecondary} />
301
+ <View style={{ flex: 1, minWidth: 0 }}>
302
+ <Text variant="body" numberOfLines={1}>
303
+ {external ? "External link" : "Not a managed asset"}
304
+ </Text>
305
+ <Text variant="xs" color="tertiary">
306
+ {external
307
+ ? "This URL points outside your repository."
308
+ : "No file in a configured media set matches this path."}
309
+ </Text>
310
+ </View>
311
+ </View>
312
+ );
313
+ }
314
+
315
+ // MissingNotice covers an object reference whose asset is gone — the file was
316
+ // removed from the repository after this document pointed at it. The stored
317
+ // value is preserved so the author can see what it referenced before replacing
318
+ // it; silently blanking the field would destroy that evidence.
319
+ function MissingNotice({ id }: { id: string }) {
320
+ const t = useTheme();
321
+ return (
322
+ <View
323
+ style={{
324
+ flexDirection: "row",
325
+ alignItems: "center",
326
+ gap: t.space(3),
327
+ padding: t.space(3),
328
+ borderWidth: 1,
329
+ borderColor: t.color.borderDefault,
330
+ borderRadius: t.radius.md,
331
+ backgroundColor: t.color.surfaceSunken,
332
+ }}
333
+ >
334
+ <Icon name="alert" size={18} color={t.color.textSecondary} />
335
+ <View style={{ flex: 1, minWidth: 0 }}>
336
+ <Text variant="body">Missing asset</Text>
337
+ <Text variant="xs" color="tertiary" numberOfLines={1}>
338
+ {`No media with id ${id} — it may have been removed from the repository.`}
339
+ </Text>
340
+ </View>
341
+ </View>
342
+ );
343
+ }
344
+
345
+ // AmbiguousNotice is what a bare-filename match gets when several assets share
346
+ // that name. Asking is strictly better than picking: a silently wrong image in a
347
+ // published page is far more expensive than one extra click here.
348
+ function AmbiguousNotice({
349
+ resolution,
350
+ onPick,
351
+ readOnly,
352
+ }: {
353
+ resolution: MediaResolution;
354
+ onPick: (asset: MediaAsset) => void;
355
+ readOnly?: boolean;
356
+ }) {
357
+ const t = useTheme();
358
+ return (
359
+ <View style={{ gap: t.space(2) }}>
360
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
361
+ <Icon name="alert" size={16} color={t.color.textSecondary} />
362
+ <Text variant="xs" color="tertiary">
363
+ {resolution.candidates.length} files share this name — pick the right one.
364
+ </Text>
365
+ </View>
366
+ {resolution.candidates.map((c) => (
367
+ <Pressable
368
+ key={c.id}
369
+ onPress={() => (readOnly ? undefined : onPick(c))}
370
+ disabled={readOnly}
371
+ testID={`media-candidate-${c.id}`}
372
+ >
373
+ <MediaChip media={c} />
374
+ </Pressable>
375
+ ))}
376
+ </View>
377
+ );
378
+ }
379
+
380
+ // ---- picker ----------------------------------------------------------------
381
+
382
+ const PAGE_SIZE = 40;
383
+
384
+ export type MediaPickerProps = {
385
+ visible: boolean;
386
+ onClose: () => void;
387
+ onSelect: (asset: MediaAsset) => void;
388
+ set?: string;
389
+ kinds?: MediaKind[];
390
+ };
391
+
392
+ export function MediaPicker({ visible, onClose, onSelect, set, kinds }: MediaPickerProps) {
393
+ const t = useTheme();
394
+ const api = useMediaApi();
395
+ const [activeSet, setActiveSet] = React.useState<string | undefined>(set);
396
+ const [search, setSearch] = React.useState("");
397
+ const [items, setItems] = React.useState<MediaAsset[]>([]);
398
+ const [loading, setLoading] = React.useState(false);
399
+ const [uploading, setUploading] = React.useState(false);
400
+ const [uploadError, setUploadError] = React.useState<string | null>(null);
401
+
402
+ React.useEffect(() => {
403
+ if (visible) setActiveSet(set);
404
+ }, [visible, set]);
405
+
406
+ // Debounced so typing a filename doesn't fire a request per keystroke.
407
+ React.useEffect(() => {
408
+ if (!visible || !api) return;
409
+ let cancelled = false;
410
+ setLoading(true);
411
+ const timer = setTimeout(() => {
412
+ api
413
+ .list({ set: activeSet, kinds, search: search.trim() || undefined, limit: PAGE_SIZE })
414
+ .then((rows) => {
415
+ if (!cancelled) setItems(rows);
416
+ })
417
+ .catch(() => {
418
+ if (!cancelled) setItems([]);
419
+ })
420
+ .finally(() => {
421
+ if (!cancelled) setLoading(false);
422
+ });
423
+ }, 250);
424
+ return () => {
425
+ cancelled = true;
426
+ clearTimeout(timer);
427
+ };
428
+ }, [visible, api, activeSet, kinds, search]);
429
+
430
+ const handleUpload = async (file: File) => {
431
+ if (!api?.upload || !activeSet) return;
432
+ setUploading(true);
433
+ setUploadError(null);
434
+ try {
435
+ const asset = await api.upload({ set: activeSet, file });
436
+ onSelect(asset);
437
+ } catch (err) {
438
+ setUploadError(err instanceof Error ? err.message : "Upload failed.");
439
+ } finally {
440
+ setUploading(false);
441
+ }
442
+ };
443
+
444
+ const sets = api?.sets ?? [];
445
+
446
+ return (
447
+ <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
448
+ <View
449
+ style={{
450
+ flex: 1,
451
+ backgroundColor: t.color.overlay,
452
+ alignItems: "center",
453
+ justifyContent: "center",
454
+ padding: t.space(4),
455
+ }}
456
+ >
457
+ <View
458
+ style={{
459
+ width: "100%",
460
+ maxWidth: 720,
461
+ maxHeight: "85%",
462
+ backgroundColor: t.color.surfacePage,
463
+ borderRadius: t.radius.lg,
464
+ borderWidth: 1,
465
+ borderColor: t.color.borderDefault,
466
+ overflow: "hidden",
467
+ }}
468
+ >
469
+ <View
470
+ style={{
471
+ flexDirection: "row",
472
+ alignItems: "center",
473
+ gap: t.space(3),
474
+ padding: t.space(4),
475
+ borderBottomWidth: 1,
476
+ borderBottomColor: t.color.borderSubtle,
477
+ }}
478
+ >
479
+ <Text variant="h3" style={{ flex: 1 }}>
480
+ Choose media
481
+ </Text>
482
+ <Pressable onPress={onClose} accessibilityLabel="Close">
483
+ <Icon name="x" size={18} color={t.color.textSecondary} />
484
+ </Pressable>
485
+ </View>
486
+
487
+ {/* Set switcher — only when there is a choice to make. */}
488
+ {sets.length > 1 ? (
489
+ <ScrollView
490
+ horizontal
491
+ showsHorizontalScrollIndicator={false}
492
+ contentContainerStyle={{ gap: t.space(2), padding: t.space(3) }}
493
+ >
494
+ {sets.map((s) => (
495
+ <Pressable
496
+ key={s.name}
497
+ onPress={() => setActiveSet(s.name)}
498
+ style={{
499
+ paddingHorizontal: t.space(3),
500
+ height: t.control.sm,
501
+ justifyContent: "center",
502
+ borderRadius: t.radius.pill,
503
+ borderWidth: 1,
504
+ borderColor: activeSet === s.name ? t.color.borderActive : t.color.borderDefault,
505
+ backgroundColor: activeSet === s.name ? t.color.surfaceActive : "transparent",
506
+ }}
507
+ >
508
+ <Text variant="xs" color="tertiary">{`${s.name} (${s.count})`}</Text>
509
+ </Pressable>
510
+ ))}
511
+ </ScrollView>
512
+ ) : null}
513
+
514
+ <View style={{ padding: t.space(3), gap: t.space(3) }}>
515
+ <TextInput
516
+ value={search}
517
+ onChangeText={setSearch}
518
+ placeholder="Search by file name or folder"
519
+ placeholderTextColor={t.color.textTertiary}
520
+ // @ts-expect-error react-native-web accepts outlineStyle
521
+ style={{
522
+ height: t.control.md,
523
+ paddingHorizontal: t.space(3),
524
+ borderWidth: 1,
525
+ borderColor: t.color.borderDefault,
526
+ borderRadius: t.radius.md,
527
+ backgroundColor: t.color.surfaceRaised,
528
+ color: t.color.textPrimary,
529
+ fontSize: 13,
530
+ outlineStyle: "none",
531
+ }}
532
+ />
533
+ {api?.upload && activeSet ? (
534
+ <UploadControl onPick={handleUpload} uploading={uploading} />
535
+ ) : null}
536
+ {uploadError ? (
537
+ <Text variant="xs" color="tertiary" style={{ color: t.color.diffDelFg }}>
538
+ {uploadError}
539
+ </Text>
540
+ ) : null}
541
+ </View>
542
+
543
+ <ScrollView contentContainerStyle={{ padding: t.space(3), gap: t.space(2) }}>
544
+ {loading ? (
545
+ <View style={{ padding: t.space(6), alignItems: "center" }}>
546
+ <ActivityIndicator size="small" color={t.color.textTertiary} />
547
+ </View>
548
+ ) : items.length === 0 ? (
549
+ <View style={{ padding: t.space(6), alignItems: "center", gap: t.space(2) }}>
550
+ <Text variant="xs" color="tertiary">
551
+ {search ? "Nothing matches that search." : "This set has no files yet."}
552
+ </Text>
553
+ </View>
554
+ ) : (
555
+ items.map((asset) => (
556
+ <Pressable key={asset.id} onPress={() => onSelect(asset)} testID={`media-row-${asset.id}`}>
557
+ <MediaRow asset={asset} />
558
+ </Pressable>
559
+ ))
560
+ )}
561
+ </ScrollView>
562
+ </View>
563
+ </View>
564
+ </Modal>
565
+ );
566
+ }
567
+
568
+ // MediaRow is one asset in the picker list: a thumbnail for images, an icon for
569
+ // everything else, plus the path and size.
570
+ function MediaRow({ asset }: { asset: MediaAsset }) {
571
+ const t = useTheme();
572
+ const thumbable = asset.kind === "image" || asset.kind === "svg";
573
+ return (
574
+ <View
575
+ style={{
576
+ flexDirection: "row",
577
+ alignItems: "center",
578
+ gap: t.space(3),
579
+ padding: t.space(2),
580
+ borderWidth: 1,
581
+ borderColor: t.color.borderSubtle,
582
+ borderRadius: t.radius.md,
583
+ backgroundColor: t.color.surfaceRaised,
584
+ }}
585
+ >
586
+ <View
587
+ style={{
588
+ width: 48,
589
+ height: 48,
590
+ borderRadius: t.radius.sm,
591
+ backgroundColor: t.color.surfaceSunken,
592
+ alignItems: "center",
593
+ justifyContent: "center",
594
+ overflow: "hidden",
595
+ }}
596
+ >
597
+ {thumbable ? (
598
+ <Thumbnail asset={asset} />
599
+ ) : (
600
+ <Icon name={kindIcon(asset.kind)} size={20} color={t.color.textSecondary} />
601
+ )}
602
+ </View>
603
+ <View style={{ flex: 1, minWidth: 0 }}>
604
+ <Text variant="body" numberOfLines={1}>
605
+ {fileName(asset.repoPath)}
606
+ </Text>
607
+ <Text variant="xs" color="tertiary" numberOfLines={1}>
608
+ {[asset.repoPath, formatBytes(asset.size)].filter(Boolean).join(" · ")}
609
+ </Text>
610
+ </View>
611
+ {asset.pending ? (
612
+ <View
613
+ style={{
614
+ paddingHorizontal: t.space(2),
615
+ paddingVertical: 2,
616
+ borderRadius: t.radius.pill,
617
+ backgroundColor: t.color.surfaceActive,
618
+ }}
619
+ >
620
+ {/* Uploaded but not yet committed to git; it works in the editor and
621
+ lands in the repository on the next export. */}
622
+ <Text variant="xs" color="tertiary">not in git yet</Text>
623
+ </View>
624
+ ) : null}
625
+ </View>
626
+ );
627
+ }
628
+
629
+ function Thumbnail({ asset }: { asset: MediaAsset }) {
630
+ return <MediaPreview media={asset} compact maxHeight={48} />;
631
+ }
632
+
633
+ // UploadControl is the file input. It is web-only in effect: react-native has no
634
+ // <input type="file">, and a native image picker would be another dependency, so
635
+ // on native the control simply does not render.
636
+ function UploadControl({
637
+ onPick,
638
+ uploading,
639
+ }: {
640
+ onPick: (file: File) => void;
641
+ uploading: boolean;
642
+ }) {
643
+ const t = useTheme();
644
+ const inputRef = React.useRef<HTMLInputElement | null>(null);
645
+ if (typeof document === "undefined") return null;
646
+
647
+ return (
648
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
649
+ <Button
650
+ title={uploading ? "Uploading…" : "Upload a file"}
651
+ onPress={() => inputRef.current?.click()}
652
+ variant="default"
653
+ size="sm"
654
+ iconLeft="upload"
655
+ disabled={uploading}
656
+ />
657
+ <input
658
+ ref={inputRef}
659
+ type="file"
660
+ style={{ display: "none" }}
661
+ onChange={(e) => {
662
+ const file = e.target.files?.[0];
663
+ // Reset so re-picking the same file fires change again.
664
+ e.target.value = "";
665
+ if (file) onPick(file);
666
+ }}
667
+ />
668
+ </View>
669
+ );
670
+ }