@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.
- package/README.md +77 -0
- package/css/components.css +312 -0
- package/css/tokens.css +212 -0
- package/package.json +65 -0
- package/src/ThemeProvider.tsx +86 -0
- package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/src/__tests__/BranchImport.test.tsx +46 -0
- package/src/__tests__/Button.test.tsx +45 -0
- package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/src/__tests__/MediaField.test.tsx +185 -0
- package/src/__tests__/Notifications.test.tsx +69 -0
- package/src/__tests__/Onboarding.test.tsx +287 -0
- package/src/__tests__/cssTokens.test.ts +201 -0
- package/src/__tests__/fieldComponents.test.ts +43 -0
- package/src/__tests__/reorder.test.ts +58 -0
- package/src/components/ApplyChangesModal.tsx +348 -0
- package/src/components/BranchImport.tsx +157 -0
- package/src/components/BranchMenu.tsx +192 -0
- package/src/components/Button.tsx +131 -0
- package/src/components/ChangeDetail.tsx +472 -0
- package/src/components/ChangeRequestSummary.tsx +173 -0
- package/src/components/CollabField.tsx +388 -0
- package/src/components/ContentBrowser.tsx +5073 -0
- package/src/components/Icon.tsx +28 -0
- package/src/components/Icon.web.tsx +31 -0
- package/src/components/Input.tsx +106 -0
- package/src/components/MediaBrowser.tsx +766 -0
- package/src/components/MediaField.tsx +670 -0
- package/src/components/MediaPreview.tsx +91 -0
- package/src/components/MediaPreview.web.tsx +169 -0
- package/src/components/NavRow.tsx +105 -0
- package/src/components/Notifications.tsx +301 -0
- package/src/components/Onboarding.tsx +751 -0
- package/src/components/ProjectMenu.tsx +124 -0
- package/src/components/Segment.tsx +87 -0
- package/src/components/Skeleton.tsx +216 -0
- package/src/components/Spinner.tsx +44 -0
- package/src/components/Text.tsx +85 -0
- package/src/components/documentDrafts.ts +213 -0
- package/src/components/layout.tsx +284 -0
- package/src/components/primitives.tsx +143 -0
- package/src/components/reorder.ts +40 -0
- package/src/fieldComponents.ts +63 -0
- package/src/icons.ts +102 -0
- package/src/index.ts +198 -0
- package/src/media.ts +229 -0
- package/src/theme.ts +116 -0
- package/src/web/Button.tsx +110 -0
- package/src/web/Icon.tsx +52 -0
- package/src/web/Input.tsx +39 -0
- package/src/web/index.ts +43 -0
- 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
|
+
}
|