@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,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 };
|