@gogitcms/design-system 0.16.0-next.13 → 0.16.0-next.15
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 +6 -4
- package/css/tokens.css +1 -0
- package/package.json +2 -2
- package/src/__tests__/BranchMenu.test.tsx +38 -0
- package/src/__tests__/ContentBrowser.changes.test.tsx +23 -14
- package/src/__tests__/ContentBrowser.contentslot.test.tsx +10 -6
- package/src/__tests__/ContentBrowser.expand.test.tsx +170 -0
- package/src/__tests__/ContentBrowser.forms.test.tsx +25 -20
- package/src/__tests__/ContentBrowser.listrow.test.tsx +57 -0
- package/src/__tests__/ContentBrowser.master.test.tsx +147 -0
- package/src/__tests__/ContentBrowser.reorder.test.tsx +2 -3
- package/src/__tests__/ContentBrowser.window.test.tsx +203 -0
- package/src/__tests__/MediaBrowser.test.tsx +15 -9
- package/src/__tests__/ProtectedBranchModal.test.tsx +14 -0
- package/src/__tests__/cssTokens.test.ts +1 -0
- package/src/__tests__/documentDrafts.test.ts +59 -0
- package/src/components/BranchMenu.tsx +14 -4
- package/src/components/ContentBrowser.tsx +561 -445
- package/src/components/Input.tsx +37 -5
- package/src/components/Notifications.tsx +1 -1
- package/src/components/ProjectMenu.tsx +100 -26
- package/src/components/ProtectedBranchModal.tsx +19 -7
- package/src/components/Skeleton.tsx +12 -19
- package/src/components/WorkspaceMenu.tsx +162 -0
- package/src/components/documentDrafts.ts +19 -4
- package/src/components/layout.tsx +33 -1
- package/src/history.ts +1 -1
- package/src/icons.ts +5 -0
- package/src/index.ts +4 -1
- package/src/theme.ts +3 -1
|
@@ -8,11 +8,12 @@ import { Button, IconButton } from "./Button";
|
|
|
8
8
|
import { Input } from "./Input";
|
|
9
9
|
import { Avatar, Badge, CheckBox, DiffStat, Divider, SectionLabel } from "./primitives";
|
|
10
10
|
import { NavRow } from "./NavRow";
|
|
11
|
-
import { AppShell, MobileScreen, Pane, TopBar, ThemeToggle } from "./layout";
|
|
11
|
+
import { AppShell, MobileScreen, Pane, TopBar, ThemeToggle, type WindowChrome } from "./layout";
|
|
12
12
|
import { Segment, type SegmentItem } from "./Segment";
|
|
13
13
|
import { readDraft, writeDraft, clearDraft, subscribeDrafts, hasDraft, draftCount } from "./documentDrafts";
|
|
14
14
|
import { BranchMenu, type BranchRef } from "./BranchMenu";
|
|
15
|
-
import { ProjectMenu, type ProjectRef } from "./ProjectMenu";
|
|
15
|
+
import { ProjectMenu, type ProjectMenuAction, type ProjectRef } from "./ProjectMenu";
|
|
16
|
+
import { WorkspaceMenu, type WorkspaceMenuProps } from "./WorkspaceMenu";
|
|
16
17
|
import { ChangeDetail, type DocumentChange, type FieldConflict, type ConflictChoice } from "./ChangeDetail";
|
|
17
18
|
import { ApplyChangesModal, type MergeProgress } from "./ApplyChangesModal";
|
|
18
19
|
import { ProtectedBranchModal } from "./ProtectedBranchModal";
|
|
@@ -39,7 +40,7 @@ import { referenceValue } from "../references";
|
|
|
39
40
|
import { MediaBrowser } from "./MediaBrowser";
|
|
40
41
|
import { FormsBrowser } from "./FormsBrowser";
|
|
41
42
|
import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
|
|
42
|
-
import { type HistoryApi } from "../history";
|
|
43
|
+
import { relativeTime, type HistoryApi } from "../history";
|
|
43
44
|
import { DocumentHistory } from "./DocumentHistory";
|
|
44
45
|
import {
|
|
45
46
|
MEDIA_NAV_KEY,
|
|
@@ -53,7 +54,19 @@ import {
|
|
|
53
54
|
} from "../media";
|
|
54
55
|
import type { IconName } from "../icons";
|
|
55
56
|
|
|
56
|
-
export type CmsNavItem = {
|
|
57
|
+
export type CmsNavItem = {
|
|
58
|
+
key: string;
|
|
59
|
+
label: string;
|
|
60
|
+
icon: IconName;
|
|
61
|
+
count?: number;
|
|
62
|
+
conflicts?: number;
|
|
63
|
+
// The item has no list of its own: selecting it opens the thing itself in the
|
|
64
|
+
// detail region — a singleton document, a plugin screen — rather than pushing
|
|
65
|
+
// a list into the master pane. The sections stay on screen with its row lit,
|
|
66
|
+
// and the pushed list's title menu leaves it out, since there is no list to
|
|
67
|
+
// switch to. Omitted for a collection, Media and Forms, which all list.
|
|
68
|
+
direct?: boolean;
|
|
69
|
+
};
|
|
57
70
|
// An entry in the user (avatar) menu: host settings screens and
|
|
58
71
|
// plugin-contributed links. When any are present the avatar becomes a dropdown.
|
|
59
72
|
export type UserMenuItem = { label: string; icon?: IconName; onSelect: () => void; testID?: string };
|
|
@@ -211,6 +224,12 @@ export type CmsEntry = {
|
|
|
211
224
|
conflicted?: boolean;
|
|
212
225
|
title: string;
|
|
213
226
|
body: string;
|
|
227
|
+
// When this document last changed, as an ISO 8601 string. The list row shows
|
|
228
|
+
// its age in place of the file path — a document is its title and when it was
|
|
229
|
+
// last touched, and the path is one click away in the editor's Details. Hosts
|
|
230
|
+
// that have no such timestamp (the changes surface, whose rows are diffs of
|
|
231
|
+
// files) leave it off and keep the path.
|
|
232
|
+
updatedAt?: string;
|
|
214
233
|
// When present, the detail pane renders a field editor instead of title/body.
|
|
215
234
|
fields?: EntryField[];
|
|
216
235
|
// A list entry whose full document (fields/body) hasn't loaded yet — the detail
|
|
@@ -307,14 +326,15 @@ export type ContentBrowserProps = {
|
|
|
307
326
|
// Items shown in the avatar dropdown above Sign out. Absent/empty keeps the
|
|
308
327
|
// pre-menu topbar (sign-out icon + static avatar) byte-identical.
|
|
309
328
|
userMenuItems?: UserMenuItem[];
|
|
310
|
-
// When set, the
|
|
311
|
-
// desktop
|
|
312
|
-
//
|
|
329
|
+
// When set, the detail region is replaced by this node (plugin screens): on
|
|
330
|
+
// desktop it becomes one flexing pane beside the master pane, which stays on
|
|
331
|
+
// the sections with the plugin's row lit; on mobile it renders as its own
|
|
332
|
+
// screen titled `contentTitle`. The topbar stays.
|
|
313
333
|
contentSlot?: React.ReactNode;
|
|
314
334
|
contentTitle?: string;
|
|
315
335
|
// Providing entriesEmpty switches on "collection browsing" mode: nothing is
|
|
316
|
-
// auto-selected,
|
|
317
|
-
// unselected document shows "Select {collection}".
|
|
336
|
+
// auto-selected, the detail region shows this message while no collection
|
|
337
|
+
// is selected, and an unselected document shows "Select {collection}".
|
|
318
338
|
entriesEmpty?: string;
|
|
319
339
|
|
|
320
340
|
// Controlled document selection. When onSelectEntry is provided the component
|
|
@@ -330,7 +350,7 @@ export type ContentBrowserProps = {
|
|
|
330
350
|
// owns which docs are open, each column's loaded data + loading state, and its
|
|
331
351
|
// per-tab collection context). `entries` then only needs the list's display
|
|
332
352
|
// fields — each tab loads its own document. Absent → the internal (self-owned)
|
|
333
|
-
// columns behavior
|
|
353
|
+
// columns behavior. Mobile is unaffected.
|
|
334
354
|
openTabs?: OpenTab[];
|
|
335
355
|
activeTabId?: string | null;
|
|
336
356
|
// A content-list row was opened: newColumn (shift/⌘-click) → open in a new
|
|
@@ -501,9 +521,33 @@ export type ContentBrowserProps = {
|
|
|
501
521
|
onSelectProject?: (project: ProjectRef) => void;
|
|
502
522
|
/** cms.config.js pins this deployment to one project: shown, but inert. */
|
|
503
523
|
projectLocked?: boolean;
|
|
524
|
+
|
|
525
|
+
// ---- an app window (all optional; the web and mobile apps pass none) -----
|
|
526
|
+
// For a host that is a window per project, moving between workspaces rather
|
|
527
|
+
// than being addressed to one by a URL. These are what that needs of the
|
|
528
|
+
// desktop layout's top bar; the mobile layout ignores them.
|
|
529
|
+
/**
|
|
530
|
+
* The workspace switcher, in place of the static workspace tag: every
|
|
531
|
+
* workspace the user belongs to, and what choosing one does.
|
|
532
|
+
*/
|
|
533
|
+
workspaceMenu?: WorkspaceMenuProps;
|
|
534
|
+
/** Offers each project in the project menu in a window of its own. */
|
|
535
|
+
onOpenProjectInNewWindow?: (project: ProjectRef) => void;
|
|
536
|
+
/** Rows under the project menu's list. */
|
|
537
|
+
projectMenuActions?: ProjectMenuAction[];
|
|
538
|
+
/**
|
|
539
|
+
* Room for the platform's window controls, and the top bar marked as the
|
|
540
|
+
* window's drag region, for a host drawing its own window chrome (a
|
|
541
|
+
* chromeless macOS window).
|
|
542
|
+
*/
|
|
543
|
+
windowChrome?: WindowChrome;
|
|
504
544
|
// Omitted → no "New branch" row. Rejecting a name should throw so the inline
|
|
505
545
|
// form can show why.
|
|
506
546
|
onCreateBranch?: (name: string) => Promise<void> | void;
|
|
547
|
+
// The repository's branch prefix ("cms/"). Every branch the editor creates —
|
|
548
|
+
// from the branch menu or the protected-branch prompt — is named under it, so
|
|
549
|
+
// both forms show it read-only ahead of the name and hand back the full name.
|
|
550
|
+
branchPrefix?: string;
|
|
507
551
|
|
|
508
552
|
// The comparison target on the changes surface. When set, the top bar shows a
|
|
509
553
|
// `source → target` pair instead of a single branch menu — the source is the
|
|
@@ -639,6 +683,11 @@ function EntryListItem({ entry }: { entry: CmsEntry }) {
|
|
|
639
683
|
// what git thinks of the file: a document can be a local draft, or committed
|
|
640
684
|
// and modified on the branch, or both, and they are not the same state.
|
|
641
685
|
const draft = useHasDraft(entry.id);
|
|
686
|
+
// "Updated 3d ago": the age alone reads as a bare number against the title,
|
|
687
|
+
// with nothing saying what happened then. An unparseable timestamp renders as
|
|
688
|
+
// "" — fall back to the path rather than leave the row's second line blank.
|
|
689
|
+
const age = entry.updatedAt ? relativeTime(entry.updatedAt) : "";
|
|
690
|
+
const updated = age ? `Updated ${age}` : "";
|
|
642
691
|
return (
|
|
643
692
|
<ListRow conflicted={entry.conflicted}>
|
|
644
693
|
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
@@ -657,8 +706,8 @@ function EntryListItem({ entry }: { entry: CmsEntry }) {
|
|
|
657
706
|
{entry.status}
|
|
658
707
|
</Text>
|
|
659
708
|
) : null}
|
|
660
|
-
<Text variant="monoSm" color="tertiary" numberOfLines={1}>
|
|
661
|
-
{entry.path}
|
|
709
|
+
<Text variant="monoSm" color="tertiary" numberOfLines={1} testID="entry-subtitle">
|
|
710
|
+
{updated || entry.path}
|
|
662
711
|
</Text>
|
|
663
712
|
</View>
|
|
664
713
|
</ListRow>
|
|
@@ -2784,7 +2833,8 @@ function EntryDetail({
|
|
|
2784
2833
|
history,
|
|
2785
2834
|
active,
|
|
2786
2835
|
onActivate,
|
|
2787
|
-
|
|
2836
|
+
expanded,
|
|
2837
|
+
onToggleExpand,
|
|
2788
2838
|
onClose,
|
|
2789
2839
|
dragHandle,
|
|
2790
2840
|
}: {
|
|
@@ -2813,11 +2863,13 @@ function EntryDetail({
|
|
|
2813
2863
|
// the header renders exactly as it did before history existed.
|
|
2814
2864
|
history?: HistoryApi;
|
|
2815
2865
|
// Desktop columns: `active` marks the focused column (accent + header press
|
|
2816
|
-
// activates it via onActivate);
|
|
2817
|
-
// controls
|
|
2866
|
+
// activates it via onActivate); onToggleExpand/onClose add the header
|
|
2867
|
+
// expand/close controls, and `expanded` says which way the expand control
|
|
2868
|
+
// points. All omitted on mobile → unchanged single-detail behavior.
|
|
2818
2869
|
active?: boolean;
|
|
2819
2870
|
onActivate?: () => void;
|
|
2820
|
-
|
|
2871
|
+
expanded?: boolean;
|
|
2872
|
+
onToggleExpand?: () => void;
|
|
2821
2873
|
onClose?: () => void;
|
|
2822
2874
|
// A drag grip rendered at the head of the breadcrumb row to reorder columns.
|
|
2823
2875
|
dragHandle?: React.ReactNode;
|
|
@@ -3307,8 +3359,15 @@ function EntryDetail({
|
|
|
3307
3359
|
onMove={canMove ? () => setPrompt("move") : undefined}
|
|
3308
3360
|
onDelete={canDelete && deleteEntry ? deleteEntry : undefined}
|
|
3309
3361
|
/>
|
|
3310
|
-
{
|
|
3311
|
-
<IconButton
|
|
3362
|
+
{onToggleExpand ? (
|
|
3363
|
+
<IconButton
|
|
3364
|
+
name={expanded ? "minimize" : "maximize"}
|
|
3365
|
+
size="sm"
|
|
3366
|
+
active={expanded}
|
|
3367
|
+
label={expanded ? "Collapse column" : "Expand column"}
|
|
3368
|
+
onPress={onToggleExpand}
|
|
3369
|
+
testID="column-expand"
|
|
3370
|
+
/>
|
|
3312
3371
|
) : null}
|
|
3313
3372
|
{onClose ? (
|
|
3314
3373
|
<IconButton name="x" size="sm" label="Close column" onPress={onClose} testID="column-close" />
|
|
@@ -3504,6 +3563,12 @@ const MIN_COL = 480;
|
|
|
3504
3563
|
// a burst of keystrokes is one update rather than twenty.
|
|
3505
3564
|
const DRAFT_DEBOUNCE_MS = 150;
|
|
3506
3565
|
|
|
3566
|
+
// Which pane, if any, has taken over the desktop layout: the master pane (the
|
|
3567
|
+
// sections or the list pushed into it), or a single document column. Expanding
|
|
3568
|
+
// is exclusive — one pane at a time, and everything else stops rendering — so
|
|
3569
|
+
// this is a single value rather than a flag per pane.
|
|
3570
|
+
type ExpandedPane = { kind: "master" } | { kind: "column"; id: string } | null;
|
|
3571
|
+
|
|
3507
3572
|
// A normalized desktop column, unifying the host-controlled (openTabs) and
|
|
3508
3573
|
// self-owned (openIds) sources so the render + sizing paths stay single. `entry`
|
|
3509
3574
|
// is undefined while a controlled tab's document loads.
|
|
@@ -4008,6 +4073,7 @@ function ModalOverlays(props: ContentBrowserProps) {
|
|
|
4008
4073
|
error={props.protectedBranchError}
|
|
4009
4074
|
onCreate={props.onCreateBranchAndSave ?? (() => {})}
|
|
4010
4075
|
onCancel={props.onCancelProtectedSave ?? (() => {})}
|
|
4076
|
+
prefix={props.branchPrefix}
|
|
4011
4077
|
/>
|
|
4012
4078
|
) : null}
|
|
4013
4079
|
</>
|
|
@@ -4074,20 +4140,40 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4074
4140
|
|
|
4075
4141
|
// ---- open document columns (ephemeral desktop state) --------------------
|
|
4076
4142
|
// openIds are the documents shown as side-by-side columns, in display order.
|
|
4077
|
-
//
|
|
4078
|
-
//
|
|
4143
|
+
// They reset when the collection changes (open columns belong to the active
|
|
4144
|
+
// collection).
|
|
4079
4145
|
const [openIds, setOpenIds] = useState<string[]>([]);
|
|
4080
|
-
|
|
4081
|
-
|
|
4146
|
+
// The one pane that has taken over the screen, if any: the master pane, or a
|
|
4147
|
+
// single document column. While it holds, nothing else renders, and the
|
|
4148
|
+
// control that expanded it puts the layout back.
|
|
4149
|
+
const [expanded, setExpanded] = useState<ExpandedPane>(null);
|
|
4082
4150
|
const [region, setRegion] = useState({ w: 0, h: 0 });
|
|
4083
|
-
//
|
|
4084
|
-
//
|
|
4085
|
-
const [
|
|
4086
|
-
|
|
4151
|
+
// The master pane's width. Seeded from the layout default; dragging the seam
|
|
4152
|
+
// beside it updates it live (clamped). Local, ephemeral view state.
|
|
4153
|
+
const [masterW, setMasterW] = useState(t.layout.master);
|
|
4154
|
+
// ---- master / detail ----------------------------------------------------
|
|
4155
|
+
// One pane plays the sidebar and the content list in turn. At rest it lists
|
|
4156
|
+
// the sections; selecting an item that HAS a list pushes that list in, headed
|
|
4157
|
+
// by a Back control and a title that doubles as a switcher. A direct item — a
|
|
4158
|
+
// singleton, a plugin screen — has no list: it opens in the detail region and
|
|
4159
|
+
// the sections stay put with its row lit.
|
|
4160
|
+
const activeItem = navItemFor(sections, activeNavKey);
|
|
4161
|
+
// Media and Forms are named even when the host supplied no row for them
|
|
4162
|
+
// (a media key with no sets yet, a forms key on a host that lists forms
|
|
4163
|
+
// some other way): the pushed list still needs a title.
|
|
4164
|
+
const activeLabel = activeItem?.label ?? (mediaShowing ? "Media" : formsShowing ? "Forms" : "Content");
|
|
4165
|
+
const pushed = !!activeNavKey && !activeItem?.direct && props.contentSlot == null;
|
|
4087
4166
|
// The self-owned column set (openIds) is only used in non-tabbed mode; in tabbed
|
|
4088
|
-
// mode the host owns it, so
|
|
4089
|
-
//
|
|
4090
|
-
useEffect(() => { if (!tabbed)
|
|
4167
|
+
// mode the host owns it, so this maintenance effect no-ops. `expanded` stays
|
|
4168
|
+
// local (pure view state) in both modes.
|
|
4169
|
+
useEffect(() => { if (!tabbed) setOpenIds([]); }, [activeNavKey, tabbed]);
|
|
4170
|
+
// Anything that opens in the detail region puts a full-screen master back:
|
|
4171
|
+
// the thing just opened would otherwise be invisible. Called from every
|
|
4172
|
+
// control in the master that does so (a list row, a media set, a form), and
|
|
4173
|
+
// from the effect below for a direct item, which the host resolves.
|
|
4174
|
+
const revealDetail = () => setExpanded((x) => (x?.kind === "master" ? null : x));
|
|
4175
|
+
const directActive = !!activeNavKey && !pushed;
|
|
4176
|
+
useEffect(() => { if (directActive) revealDetail(); }, [directActive, activeNavKey]);
|
|
4091
4177
|
// Clear the selection when the collection changes so the detail shows its empty
|
|
4092
4178
|
// state. In controlled mode the host owns this (the URL changes with the nav).
|
|
4093
4179
|
useEffect(() => {
|
|
@@ -4117,8 +4203,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4117
4203
|
// RNW surfaces the click modifiers on nativeEvent (typed without them here).
|
|
4118
4204
|
const mods = ev?.nativeEvent as unknown as { shiftKey?: boolean; metaKey?: boolean } | undefined;
|
|
4119
4205
|
const newColumn = !!(mods?.shiftKey || mods?.metaKey);
|
|
4120
|
-
//
|
|
4121
|
-
|
|
4206
|
+
// Opening a document from a full-screen list restores the layout: the column
|
|
4207
|
+
// it opens in lives in the region that list is covering.
|
|
4208
|
+
revealDetail();
|
|
4122
4209
|
// Tabbed mode: hand the intent to the host (it owns the open-tab set).
|
|
4123
4210
|
if (tabbed) { props.onOpenEntry?.(e, { newColumn }); return; }
|
|
4124
4211
|
// Plain click on a not-yet-open document: swap it into the active column's
|
|
@@ -4137,12 +4224,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4137
4224
|
};
|
|
4138
4225
|
// Closing a column drops it; if it was active, activate a neighbor (or clear).
|
|
4139
4226
|
const closeColumn = (id: string) => {
|
|
4140
|
-
|
|
4141
|
-
if (!(id in c)) return c;
|
|
4142
|
-
const cp = { ...c };
|
|
4143
|
-
delete cp[id];
|
|
4144
|
-
return cp;
|
|
4145
|
-
});
|
|
4227
|
+
setExpanded((x) => (x?.kind === "column" && x.id === id ? null : x));
|
|
4146
4228
|
if (tabbed) { props.onCloseTab?.(id); return; }
|
|
4147
4229
|
const idx = openIds.indexOf(id);
|
|
4148
4230
|
const next = openIds.filter((x) => x !== id);
|
|
@@ -4157,8 +4239,11 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4157
4239
|
const { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys } = useMultiSelect(activeNavKey);
|
|
4158
4240
|
const [movePrompt, setMovePrompt] = useState(false);
|
|
4159
4241
|
const selected = entries.find((e) => e.id === selectedId) ?? (controlled || browsing ? null : entries[0] ?? null);
|
|
4160
|
-
const activeLabel = sections.flatMap((s) => s.items).find((i) => i.key === activeNavKey)?.label ?? "Content";
|
|
4161
4242
|
const showCreate = !!(creating && createFields && onSubmitCreate);
|
|
4243
|
+
// The create form opens as a column in the detail region, which an expanded
|
|
4244
|
+
// pane either hides (the list) or crowds out (a column). Starting a create
|
|
4245
|
+
// puts the layout back rather than opening a form nobody can see.
|
|
4246
|
+
useEffect(() => { if (showCreate) setExpanded(null); }, [showCreate]);
|
|
4162
4247
|
|
|
4163
4248
|
// Folder drill-down for hierarchical collections: navigate the folder tree and
|
|
4164
4249
|
// show only the folders + documents at the current level.
|
|
@@ -4220,16 +4305,22 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4220
4305
|
canMove: !!onMove,
|
|
4221
4306
|
collectionPath,
|
|
4222
4307
|
}));
|
|
4223
|
-
//
|
|
4224
|
-
//
|
|
4225
|
-
//
|
|
4226
|
-
const
|
|
4227
|
-
const
|
|
4228
|
-
|
|
4308
|
+
// Which pane is expanded, resolved against what is actually open: a column
|
|
4309
|
+
// that has since closed (or that belonged to another collection) falls back to
|
|
4310
|
+
// the normal layout rather than blanking the screen.
|
|
4311
|
+
const expandedCol = expanded?.kind === "column" ? columns.find((c) => c.id === expanded.id) ?? null : null;
|
|
4312
|
+
const masterExpanded = expanded?.kind === "master";
|
|
4313
|
+
// An expanded column is the only one on screen. The others stay open (they
|
|
4314
|
+
// keep their place in openIds/openTabs, and their unsaved edits) — they are
|
|
4315
|
+
// just not rendered until it collapses.
|
|
4316
|
+
const shownColumns = expandedCol ? [expandedCol] : columns;
|
|
4317
|
+
// Size the columns: a lone column fills the region; several share it down to
|
|
4318
|
+
// MIN_COL, past which the region scrolls horizontally.
|
|
4319
|
+
const colCount = shownColumns.length + (showCreate ? 1 : 0);
|
|
4229
4320
|
let colW = MIN_COL;
|
|
4230
|
-
if (region.w > 0 &&
|
|
4231
|
-
const per =
|
|
4232
|
-
colW = per >= MIN_COL ? Math.floor(per) :
|
|
4321
|
+
if (region.w > 0 && colCount > 0) {
|
|
4322
|
+
const per = region.w / colCount;
|
|
4323
|
+
colW = per >= MIN_COL ? Math.floor(per) : colCount <= 1 ? Math.max(1, Math.floor(region.w)) : MIN_COL;
|
|
4233
4324
|
}
|
|
4234
4325
|
const hasColumns = columns.length > 0 || showCreate;
|
|
4235
4326
|
|
|
@@ -4239,8 +4330,10 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4239
4330
|
// origin plus the scroll offset), then `slotAtX` picks the gap it's nearest.
|
|
4240
4331
|
// Reorder applies to the host (onReorderTabs) in tabbed mode or the local
|
|
4241
4332
|
// openIds otherwise. Web-only affordance; the native shells never mount it.
|
|
4242
|
-
|
|
4243
|
-
|
|
4333
|
+
// Reorder operates on the columns on screen, so an expanded column — the only
|
|
4334
|
+
// one rendered — has nothing to trade places with and grows no grip.
|
|
4335
|
+
const colIds = shownColumns.map((c) => c.id);
|
|
4336
|
+
const colWidths = shownColumns.map(() => colW);
|
|
4244
4337
|
const idsRef = useRef(colIds);
|
|
4245
4338
|
idsRef.current = colIds;
|
|
4246
4339
|
const widthsRef = useRef(colWidths);
|
|
@@ -4311,34 +4404,64 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4311
4404
|
<View key={key} style={{ width: 2, alignSelf: "stretch", backgroundColor: t.color.borderActive }} />
|
|
4312
4405
|
);
|
|
4313
4406
|
|
|
4314
|
-
const listLabel = browsing && !activeNavKey ? "Content" : activeLabel;
|
|
4315
4407
|
const createBtn = canCreate && onStartCreate && activeNavKey ? (
|
|
4316
4408
|
<IconButton name="plus" size="sm" label="Create" onPress={onStartCreate} testID="create-start" />
|
|
4317
4409
|
) : null;
|
|
4318
4410
|
|
|
4319
4411
|
// Collection search + faceted filters. `search` is enabled for a real (non
|
|
4320
|
-
// singleton) active collection; the icons/inputs render into the
|
|
4412
|
+
// singleton) active collection; the icons/inputs render into the master header.
|
|
4321
4413
|
const search = useCollectionSearch(props, activeNavKey);
|
|
4322
4414
|
const searchEnabled = !!props.searchable && !!activeNavKey;
|
|
4323
4415
|
const facetFields = props.facetFields ?? [];
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
)
|
|
4416
|
+
|
|
4417
|
+
// The master pane's header. At rest it names the surface; with a list pushed
|
|
4418
|
+
// in it carries Back, the list's title (a switcher when there is more than one
|
|
4419
|
+
// list), and whatever controls that list has. The expand control is there in
|
|
4420
|
+
// both states, exactly as a column's is.
|
|
4421
|
+
const expandBtn = (
|
|
4422
|
+
<IconButton
|
|
4423
|
+
name={masterExpanded ? "minimize" : "maximize"}
|
|
4424
|
+
size="sm"
|
|
4425
|
+
active={masterExpanded}
|
|
4426
|
+
label={masterExpanded ? "Collapse pane" : "Expand pane"}
|
|
4427
|
+
onPress={() => setExpanded(masterExpanded ? null : { kind: "master" })}
|
|
4428
|
+
testID="master-expand"
|
|
4429
|
+
/>
|
|
4430
|
+
);
|
|
4431
|
+
const masterHeader = (extras?: React.ReactNode) => (
|
|
4328
4432
|
<>
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
<>
|
|
4332
|
-
<IconButton name="search" size="sm" label="Search" onPress={search.openSearch} testID="search-open" />
|
|
4333
|
-
{facetFields.length > 0 ? (
|
|
4334
|
-
<IconButton name="filter" size="sm" label="Filter" onPress={search.openAdvanced} testID="filter-open" />
|
|
4335
|
-
) : null}
|
|
4336
|
-
</>
|
|
4433
|
+
{pushed ? (
|
|
4434
|
+
<IconButton name="chevronLeft" size="sm" label="Back to sections" onPress={() => onSelectNav("")} testID="master-back" />
|
|
4337
4435
|
) : null}
|
|
4338
|
-
{
|
|
4339
|
-
|
|
4436
|
+
{pushed ? (
|
|
4437
|
+
<MasterTitle label={activeLabel} sections={sections} activeNavKey={activeNavKey} onSelect={onSelectNav} />
|
|
4438
|
+
) : (
|
|
4439
|
+
<Text variant="h3" weight="semibold" style={{ flex: 1 }} testID="master-title">
|
|
4440
|
+
{(props.surface ?? "edit") === "changes" ? "Changes" : "Content"}
|
|
4441
|
+
</Text>
|
|
4442
|
+
)}
|
|
4443
|
+
{extras}
|
|
4444
|
+
{expandBtn}
|
|
4340
4445
|
</>
|
|
4341
4446
|
);
|
|
4447
|
+
// A collection's header swaps to a search input while a search is open.
|
|
4448
|
+
const collectionHeader = search.open ? (
|
|
4449
|
+
<SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
|
|
4450
|
+
) : (
|
|
4451
|
+
masterHeader(
|
|
4452
|
+
<>
|
|
4453
|
+
{searchEnabled ? (
|
|
4454
|
+
<>
|
|
4455
|
+
<IconButton name="search" size="sm" label="Search" onPress={search.openSearch} testID="search-open" />
|
|
4456
|
+
{facetFields.length > 0 ? (
|
|
4457
|
+
<IconButton name="filter" size="sm" label="Filter" onPress={search.openAdvanced} testID="filter-open" />
|
|
4458
|
+
) : null}
|
|
4459
|
+
</>
|
|
4460
|
+
) : null}
|
|
4461
|
+
{createBtn}
|
|
4462
|
+
</>,
|
|
4463
|
+
)
|
|
4464
|
+
);
|
|
4342
4465
|
|
|
4343
4466
|
// Activate a column: hand off to the host in tabbed mode (it switches the active
|
|
4344
4467
|
// collection + URL to the tab); otherwise move the local selection.
|
|
@@ -4373,7 +4496,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4373
4496
|
title={col.title || "Untitled"}
|
|
4374
4497
|
active={!showCreate && col.active}
|
|
4375
4498
|
onActivate={() => activateColumn(col)}
|
|
4376
|
-
|
|
4499
|
+
expanded={!!expandedCol}
|
|
4500
|
+
onToggleExpand={() => setExpanded(expandedCol ? null : { kind: "column", id: col.id })}
|
|
4377
4501
|
onClose={() => closeColumn(col.id)}
|
|
4378
4502
|
dragHandle={columnGrip(col.id)}
|
|
4379
4503
|
>
|
|
@@ -4400,7 +4524,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4400
4524
|
folderOptions={moveFolders}
|
|
4401
4525
|
active={!showCreate && col.active}
|
|
4402
4526
|
onActivate={() => activateColumn(col)}
|
|
4403
|
-
|
|
4527
|
+
expanded={!!expandedCol}
|
|
4528
|
+
onToggleExpand={() => setExpanded(expandedCol ? null : { kind: "column", id: col.id })}
|
|
4404
4529
|
onClose={() => closeColumn(col.id)}
|
|
4405
4530
|
dragHandle={columnGrip(col.id)}
|
|
4406
4531
|
/>
|
|
@@ -4414,9 +4539,10 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4414
4539
|
|
|
4415
4540
|
const topBar = (
|
|
4416
4541
|
<TopBar
|
|
4542
|
+
windowChrome={props.windowChrome}
|
|
4417
4543
|
left={
|
|
4418
4544
|
<>
|
|
4419
|
-
<WorkspaceTag workspace={workspace} />
|
|
4545
|
+
{props.workspaceMenu ? <WorkspaceMenu {...props.workspaceMenu} /> : <WorkspaceTag workspace={workspace} />}
|
|
4420
4546
|
{/* Project before branch: the coarser choice reads first, matching the
|
|
4421
4547
|
URL's own order (projects/{name}/branches/{id}). */}
|
|
4422
4548
|
<ProjectMenu
|
|
@@ -4424,6 +4550,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4424
4550
|
projects={props.projects}
|
|
4425
4551
|
onSelect={props.onSelectProject}
|
|
4426
4552
|
locked={props.projectLocked}
|
|
4553
|
+
onOpenInNewWindow={props.onOpenProjectInNewWindow}
|
|
4554
|
+
actions={props.projectMenuActions}
|
|
4427
4555
|
/>
|
|
4428
4556
|
<BranchMenu
|
|
4429
4557
|
current={workspace.branch}
|
|
@@ -4431,6 +4559,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4431
4559
|
defaultBranch={props.defaultBranch}
|
|
4432
4560
|
onSelect={props.onSelectBranch}
|
|
4433
4561
|
onCreate={props.onCreateBranch}
|
|
4562
|
+
prefix={props.onCreateBranch ? props.branchPrefix : undefined}
|
|
4434
4563
|
/>
|
|
4435
4564
|
{/* On the changes surface the top bar reads `source → target`: the
|
|
4436
4565
|
branch above is the source under review, this is what it's compared
|
|
@@ -4488,8 +4617,11 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4488
4617
|
/>
|
|
4489
4618
|
);
|
|
4490
4619
|
|
|
4491
|
-
|
|
4492
|
-
|
|
4620
|
+
// The sections list — the master pane at rest. Plain rows; a row with a list
|
|
4621
|
+
// pushes it in (through the host's nav key), a direct row opens its thing in
|
|
4622
|
+
// the detail region and stays lit here.
|
|
4623
|
+
const sectionsList = (
|
|
4624
|
+
<ScrollView testID="nav-list" contentContainerStyle={{ paddingVertical: t.space(2) }}>
|
|
4493
4625
|
{sections.map((section, i) => (
|
|
4494
4626
|
<View key={section.title || `section-${i}`}>
|
|
4495
4627
|
{/* An untitled section is a standalone button (Media), not a group. */}
|
|
@@ -4509,7 +4641,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4509
4641
|
))}
|
|
4510
4642
|
</View>
|
|
4511
4643
|
))}
|
|
4512
|
-
</
|
|
4644
|
+
</ScrollView>
|
|
4513
4645
|
);
|
|
4514
4646
|
|
|
4515
4647
|
// A full-width read-only notice (branch frozen in a change request) below the
|
|
@@ -4519,274 +4651,206 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4519
4651
|
);
|
|
4520
4652
|
const modalOverlays = <ModalOverlays {...props} />;
|
|
4521
4653
|
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
const changeCount = entries.length;
|
|
4529
|
-
return (
|
|
4530
|
-
<AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
|
|
4531
|
-
{navPane}
|
|
4532
|
-
<ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
|
|
4533
|
-
|
|
4534
|
-
<Pane
|
|
4535
|
-
width={listW}
|
|
4536
|
-
testID="pane-changes"
|
|
4537
|
-
scroll={false}
|
|
4538
|
-
header={<PaneTitle title={activeNavKey ? activeLabel : "Changes"} />}
|
|
4539
|
-
>
|
|
4540
|
-
{!activeNavKey ? (
|
|
4541
|
-
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
4542
|
-
<Text variant="body" color="tertiary" testID="changes-empty">
|
|
4543
|
-
{entriesEmpty ?? "Select a collection"}
|
|
4544
|
-
</Text>
|
|
4545
|
-
</View>
|
|
4546
|
-
) : (
|
|
4547
|
-
<EntriesList
|
|
4548
|
-
variant="desktop"
|
|
4549
|
-
entries={entries}
|
|
4550
|
-
selectedId={selectedId}
|
|
4551
|
-
onOpenEntry={(entry) => activate(entry)}
|
|
4552
|
-
header={
|
|
4553
|
-
<View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2) }}>
|
|
4554
|
-
<Text variant="label" color="tertiary">
|
|
4555
|
-
{`${changeCount} changed`}
|
|
4556
|
-
</Text>
|
|
4557
|
-
</View>
|
|
4558
|
-
}
|
|
4559
|
-
folders={[]}
|
|
4560
|
-
onFolderPress={() => {}}
|
|
4561
|
-
/>
|
|
4562
|
-
)}
|
|
4563
|
-
</Pane>
|
|
4564
|
-
<ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-list" />
|
|
4654
|
+
const changes = (props.surface ?? "edit") === "changes";
|
|
4655
|
+
const emptyDetail = (message: string, testID: string) => (
|
|
4656
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
4657
|
+
<Text variant="body" color="tertiary" testID={testID}>{message}</Text>
|
|
4658
|
+
</View>
|
|
4659
|
+
);
|
|
4565
4660
|
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4661
|
+
// What the master pane holds and how it is headed, by what is pushed into it.
|
|
4662
|
+
// Nothing pushed → the sections. The changes surface, Media and Forms each
|
|
4663
|
+
// push a list of their own kind; a collection pushes its entries. The header
|
|
4664
|
+
// is the same shape throughout, so switching between them moves nothing.
|
|
4665
|
+
let masterHeaderNode: React.ReactNode;
|
|
4666
|
+
let masterBody: React.ReactNode;
|
|
4667
|
+
if (!pushed) {
|
|
4668
|
+
masterHeaderNode = masterHeader();
|
|
4669
|
+
masterBody = sectionsList;
|
|
4670
|
+
} else if (changes) {
|
|
4671
|
+
// The changes list: the same rows as the editor's, carrying a status and a
|
|
4672
|
+
// diff stat instead of a draft badge. Everything that mutates a document
|
|
4673
|
+
// (create, bulk select, search, folder drill-down) is deliberately absent:
|
|
4674
|
+
// this describes a comparison between two branches, not something to edit.
|
|
4675
|
+
masterHeaderNode = masterHeader();
|
|
4676
|
+
masterBody = (
|
|
4677
|
+
<EntriesList
|
|
4678
|
+
variant="desktop"
|
|
4679
|
+
entries={entries}
|
|
4680
|
+
selectedId={selectedId}
|
|
4681
|
+
onOpenEntry={(entry) => { revealDetail(); activate(entry); }}
|
|
4682
|
+
header={
|
|
4683
|
+
<View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2) }}>
|
|
4684
|
+
<Text variant="label" color="tertiary">
|
|
4685
|
+
{`${entries.length} changed`}
|
|
4686
|
+
</Text>
|
|
4687
|
+
</View>
|
|
4688
|
+
}
|
|
4689
|
+
folders={[]}
|
|
4690
|
+
onFolderPress={() => {}}
|
|
4691
|
+
/>
|
|
4575
4692
|
);
|
|
4576
|
-
}
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
<
|
|
4583
|
-
{
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
{modalOverlays}
|
|
4589
|
-
</AppShell>
|
|
4693
|
+
} else if (formsShowing && props.forms) {
|
|
4694
|
+
// The forms list sits where a collection's documents would. Opening a form
|
|
4695
|
+
// hands the detail region to FormsBrowser, which owns the two levels beneath
|
|
4696
|
+
// (submissions → one submission); the list stays here so you can switch.
|
|
4697
|
+
masterHeaderNode = masterHeader();
|
|
4698
|
+
masterBody = (
|
|
4699
|
+
<FormsBrowser
|
|
4700
|
+
api={props.forms}
|
|
4701
|
+
form={null}
|
|
4702
|
+
onSelectForm={(name) => { revealDetail(); onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY); }}
|
|
4703
|
+
variant="desktop"
|
|
4704
|
+
/>
|
|
4590
4705
|
);
|
|
4591
|
-
}
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4706
|
+
} else if (mediaShowing) {
|
|
4707
|
+
// Media lists its sets here and browses the chosen one in the detail region.
|
|
4708
|
+
// Selecting a set goes through onSelectNav, so it lands in the URL like any
|
|
4709
|
+
// other selection.
|
|
4710
|
+
masterHeaderNode = masterHeader();
|
|
4711
|
+
masterBody = (
|
|
4712
|
+
<MediaSetList
|
|
4713
|
+
sets={mediaSets}
|
|
4714
|
+
selected={mediaSet}
|
|
4715
|
+
onSelect={(name) => { revealDetail(); onSelectNav(mediaNavKey(name)); }}
|
|
4716
|
+
/>
|
|
4717
|
+
);
|
|
4718
|
+
} else {
|
|
4719
|
+
masterHeaderNode = collectionHeader;
|
|
4720
|
+
masterBody = search.advanced ? (
|
|
4721
|
+
<AdvancedSearch
|
|
4722
|
+
fields={facetFields}
|
|
4723
|
+
initial={search.draft}
|
|
4724
|
+
onChange={search.setDraft}
|
|
4725
|
+
onApply={search.applyDraft}
|
|
4726
|
+
matchCount={search.matchCount}
|
|
4727
|
+
counting={search.counting}
|
|
4728
|
+
/>
|
|
4729
|
+
) : (
|
|
4730
|
+
<EntriesList
|
|
4731
|
+
variant="desktop"
|
|
4732
|
+
entries={visibleEntries}
|
|
4733
|
+
selectedId={selectedId}
|
|
4734
|
+
onOpenEntry={openEntry}
|
|
4735
|
+
header={
|
|
4736
|
+
showChecks && selectedCount > 0 && (onBulkDelete || canBulkMove) ? (
|
|
4737
|
+
<SelectionBar
|
|
4738
|
+
count={selectedCount}
|
|
4739
|
+
onClear={clear}
|
|
4740
|
+
onMove={canBulkMove ? () => setMovePrompt(true) : undefined}
|
|
4741
|
+
onDelete={onBulkDelete ? async () => { await onBulkDelete(moveIds); clear(); } : undefined}
|
|
4621
4742
|
/>
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4743
|
+
) : (
|
|
4744
|
+
<View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2), flexDirection: "row", alignItems: "center", gap: t.space(1.5) }}>
|
|
4745
|
+
<Text variant="label" color="tertiary">
|
|
4746
|
+
{`${totalEntries ?? entries.length} entries`}
|
|
4747
|
+
</Text>
|
|
4748
|
+
{/* The count is of documents with unsaved edits in this
|
|
4749
|
+
browser — the same state the rows below badge as Draft.
|
|
4750
|
+
It replaced workspace.changed here, which every host
|
|
4751
|
+
passed as a constant and which therefore told nobody
|
|
4752
|
+
anything. Rendered only when there is something to
|
|
4753
|
+
report: a permanent "0 uncommitted" is furniture. */}
|
|
4754
|
+
{draftsOpen > 0 ? (
|
|
4755
|
+
<>
|
|
4756
|
+
<Text variant="label" color="tertiary">·</Text>
|
|
4757
|
+
<Text variant="label" color="primary" testID="uncommitted-count">
|
|
4758
|
+
{`${draftsOpen} uncommitted`}
|
|
4759
|
+
</Text>
|
|
4760
|
+
</>
|
|
4761
|
+
) : null}
|
|
4762
|
+
</View>
|
|
4763
|
+
)
|
|
4764
|
+
}
|
|
4765
|
+
upRow={
|
|
4766
|
+
hierarchical && folder.length > 0 ? (
|
|
4767
|
+
<PressableRow onPress={() => setFolder(folder.slice(0, -1))}>
|
|
4768
|
+
<UpListItem parentLabel={parentLabel(folder, activeLabel)} currentPath={folder.join("/")} />
|
|
4769
|
+
</PressableRow>
|
|
4770
|
+
) : null
|
|
4771
|
+
}
|
|
4772
|
+
folders={
|
|
4773
|
+
hierarchical
|
|
4774
|
+
? folderNames.map((name) => ({
|
|
4775
|
+
name,
|
|
4776
|
+
count: countItems(node!.folders.get(name)!),
|
|
4777
|
+
key: [...folder, name].join("/"),
|
|
4778
|
+
}))
|
|
4779
|
+
: []
|
|
4780
|
+
}
|
|
4781
|
+
onFolderPress={(name) => setFolder([...folder, name])}
|
|
4782
|
+
showChecks={showChecks}
|
|
4783
|
+
checked={checked}
|
|
4784
|
+
onToggleEntry={toggle}
|
|
4785
|
+
showFolderChecks={canBulkMove}
|
|
4786
|
+
checkedFolders={checkedFolders}
|
|
4787
|
+
onToggleFolder={toggleFolder}
|
|
4788
|
+
// Pagination is driven by the flat list; the folder drill-down loads
|
|
4789
|
+
// the whole set up front, so hasMore is false there.
|
|
4790
|
+
hasMore={!hierarchical && hasMoreEntries}
|
|
4791
|
+
loadingMore={loadingMoreEntries}
|
|
4792
|
+
onLoadMore={onLoadMoreEntries}
|
|
4793
|
+
/>
|
|
4648
4794
|
);
|
|
4649
4795
|
}
|
|
4650
4796
|
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
sets={mediaSets}
|
|
4660
|
-
selected={mediaSet}
|
|
4661
|
-
onSelect={(name) => onSelectNav(mediaNavKey(name))}
|
|
4662
|
-
/>
|
|
4663
|
-
</Pane>
|
|
4664
|
-
<ResizeHandle width={listW} min={240} max={520} onChange={setListW} testID="resize-media-sets" />
|
|
4797
|
+
// The master pane, expandable to the whole screen, and gone while a document
|
|
4798
|
+
// column has it. Its body owns its own scrolling (a FlatList, or the sections
|
|
4799
|
+
// ScrollView), so the pane itself does not.
|
|
4800
|
+
const masterPane = (
|
|
4801
|
+
<Pane width={masterExpanded ? undefined : masterW} testID="pane-master" scroll={false} header={masterHeaderNode}>
|
|
4802
|
+
{masterBody}
|
|
4803
|
+
</Pane>
|
|
4804
|
+
);
|
|
4665
4805
|
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
{
|
|
4674
|
-
|
|
4806
|
+
// The detail region, by what the master has pushed. A plugin screen and an
|
|
4807
|
+
// open form take it whole; Media browses the chosen set; the changes surface
|
|
4808
|
+
// shows the selected diff; the editor shows its document columns.
|
|
4809
|
+
let detail: React.ReactNode;
|
|
4810
|
+
if (changes) {
|
|
4811
|
+
detail = (
|
|
4812
|
+
<ChangeDetail
|
|
4813
|
+
change={props.selectedChange}
|
|
4814
|
+
loading={props.loadingChange}
|
|
4815
|
+
emptyMessage={activeNavKey ? "Select a change" : (entriesEmpty ?? "No changes on this branch")}
|
|
4816
|
+
conflicts={props.selectedConflicts}
|
|
4817
|
+
onResolveConflict={props.onResolveConflict}
|
|
4818
|
+
/>
|
|
4675
4819
|
);
|
|
4676
|
-
}
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
<
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
trailing={createBtn}
|
|
4820
|
+
} else if (props.contentSlot != null) {
|
|
4821
|
+
detail = (
|
|
4822
|
+
<Pane flex={1} testID="pane-plugin" scroll={false} borderRight={false}>
|
|
4823
|
+
{props.contentSlot}
|
|
4824
|
+
</Pane>
|
|
4825
|
+
);
|
|
4826
|
+
} else if (formsShowing && props.forms) {
|
|
4827
|
+
detail = activeForm ? (
|
|
4828
|
+
<Pane flex={1} testID="pane-forms" scroll={false} borderRight={false}>
|
|
4829
|
+
<FormsBrowser
|
|
4830
|
+
api={props.forms}
|
|
4831
|
+
form={activeForm}
|
|
4832
|
+
onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
|
|
4833
|
+
selectedId={props.selectedSubmissionId ?? null}
|
|
4834
|
+
onSelectSubmission={props.onSelectSubmission}
|
|
4835
|
+
variant="desktop"
|
|
4693
4836
|
/>
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
4712
|
-
<Text variant="body" color="tertiary" testID="entries-empty">{entriesEmpty}</Text>
|
|
4713
|
-
</View>
|
|
4714
|
-
) : (
|
|
4715
|
-
<EntriesList
|
|
4716
|
-
variant="desktop"
|
|
4717
|
-
entries={visibleEntries}
|
|
4718
|
-
selectedId={selectedId}
|
|
4719
|
-
onOpenEntry={openEntry}
|
|
4720
|
-
header={
|
|
4721
|
-
showChecks && selectedCount > 0 && (onBulkDelete || canBulkMove) ? (
|
|
4722
|
-
<SelectionBar
|
|
4723
|
-
count={selectedCount}
|
|
4724
|
-
onClear={clear}
|
|
4725
|
-
onMove={canBulkMove ? () => setMovePrompt(true) : undefined}
|
|
4726
|
-
onDelete={onBulkDelete ? async () => { await onBulkDelete(moveIds); clear(); } : undefined}
|
|
4727
|
-
/>
|
|
4728
|
-
) : (
|
|
4729
|
-
<View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2), flexDirection: "row", alignItems: "center", gap: t.space(1.5) }}>
|
|
4730
|
-
<Text variant="label" color="tertiary">
|
|
4731
|
-
{`${totalEntries ?? entries.length} entries`}
|
|
4732
|
-
</Text>
|
|
4733
|
-
{/* The count is of documents with unsaved edits in this
|
|
4734
|
-
browser — the same state the rows below badge as Draft.
|
|
4735
|
-
It replaced workspace.changed here, which every host
|
|
4736
|
-
passed as a constant and which therefore told nobody
|
|
4737
|
-
anything. Rendered only when there is something to
|
|
4738
|
-
report: a permanent "0 uncommitted" is furniture. */}
|
|
4739
|
-
{draftsOpen > 0 ? (
|
|
4740
|
-
<>
|
|
4741
|
-
<Text variant="label" color="tertiary">·</Text>
|
|
4742
|
-
<Text variant="label" color="primary" testID="uncommitted-count">
|
|
4743
|
-
{`${draftsOpen} uncommitted`}
|
|
4744
|
-
</Text>
|
|
4745
|
-
</>
|
|
4746
|
-
) : null}
|
|
4747
|
-
</View>
|
|
4748
|
-
)
|
|
4749
|
-
}
|
|
4750
|
-
upRow={
|
|
4751
|
-
hierarchical && folder.length > 0 ? (
|
|
4752
|
-
<PressableRow onPress={() => setFolder(folder.slice(0, -1))}>
|
|
4753
|
-
<UpListItem parentLabel={parentLabel(folder, activeLabel)} currentPath={folder.join("/")} />
|
|
4754
|
-
</PressableRow>
|
|
4755
|
-
) : null
|
|
4756
|
-
}
|
|
4757
|
-
folders={
|
|
4758
|
-
hierarchical
|
|
4759
|
-
? folderNames.map((name) => ({
|
|
4760
|
-
name,
|
|
4761
|
-
count: countItems(node!.folders.get(name)!),
|
|
4762
|
-
key: [...folder, name].join("/"),
|
|
4763
|
-
}))
|
|
4764
|
-
: []
|
|
4765
|
-
}
|
|
4766
|
-
onFolderPress={(name) => setFolder([...folder, name])}
|
|
4767
|
-
showChecks={showChecks}
|
|
4768
|
-
checked={checked}
|
|
4769
|
-
onToggleEntry={toggle}
|
|
4770
|
-
showFolderChecks={canBulkMove}
|
|
4771
|
-
checkedFolders={checkedFolders}
|
|
4772
|
-
onToggleFolder={toggleFolder}
|
|
4773
|
-
// Pagination is driven by the flat list; the folder drill-down loads
|
|
4774
|
-
// the whole set up front, so hasMore is false there.
|
|
4775
|
-
hasMore={!hierarchical && hasMoreEntries}
|
|
4776
|
-
loadingMore={loadingMoreEntries}
|
|
4777
|
-
onLoadMore={onLoadMoreEntries}
|
|
4778
|
-
/>
|
|
4779
|
-
)}
|
|
4780
|
-
</Pane>
|
|
4781
|
-
)}
|
|
4782
|
-
|
|
4783
|
-
{/* draggable seam: resize the entry list (only while it's expanded) */}
|
|
4784
|
-
{listCollapsed ? null : (
|
|
4785
|
-
<ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-list" />
|
|
4786
|
-
)}
|
|
4787
|
-
|
|
4788
|
-
{/* detail — a horizontally-scrolling row of side-by-side document columns.
|
|
4789
|
-
Each open column is its own editor focus-mode viewport (see `column`). */}
|
|
4837
|
+
</Pane>
|
|
4838
|
+
) : (
|
|
4839
|
+
emptyDetail("Select a form", "forms-empty")
|
|
4840
|
+
);
|
|
4841
|
+
} else if (mediaShowing) {
|
|
4842
|
+
detail = mediaSet ? (
|
|
4843
|
+
<MediaBrowser set={mediaSet} label={mediaSetLabel(mediaSets, mediaSet)} variant="desktop" />
|
|
4844
|
+
) : (
|
|
4845
|
+
emptyDetail("Select a media collection", "media-sets-empty")
|
|
4846
|
+
);
|
|
4847
|
+
} else {
|
|
4848
|
+
// A horizontally-scrolling row of side-by-side document columns. Each open
|
|
4849
|
+
// column is its own editor focus-mode viewport (see `columnView`). Unmounted
|
|
4850
|
+
// while the master has the screen; the columns keep their state through
|
|
4851
|
+
// that, since the host owns the open-tab set and the drafts live outside
|
|
4852
|
+
// this tree.
|
|
4853
|
+
detail = (
|
|
4790
4854
|
<View
|
|
4791
4855
|
testID="pane-detail"
|
|
4792
4856
|
ref={detailRef}
|
|
@@ -4809,27 +4873,13 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4809
4873
|
scrollEventThrottle={16}
|
|
4810
4874
|
onScroll={(ev) => { scrollXRef.current = ev.nativeEvent.contentOffset.x; }}
|
|
4811
4875
|
>
|
|
4812
|
-
{
|
|
4876
|
+
{shownColumns.flatMap((col, i) => {
|
|
4813
4877
|
const nodes: React.ReactNode[] = [];
|
|
4814
4878
|
if (dragId && overSlot === i) nodes.push(dropIndicator(`drop-${i}`));
|
|
4815
|
-
nodes.push(
|
|
4816
|
-
collapsedCols[col.id] ? (
|
|
4817
|
-
<CollapsedRail
|
|
4818
|
-
key={col.id}
|
|
4819
|
-
testID={`column-${col.id}`}
|
|
4820
|
-
label={col.title || col.entry?.title || "Untitled"}
|
|
4821
|
-
onExpand={() => setCollapsedCols((c) => ({ ...c, [col.id]: false }))}
|
|
4822
|
-
onClose={() => closeColumn(col.id)}
|
|
4823
|
-
dragHandle={columnGrip(col.id)}
|
|
4824
|
-
dimmed={dragId === col.id}
|
|
4825
|
-
/>
|
|
4826
|
-
) : (
|
|
4827
|
-
columnView(col)
|
|
4828
|
-
),
|
|
4829
|
-
);
|
|
4879
|
+
nodes.push(columnView(col));
|
|
4830
4880
|
return nodes;
|
|
4831
4881
|
})}
|
|
4832
|
-
{dragId && overSlot ===
|
|
4882
|
+
{dragId && overSlot === shownColumns.length ? dropIndicator("drop-end") : null}
|
|
4833
4883
|
{showCreate ? (
|
|
4834
4884
|
<View
|
|
4835
4885
|
testID="column-create"
|
|
@@ -4854,12 +4904,33 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4854
4904
|
</ScrollView>
|
|
4855
4905
|
) : (
|
|
4856
4906
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
|
|
4907
|
+
{/* With nothing pushed the region says what to do; a pushed list
|
|
4908
|
+
says what to pick from it; a direct item is on its way and
|
|
4909
|
+
needs no prompt. */}
|
|
4857
4910
|
<Text variant="body" color="tertiary" testID="detail-empty">
|
|
4858
|
-
{browsing
|
|
4911
|
+
{!browsing
|
|
4912
|
+
? "No entry selected."
|
|
4913
|
+
: !activeNavKey
|
|
4914
|
+
? entriesEmpty
|
|
4915
|
+
: activeItem?.direct
|
|
4916
|
+
? ""
|
|
4917
|
+
: `Select ${activeLabel}`}
|
|
4859
4918
|
</Text>
|
|
4860
4919
|
</View>
|
|
4861
4920
|
)}
|
|
4862
4921
|
</View>
|
|
4922
|
+
);
|
|
4923
|
+
}
|
|
4924
|
+
|
|
4925
|
+
return (
|
|
4926
|
+
<AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
|
|
4927
|
+
{expandedCol ? null : masterPane}
|
|
4928
|
+
{/* draggable seam: resize the master pane — there is nothing to resize it
|
|
4929
|
+
against once a pane has taken the screen */}
|
|
4930
|
+
{masterExpanded || expandedCol ? null : (
|
|
4931
|
+
<ResizeHandle width={masterW} min={260} max={640} onChange={setMasterW} testID="resize-master" />
|
|
4932
|
+
)}
|
|
4933
|
+
{masterExpanded ? null : detail}
|
|
4863
4934
|
{movePrompt && onBulkMove ? (
|
|
4864
4935
|
<PromptDialog
|
|
4865
4936
|
title={`Move ${selectedCount} item${selectedCount === 1 ? "" : "s"}`}
|
|
@@ -5282,10 +5353,6 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
5282
5353
|
);
|
|
5283
5354
|
}
|
|
5284
5355
|
|
|
5285
|
-
// Width of a collapsed rail (the content list or a document column). Narrow,
|
|
5286
|
-
// full-height, with its label rotated 90° — clicking the body re-expands it.
|
|
5287
|
-
const RAIL_W = 44;
|
|
5288
|
-
|
|
5289
5356
|
// RN's typed `cursor` only allows auto/pointer; react-native-web forwards any CSS
|
|
5290
5357
|
// cursor to the DOM. Cast these desktop-only affordances through ViewStyle.
|
|
5291
5358
|
const CURSOR_COL_RESIZE = { cursor: "col-resize" } as unknown as ViewStyle;
|
|
@@ -5362,14 +5429,15 @@ function ResizeHandle({
|
|
|
5362
5429
|
|
|
5363
5430
|
// CustomColumn is a column whose body is a caller-supplied node rather than the
|
|
5364
5431
|
// document editor (OpenTab.content). It reproduces the editor column's header
|
|
5365
|
-
// chrome — grip, active accent,
|
|
5432
|
+
// chrome — grip, active accent, expand, close — so a plugin's column is a
|
|
5366
5433
|
// peer of the document columns rather than something visibly bolted on. The
|
|
5367
5434
|
// body gets no padding and no scroll container: an iframe wants the whole box.
|
|
5368
5435
|
function CustomColumn({
|
|
5369
5436
|
title,
|
|
5370
5437
|
active,
|
|
5371
5438
|
onActivate,
|
|
5372
|
-
|
|
5439
|
+
expanded,
|
|
5440
|
+
onToggleExpand,
|
|
5373
5441
|
onClose,
|
|
5374
5442
|
dragHandle,
|
|
5375
5443
|
children,
|
|
@@ -5377,7 +5445,8 @@ function CustomColumn({
|
|
|
5377
5445
|
title: string;
|
|
5378
5446
|
active?: boolean;
|
|
5379
5447
|
onActivate?: () => void;
|
|
5380
|
-
|
|
5448
|
+
expanded?: boolean;
|
|
5449
|
+
onToggleExpand?: () => void;
|
|
5381
5450
|
onClose?: () => void;
|
|
5382
5451
|
dragHandle?: React.ReactNode;
|
|
5383
5452
|
children: React.ReactNode;
|
|
@@ -5399,8 +5468,15 @@ function CustomColumn({
|
|
|
5399
5468
|
<Pressable onPress={onActivate} style={{ flex: 1 }}>
|
|
5400
5469
|
<Text variant="monoSm" numberOfLines={1}>{title}</Text>
|
|
5401
5470
|
</Pressable>
|
|
5402
|
-
{
|
|
5403
|
-
<IconButton
|
|
5471
|
+
{onToggleExpand ? (
|
|
5472
|
+
<IconButton
|
|
5473
|
+
name={expanded ? "minimize" : "maximize"}
|
|
5474
|
+
size="sm"
|
|
5475
|
+
active={expanded}
|
|
5476
|
+
label={expanded ? "Collapse column" : "Expand column"}
|
|
5477
|
+
onPress={onToggleExpand}
|
|
5478
|
+
testID="column-expand"
|
|
5479
|
+
/>
|
|
5404
5480
|
) : null}
|
|
5405
5481
|
{onClose ? (
|
|
5406
5482
|
<IconButton name="x" size="sm" label="Close column" onPress={onClose} testID="column-close" />
|
|
@@ -5411,72 +5487,6 @@ function CustomColumn({
|
|
|
5411
5487
|
);
|
|
5412
5488
|
}
|
|
5413
5489
|
|
|
5414
|
-
// CollapsedRail is the thin vertical strip a collapsed pane becomes. It mirrors
|
|
5415
|
-
// the expanded pane's header: an optional top control row (a trailing slot such
|
|
5416
|
-
// as the create "+", and/or a close button) over a pressable body whose rotated
|
|
5417
|
-
// label matches the header's title. onExpand fires on a body press.
|
|
5418
|
-
function CollapsedRail({
|
|
5419
|
-
label,
|
|
5420
|
-
onExpand,
|
|
5421
|
-
onClose,
|
|
5422
|
-
trailing,
|
|
5423
|
-
dragHandle,
|
|
5424
|
-
dimmed,
|
|
5425
|
-
testID,
|
|
5426
|
-
}: {
|
|
5427
|
-
label: string;
|
|
5428
|
-
onExpand: () => void;
|
|
5429
|
-
onClose?: () => void;
|
|
5430
|
-
trailing?: React.ReactNode;
|
|
5431
|
-
// A drag grip (column rails) rendered above the close button in the control row.
|
|
5432
|
-
dragHandle?: React.ReactNode;
|
|
5433
|
-
// Dim the rail while it's the column being dragged.
|
|
5434
|
-
dimmed?: boolean;
|
|
5435
|
-
testID?: string;
|
|
5436
|
-
}) {
|
|
5437
|
-
const t = useTheme();
|
|
5438
|
-
return (
|
|
5439
|
-
<View
|
|
5440
|
-
testID={testID}
|
|
5441
|
-
style={{
|
|
5442
|
-
width: RAIL_W, alignSelf: "stretch", minHeight: 0, overflow: "hidden",
|
|
5443
|
-
borderRightWidth: 1, borderRightColor: t.color.borderSubtle,
|
|
5444
|
-
backgroundColor: t.color.surfacePage,
|
|
5445
|
-
opacity: dimmed ? 0.5 : 1,
|
|
5446
|
-
}}
|
|
5447
|
-
>
|
|
5448
|
-
{trailing || onClose || dragHandle ? (
|
|
5449
|
-
<View
|
|
5450
|
-
style={{
|
|
5451
|
-
height: t.layout.topbar, alignItems: "center", justifyContent: "center", gap: t.space(1),
|
|
5452
|
-
borderBottomWidth: 1, borderBottomColor: t.color.borderSubtle,
|
|
5453
|
-
}}
|
|
5454
|
-
>
|
|
5455
|
-
{dragHandle}
|
|
5456
|
-
{trailing}
|
|
5457
|
-
{onClose ? <IconButton name="x" size="sm" label="Close" onPress={onClose} testID="rail-close" /> : null}
|
|
5458
|
-
</View>
|
|
5459
|
-
) : null}
|
|
5460
|
-
<Pressable
|
|
5461
|
-
onPress={onExpand}
|
|
5462
|
-
style={({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => ({
|
|
5463
|
-
flex: 1, alignItems: "center", justifyContent: "center", gap: t.space(3),
|
|
5464
|
-
backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
|
|
5465
|
-
})}
|
|
5466
|
-
>
|
|
5467
|
-
<Icon name="chevronRight" size={14} color={t.color.textTertiary} />
|
|
5468
|
-
{/* rotate doesn't change the layout box, so the pre-rotation width becomes
|
|
5469
|
-
the label's on-screen height; overflow:hidden on the rail clips it. */}
|
|
5470
|
-
<View style={{ transform: [{ rotate: "90deg" }] }}>
|
|
5471
|
-
<Text variant="monoSm" weight="medium" numberOfLines={1} style={{ width: 240, textAlign: "center" }}>
|
|
5472
|
-
{label}
|
|
5473
|
-
</Text>
|
|
5474
|
-
</View>
|
|
5475
|
-
</Pressable>
|
|
5476
|
-
</View>
|
|
5477
|
-
);
|
|
5478
|
-
}
|
|
5479
|
-
|
|
5480
5490
|
// Small Pressable wrapper so list items get press feedback without duplicating.
|
|
5481
5491
|
// Row press feedback: the selected row holds a stronger tint (surfaceActive) so
|
|
5482
5492
|
// it stays distinct from the lighter hover tint (surfaceHover) on other rows.
|
|
@@ -5501,8 +5511,8 @@ function PressableRow({ children, onPress, active }: { children: React.ReactNode
|
|
|
5501
5511
|
|
|
5502
5512
|
/**
|
|
5503
5513
|
* Responsive CMS content browser. Renders the desktop multi-pane layout at/above
|
|
5504
|
-
* the desktop breakpoint and the mobile drill-down below it
|
|
5505
|
-
* drives the
|
|
5514
|
+
* the desktop breakpoint and the mobile drill-down below it. The same component
|
|
5515
|
+
* drives the native mobile app and the responsive web SPA.
|
|
5506
5516
|
*/
|
|
5507
5517
|
export function ContentBrowser(props: ContentBrowserProps) {
|
|
5508
5518
|
const { isDesktop } = useResponsive();
|
|
@@ -5538,23 +5548,6 @@ function mediaSetLabel(sets: MediaSetInfo[], name: string): string {
|
|
|
5538
5548
|
return set ? titleCaseWord(set.name) : titleCaseWord(name);
|
|
5539
5549
|
}
|
|
5540
5550
|
|
|
5541
|
-
// PaneTitle is the content pane's heading for the surfaces with no controls
|
|
5542
|
-
// beside it — Changes, Media, Forms.
|
|
5543
|
-
//
|
|
5544
|
-
// Deliberately the same Text the collection header uses (`listHeader`), and
|
|
5545
|
-
// nothing else. Pane's header row already supplies the height, the horizontal
|
|
5546
|
-
// padding and the rule beneath, so the padded wrapper this used to add sat
|
|
5547
|
-
// inside that padding and pushed the title a step right of every collection's —
|
|
5548
|
-
// at `body` rather than `h3`, so it read a size smaller too. Switching between
|
|
5549
|
-
// Posts and Media moved the heading twice over.
|
|
5550
|
-
function PaneTitle({ title }: { title: string }) {
|
|
5551
|
-
return (
|
|
5552
|
-
<Text variant="h3" weight="semibold" style={{ flex: 1 }}>
|
|
5553
|
-
{title}
|
|
5554
|
-
</Text>
|
|
5555
|
-
);
|
|
5556
|
-
}
|
|
5557
|
-
|
|
5558
5551
|
// MediaSetList is the content pane under Media: one row per media collection,
|
|
5559
5552
|
// sitting exactly where a collection's documents sit. Each row shows how many
|
|
5560
5553
|
// files the set holds and the glob it is discovered by, since that glob is the
|
|
@@ -5602,15 +5595,138 @@ function MediaSetList({
|
|
|
5602
5595
|
);
|
|
5603
5596
|
}
|
|
5604
5597
|
|
|
5605
|
-
// navRowActive reports whether a
|
|
5606
|
-
// button stays lit while a set beneath it is open ("media:uploads"),
|
|
5607
|
-
//
|
|
5608
|
-
//
|
|
5598
|
+
// navRowActive reports whether a sections row should read as current. The Media
|
|
5599
|
+
// button stays lit while a set beneath it is open ("media:uploads"), and Forms
|
|
5600
|
+
// while a form is, because neither the set nor the form is itself a row —
|
|
5601
|
+
// losing the highlight there would leave the sections showing nothing selected
|
|
5602
|
+
// while media or a form is plainly on screen.
|
|
5609
5603
|
function navRowActive(itemKey: string, activeNavKey: string): boolean {
|
|
5610
5604
|
if (itemKey === MEDIA_NAV_KEY) return isMediaNavKey(activeNavKey);
|
|
5605
|
+
if (itemKey === FORMS_NAV_KEY) return isFormsNavKey(activeNavKey);
|
|
5611
5606
|
return itemKey === activeNavKey;
|
|
5612
5607
|
}
|
|
5613
5608
|
|
|
5609
|
+
// navItemFor resolves the sections row a nav key belongs to — the row itself,
|
|
5610
|
+
// or, for a media set or a form, the Media or Forms row they sit beneath.
|
|
5611
|
+
function navItemFor(sections: CmsNavSection[], key: string): CmsNavItem | undefined {
|
|
5612
|
+
if (!key) return undefined;
|
|
5613
|
+
for (const s of sections) {
|
|
5614
|
+
for (const item of s.items) {
|
|
5615
|
+
if (navRowActive(item.key, key)) return item;
|
|
5616
|
+
}
|
|
5617
|
+
}
|
|
5618
|
+
return undefined;
|
|
5619
|
+
}
|
|
5620
|
+
|
|
5621
|
+
// MasterTitle heads the master pane while a list is pushed into it. It is the
|
|
5622
|
+
// list's name and, when there is more than one list to choose from, a menu of
|
|
5623
|
+
// the rest — so switching collections does not go back through the sections.
|
|
5624
|
+
// Direct items (singletons, plugin screens) are left out: they have no list to
|
|
5625
|
+
// switch to. With a single list to offer there is no menu at all, matching the
|
|
5626
|
+
// branch and project menus' rule against a dead affordance.
|
|
5627
|
+
//
|
|
5628
|
+
// The menu sits inside the pane's header, which stacks above the pane body and
|
|
5629
|
+
// clips to the pane — so it is positioned against the header row, spans the
|
|
5630
|
+
// pane's width, and scrolls past a height the pane could not show.
|
|
5631
|
+
function MasterTitle({
|
|
5632
|
+
label,
|
|
5633
|
+
sections,
|
|
5634
|
+
activeNavKey,
|
|
5635
|
+
onSelect,
|
|
5636
|
+
}: {
|
|
5637
|
+
label: string;
|
|
5638
|
+
sections: CmsNavSection[];
|
|
5639
|
+
activeNavKey: string;
|
|
5640
|
+
onSelect: (key: string) => void;
|
|
5641
|
+
}) {
|
|
5642
|
+
const t = useTheme();
|
|
5643
|
+
const [open, setOpen] = useState(false);
|
|
5644
|
+
const listed = sections
|
|
5645
|
+
.map((s) => ({ ...s, items: s.items.filter((i) => !i.direct) }))
|
|
5646
|
+
.filter((s) => s.items.length > 0);
|
|
5647
|
+
const count = listed.reduce((n, s) => n + s.items.length, 0);
|
|
5648
|
+
if (count < 2) {
|
|
5649
|
+
return (
|
|
5650
|
+
<Text variant="h3" weight="semibold" style={{ flex: 1 }} numberOfLines={1} testID="master-title">
|
|
5651
|
+
{label}
|
|
5652
|
+
</Text>
|
|
5653
|
+
);
|
|
5654
|
+
}
|
|
5655
|
+
const rowStyle = ({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
|
|
5656
|
+
flexDirection: "row" as const, alignItems: "center" as const, gap: t.space(2),
|
|
5657
|
+
paddingHorizontal: t.space(3), paddingVertical: t.space(2),
|
|
5658
|
+
backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
|
|
5659
|
+
});
|
|
5660
|
+
return (
|
|
5661
|
+
<>
|
|
5662
|
+
<Pressable
|
|
5663
|
+
testID="master-title"
|
|
5664
|
+
accessibilityRole="button"
|
|
5665
|
+
accessibilityLabel={`${label}. Switch collection`}
|
|
5666
|
+
aria-expanded={open}
|
|
5667
|
+
onPress={() => setOpen((v) => !v)}
|
|
5668
|
+
style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: t.space(1.5), minWidth: 0 }}
|
|
5669
|
+
>
|
|
5670
|
+
<Text variant="h3" weight="semibold" numberOfLines={1} style={{ flexShrink: 1 }}>{label}</Text>
|
|
5671
|
+
<Icon name="chevronDown" size={14} color={t.color.textTertiary} />
|
|
5672
|
+
</Pressable>
|
|
5673
|
+
{open ? (
|
|
5674
|
+
<>
|
|
5675
|
+
{/* A catcher so a click anywhere in the pane dismisses; above the
|
|
5676
|
+
header's other controls so none of them fires through it. */}
|
|
5677
|
+
<Pressable
|
|
5678
|
+
onPress={() => setOpen(false)}
|
|
5679
|
+
testID="master-menu-backdrop"
|
|
5680
|
+
style={{ position: "absolute", top: -2000, left: -2000, right: -2000, bottom: -2000, zIndex: 15 }}
|
|
5681
|
+
/>
|
|
5682
|
+
<View
|
|
5683
|
+
testID="master-menu"
|
|
5684
|
+
style={{
|
|
5685
|
+
position: "absolute", top: t.layout.topbar - t.space(1), left: t.space(2), right: t.space(2),
|
|
5686
|
+
maxHeight: 420, zIndex: 20,
|
|
5687
|
+
backgroundColor: t.color.surfaceRaised, borderWidth: 1, borderColor: t.color.borderDefault,
|
|
5688
|
+
borderRadius: t.radius.md, paddingVertical: t.space(1),
|
|
5689
|
+
}}
|
|
5690
|
+
>
|
|
5691
|
+
<ScrollView>
|
|
5692
|
+
{listed.map((section, i) => (
|
|
5693
|
+
<View key={section.title || `section-${i}`}>
|
|
5694
|
+
{section.title ? <SectionLabel>{section.title}</SectionLabel> : null}
|
|
5695
|
+
{section.items.map((item) => {
|
|
5696
|
+
const active = navRowActive(item.key, activeNavKey);
|
|
5697
|
+
return (
|
|
5698
|
+
<Pressable
|
|
5699
|
+
key={item.key}
|
|
5700
|
+
testID={`master-option-${item.key}`}
|
|
5701
|
+
accessibilityRole="menuitem"
|
|
5702
|
+
aria-current={active ? "page" : undefined}
|
|
5703
|
+
onPress={() => { setOpen(false); if (!active) onSelect(item.key); }}
|
|
5704
|
+
style={rowStyle}
|
|
5705
|
+
>
|
|
5706
|
+
<Icon
|
|
5707
|
+
name={active ? "check" : item.icon}
|
|
5708
|
+
size={15}
|
|
5709
|
+
color={active ? t.color.textPrimary : t.color.textSecondary}
|
|
5710
|
+
/>
|
|
5711
|
+
<Text variant="sm" weight={active ? "semibold" : "regular"} numberOfLines={1} style={{ flex: 1 }}>
|
|
5712
|
+
{item.label}
|
|
5713
|
+
</Text>
|
|
5714
|
+
{item.count != null ? (
|
|
5715
|
+
<Text variant="monoSm" color="tertiary">{String(item.count)}</Text>
|
|
5716
|
+
) : null}
|
|
5717
|
+
</Pressable>
|
|
5718
|
+
);
|
|
5719
|
+
})}
|
|
5720
|
+
</View>
|
|
5721
|
+
))}
|
|
5722
|
+
</ScrollView>
|
|
5723
|
+
</View>
|
|
5724
|
+
</>
|
|
5725
|
+
) : null}
|
|
5726
|
+
</>
|
|
5727
|
+
);
|
|
5728
|
+
}
|
|
5729
|
+
|
|
5614
5730
|
// navLabelFor finds the sidebar label for a nav key, so a pane header shows the
|
|
5615
5731
|
// same words the user clicked rather than re-deriving them from the key.
|
|
5616
5732
|
function navLabelFor(sections: CmsNavSection[], key: string): string | undefined {
|