@vespercms/admin 0.0.0-stage → 0.1.0-next.0
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 +119 -2
- package/dist/BodyEditor-CAGNfofv.js +3047 -0
- package/dist/a11y/FieldNotes.d.ts +23 -0
- package/dist/a11y/NotesDrawer.d.ts +23 -0
- package/dist/a11y/pdf.d.ts +3 -0
- package/dist/a11y/run.d.ts +3 -0
- package/dist/a11y/useNotes.d.ts +28 -0
- package/dist/app/App.d.ts +2 -0
- package/dist/app/Screen.d.ts +88 -0
- package/dist/app/UserMenu.d.ts +7 -0
- package/dist/app/api.d.ts +75 -0
- package/dist/app/appearance.d.ts +10 -0
- package/dist/app/browser-api.d.ts +17 -0
- package/dist/app/collection-icon.d.ts +12 -0
- package/dist/app/context.d.ts +23 -0
- package/dist/app/entries-changed.d.ts +11 -0
- package/dist/app/file-url.d.ts +13 -0
- package/dist/app/format.d.ts +4 -0
- package/dist/app/idb.d.ts +7 -0
- package/dist/app/load.d.ts +16 -0
- package/dist/app/media.d.ts +5 -0
- package/dist/app/navigation.d.ts +4 -0
- package/dist/app/pane-context.d.ts +19 -0
- package/dist/app/panes.d.ts +14 -0
- package/dist/app/router.d.ts +62 -0
- package/dist/body/BodyEditor.d.ts +42 -0
- package/dist/body/TableView.d.ts +2 -0
- package/dist/body/catalog.d.ts +47 -0
- package/dist/body/checks.d.ts +15 -0
- package/dist/body/chips.d.ts +31 -0
- package/dist/body/commands.d.ts +31 -0
- package/dist/body/document.d.ts +42 -0
- package/dist/body/insert.d.ts +69 -0
- package/dist/body/nodes.d.ts +38 -0
- package/dist/body/reveal.d.ts +13 -0
- package/dist/body/scroll.d.ts +16 -0
- package/dist/body/settings.d.ts +19 -0
- package/dist/body/shortcuts.d.ts +13 -0
- package/dist/body/table.d.ts +31 -0
- package/dist/body/ui.d.ts +30 -0
- package/dist/body/views.d.ts +9 -0
- package/dist/checks-C-0xDu-I.js +57 -0
- package/dist/cloud/CloudGate.d.ts +14 -0
- package/dist/cloud/access.d.ts +14 -0
- package/dist/cloud/changes.d.ts +26 -0
- package/dist/cloud/connection.d.ts +100 -0
- package/dist/cloud/editor.d.ts +44 -0
- package/dist/cloud/session.d.ts +62 -0
- package/dist/cloud/storage.d.ts +45 -0
- package/dist/editor/Field.d.ts +24 -0
- package/dist/editor/PagePicker.d.ts +20 -0
- package/dist/editor/arrange.d.ts +13 -0
- package/dist/editor/context.d.ts +23 -0
- package/dist/editor/copy.d.ts +9 -0
- package/dist/editor/reveal.d.ts +7 -0
- package/dist/editor/target.d.ts +21 -0
- package/dist/editor/values.d.ts +18 -0
- package/dist/geo-map-CT3OGD7O.js +110 -0
- package/dist/github/GitHubGate.d.ts +15 -0
- package/dist/github/api.d.ts +20 -0
- package/dist/github/cache.d.ts +2 -0
- package/dist/github/session.d.ts +35 -0
- package/dist/index.d.ts +3 -0
- package/dist/markdown-KUkv11EB.js +211 -0
- package/dist/media/MediaPicker.d.ts +28 -0
- package/dist/media/file-icon.d.ts +3 -0
- package/dist/media/formats.d.ts +23 -0
- package/dist/media/hygiene.d.ts +37 -0
- package/dist/media/upload.d.ts +31 -0
- package/dist/mount.d.ts +3 -0
- package/dist/pdf-m5s_uXJ3.js +24 -0
- package/dist/run-y4XRkg17.js +7 -0
- package/dist/schedule/ScheduleDialog.d.ts +28 -0
- package/dist/schedule/ScheduleSummary.d.ts +12 -0
- package/dist/schedule/ScheduledView.d.ts +8 -0
- package/dist/schedule/cloud.d.ts +21 -0
- package/dist/schedule/now.d.ts +5 -0
- package/dist/schedule/times.d.ts +37 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +18 -0
- package/dist/screens/Home.d.ts +6 -0
- package/dist/screens/ListPane.d.ts +6 -0
- package/dist/screens/MediaBrowser.d.ts +7 -0
- package/dist/screens/NotFound.d.ts +3 -0
- package/dist/screens/PageTree.d.ts +10 -0
- package/dist/screens/Redirects.d.ts +1 -0
- package/dist/screens/Unavailable.d.ts +8 -0
- package/dist/shortcuts-B_JMAvF9.js +4630 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6312 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/crops.d.ts +43 -0
- package/dist/widgets/focal-point.d.ts +15 -0
- package/dist/widgets/geo-map.d.ts +10 -0
- package/dist/widgets/geo.d.ts +16 -0
- package/dist/widgets/group.d.ts +41 -0
- package/dist/widgets/icon.d.ts +9 -0
- package/dist/widgets/image.d.ts +16 -0
- package/dist/widgets/markdown-lazy.d.ts +2 -0
- package/dist/widgets/markdown.d.ts +2 -0
- package/dist/widgets/reference.d.ts +12 -0
- package/dist/widgets/registry.d.ts +32 -0
- package/dist/widgets/special.d.ts +20 -0
- package/dist/widgets/union.d.ts +36 -0
- package/package.json +63 -4
- package/src/a11y/FieldNotes.tsx +56 -0
- package/src/a11y/NotesDrawer.tsx +81 -0
- package/src/a11y/pdf.ts +45 -0
- package/src/a11y/run.ts +12 -0
- package/src/a11y/useNotes.ts +139 -0
- package/src/app/App.tsx +156 -0
- package/src/app/Screen.tsx +416 -0
- package/src/app/UserMenu.tsx +95 -0
- package/src/app/api.ts +176 -0
- package/src/app/appearance.ts +54 -0
- package/src/app/browser-api.ts +173 -0
- package/src/app/collection-icon.ts +15 -0
- package/src/app/context.tsx +32 -0
- package/src/app/entries-changed.ts +48 -0
- package/src/app/file-url.tsx +44 -0
- package/src/app/format.ts +39 -0
- package/src/app/idb.ts +82 -0
- package/src/app/load.ts +40 -0
- package/src/app/media.ts +20 -0
- package/src/app/navigation.ts +4 -0
- package/src/app/pane-context.ts +25 -0
- package/src/app/panes.tsx +513 -0
- package/src/app/router.ts +158 -0
- package/src/body/BodyEditor.tsx +1139 -0
- package/src/body/TableView.tsx +700 -0
- package/src/body/catalog.ts +198 -0
- package/src/body/checks.ts +83 -0
- package/src/body/chips.ts +150 -0
- package/src/body/commands.ts +191 -0
- package/src/body/document.ts +388 -0
- package/src/body/insert.tsx +301 -0
- package/src/body/nodes.ts +287 -0
- package/src/body/reveal.ts +93 -0
- package/src/body/scroll.ts +53 -0
- package/src/body/settings.tsx +510 -0
- package/src/body/shortcuts.ts +31 -0
- package/src/body/table.ts +192 -0
- package/src/body/ui.ts +77 -0
- package/src/body/views.tsx +268 -0
- package/src/build.ts +6 -0
- package/src/cloud/CloudGate.tsx +245 -0
- package/src/cloud/access.ts +52 -0
- package/src/cloud/changes.tsx +474 -0
- package/src/cloud/connection.ts +281 -0
- package/src/cloud/editor.tsx +250 -0
- package/src/cloud/session.ts +289 -0
- package/src/cloud/storage.ts +212 -0
- package/src/editor/Field.tsx +182 -0
- package/src/editor/PagePicker.tsx +102 -0
- package/src/editor/arrange.ts +98 -0
- package/src/editor/context.tsx +31 -0
- package/src/editor/copy.ts +30 -0
- package/src/editor/reveal.ts +38 -0
- package/src/editor/target.ts +104 -0
- package/src/editor/values.ts +71 -0
- package/src/github/GitHubGate.tsx +261 -0
- package/src/github/api.ts +81 -0
- package/src/github/cache.ts +64 -0
- package/src/github/session.ts +146 -0
- package/src/index.ts +10 -0
- package/src/media/MediaPicker.tsx +302 -0
- package/src/media/file-icon.ts +17 -0
- package/src/media/formats.ts +262 -0
- package/src/media/hygiene.ts +220 -0
- package/src/media/upload.ts +47 -0
- package/src/mount.tsx +20 -0
- package/src/schedule/ScheduleDialog.tsx +300 -0
- package/src/schedule/ScheduleSummary.tsx +59 -0
- package/src/schedule/ScheduledView.tsx +294 -0
- package/src/schedule/cloud.ts +66 -0
- package/src/schedule/now.ts +18 -0
- package/src/schedule/times.ts +133 -0
- package/src/screens/EntryEditor.tsx +1803 -0
- package/src/screens/EntryList.tsx +274 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +459 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +718 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/studio.css +163 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +311 -0
- package/src/widgets/crops.tsx +399 -0
- package/src/widgets/focal-point.tsx +240 -0
- package/src/widgets/geo-map.tsx +160 -0
- package/src/widgets/geo.tsx +170 -0
- package/src/widgets/group.tsx +827 -0
- package/src/widgets/icon.tsx +47 -0
- package/src/widgets/image.tsx +407 -0
- package/src/widgets/markdown-lazy.tsx +22 -0
- package/src/widgets/markdown.tsx +267 -0
- package/src/widgets/reference.tsx +196 -0
- package/src/widgets/registry.ts +104 -0
- package/src/widgets/special.tsx +286 -0
- package/src/widgets/union.tsx +233 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import type { CollectionManifest } from '@vespercms/core';
|
|
2
|
+
import { cx, focusRing, Icon } from '@vespercms/ui';
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
type CSSProperties,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { shownTo } from '../cloud/access.ts';
|
|
13
|
+
import { DraftsWaiting, useDraftsWaiting } from '../cloud/changes.tsx';
|
|
14
|
+
import { EntryEditor } from '../screens/EntryEditor.tsx';
|
|
15
|
+
import { EntryList } from '../screens/EntryList.tsx';
|
|
16
|
+
import { Home } from '../screens/Home.tsx';
|
|
17
|
+
import { ListPane } from '../screens/ListPane.tsx';
|
|
18
|
+
import { MediaBrowser } from '../screens/MediaBrowser.tsx';
|
|
19
|
+
import { NotFound } from '../screens/NotFound.tsx';
|
|
20
|
+
import { PageTree } from '../screens/PageTree.tsx';
|
|
21
|
+
import { Redirects } from '../screens/Redirects.tsx';
|
|
22
|
+
import { ScheduledLine, ScheduledView } from '../schedule/ScheduledView.tsx';
|
|
23
|
+
import { collectionIcon } from './collection-icon.ts';
|
|
24
|
+
import type { Studio } from './context.tsx';
|
|
25
|
+
import { useLoad } from './load.ts';
|
|
26
|
+
import { hrefFor, type Route } from './router.ts';
|
|
27
|
+
import { PaneContext, usePane, type PaneInfo } from './pane-context.ts';
|
|
28
|
+
import { PaneFrame, Screen, StudioHeader, useLeadHeading, useMeasure } from './Screen.tsx';
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* The studio on wide screens, full width: panes side by side, Sanity-style
|
|
32
|
+
* (DECISIONS.md, "The studio is panes, full width"). The structure pane
|
|
33
|
+
* lists the site's collections and is always there, so getting back to them
|
|
34
|
+
* is one click. A collection's list is always a pane of its own, with all of
|
|
35
|
+
* the list's tools, and an entry opens beside it. Each pane scrolls on its
|
|
36
|
+
* own. People can collapse any pane but the deepest into a strip, and the
|
|
37
|
+
* studio folds the earlier ones itself when they don't all fit; a strip
|
|
38
|
+
* opens again with a click. With nothing chosen, the structure pane is the
|
|
39
|
+
* page itself. Narrower than 48rem the studio is one screen at a time, as
|
|
40
|
+
* before.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** Wide enough for panes: below it, one screen at a time. */
|
|
44
|
+
export const PANES = '(min-width: 48rem)';
|
|
45
|
+
|
|
46
|
+
type Width = PaneInfo['width'];
|
|
47
|
+
|
|
48
|
+
/** Least widths in rem. A folded pane is a strip. */
|
|
49
|
+
const WIDTH: Record<Width, number> = { structure: 15, list: 22, flex: 30 };
|
|
50
|
+
const STRIP = 2.75;
|
|
51
|
+
|
|
52
|
+
const widthClass: Record<Width, string> = {
|
|
53
|
+
structure: 'w-60 shrink-0',
|
|
54
|
+
list: 'w-88 shrink-0',
|
|
55
|
+
flex: 'min-w-0 flex-1',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function useWindowRem(): number {
|
|
59
|
+
return useSyncExternalStore(
|
|
60
|
+
(onChange) => {
|
|
61
|
+
window.addEventListener('resize', onChange);
|
|
62
|
+
return () => {
|
|
63
|
+
window.removeEventListener('resize', onChange);
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
() => window.innerWidth / 16,
|
|
67
|
+
() => 80,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The panes people may collapse, by kind, so a choice holds from one collection to the next. */
|
|
72
|
+
type Kind = 'structure' | 'list';
|
|
73
|
+
|
|
74
|
+
type PaneSpec = {
|
|
75
|
+
id: string;
|
|
76
|
+
kind?: Kind;
|
|
77
|
+
title: string;
|
|
78
|
+
width: Width;
|
|
79
|
+
content: ReactNode;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const COLLAPSED_KEY = 'vesper:collapsed-panes';
|
|
83
|
+
|
|
84
|
+
function readCollapsed(): Set<Kind> {
|
|
85
|
+
try {
|
|
86
|
+
const value = localStorage.getItem(COLLAPSED_KEY) ?? '';
|
|
87
|
+
return new Set(value.split(',').filter((k): k is Kind => k === 'structure' || k === 'list'));
|
|
88
|
+
} catch {
|
|
89
|
+
return new Set();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Panes people collapsed, kept per viewer in this browser. */
|
|
94
|
+
function useCollapsed(): [Set<Kind>, (kind: Kind, collapsed: boolean) => void] {
|
|
95
|
+
const [collapsed, setCollapsed] = useState(readCollapsed);
|
|
96
|
+
const set = useCallback((kind: Kind, value: boolean) => {
|
|
97
|
+
setCollapsed((now) => {
|
|
98
|
+
const next = new Set(now);
|
|
99
|
+
if (value) next.add(kind);
|
|
100
|
+
else next.delete(kind);
|
|
101
|
+
try {
|
|
102
|
+
localStorage.setItem(COLLAPSED_KEY, [...next].join(','));
|
|
103
|
+
} catch {
|
|
104
|
+
// Kept for this visit only.
|
|
105
|
+
}
|
|
106
|
+
return next;
|
|
107
|
+
});
|
|
108
|
+
}, []);
|
|
109
|
+
return [collapsed, set];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Which panes fold: the ones people collapsed, then, while they don't fit,
|
|
114
|
+
* the earliest, keeping the deepest and the one opened by hand.
|
|
115
|
+
*/
|
|
116
|
+
function folded(
|
|
117
|
+
panes: PaneSpec[],
|
|
118
|
+
room: number,
|
|
119
|
+
collapsed: Set<Kind>,
|
|
120
|
+
keep: string | null,
|
|
121
|
+
): Set<string> {
|
|
122
|
+
const last = panes.at(-1);
|
|
123
|
+
const out = new Set(
|
|
124
|
+
panes
|
|
125
|
+
.filter((pane) => pane !== last && pane.kind !== undefined && collapsed.has(pane.kind))
|
|
126
|
+
.map((pane) => pane.id),
|
|
127
|
+
);
|
|
128
|
+
const need = () =>
|
|
129
|
+
panes.reduce((sum, pane) => sum + (out.has(pane.id) ? STRIP : WIDTH[pane.width]), 0);
|
|
130
|
+
for (const pane of panes.slice(0, -1)) {
|
|
131
|
+
if (need() <= room) break;
|
|
132
|
+
if (pane.id !== keep) out.add(pane.id);
|
|
133
|
+
}
|
|
134
|
+
// Still too wide with the opened pane open: fold it too, rather than the deepest.
|
|
135
|
+
for (const pane of panes.slice(0, -1)) {
|
|
136
|
+
if (need() <= room) break;
|
|
137
|
+
out.add(pane.id);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The studio's screens. Wide, they're panes; narrow, the deepest pane alone
|
|
144
|
+
* is the screen. Both are the same tree, so crossing the width keeps the
|
|
145
|
+
* open entry as it is, unsaved changes included.
|
|
146
|
+
*/
|
|
147
|
+
export function PaneShell({
|
|
148
|
+
route,
|
|
149
|
+
studio,
|
|
150
|
+
wide,
|
|
151
|
+
}: {
|
|
152
|
+
route: Route;
|
|
153
|
+
studio: Studio;
|
|
154
|
+
wide: boolean;
|
|
155
|
+
}) {
|
|
156
|
+
const room = useWindowRem();
|
|
157
|
+
const [collapsed, setCollapsed] = useCollapsed();
|
|
158
|
+
const [opened, setOpened] = useState<{ key: string; pane: string } | null>(null);
|
|
159
|
+
const routeKey = JSON.stringify(route);
|
|
160
|
+
const all = panesFor(route, studio, wide);
|
|
161
|
+
const panes = wide ? all : all.slice(-1);
|
|
162
|
+
// A strip the studio folded, opened by hand, stays open until the next navigation.
|
|
163
|
+
const keep = opened?.key === routeKey ? opened.pane : null;
|
|
164
|
+
const fold = wide ? folded(panes, room, collapsed, keep) : new Set<string>();
|
|
165
|
+
const last = panes.at(-1);
|
|
166
|
+
const home = route.screen === 'home' || route.screen === 'changes';
|
|
167
|
+
|
|
168
|
+
// Collapsing removes the button that was pressed, so focus goes to the strip in its place,
|
|
169
|
+
// and back to the collapse button when the strip opens the pane again.
|
|
170
|
+
const row = useRef<HTMLDivElement>(null);
|
|
171
|
+
const focusNext = useRef<string | null>(null);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
const selector = focusNext.current;
|
|
174
|
+
if (selector === null) return;
|
|
175
|
+
focusNext.current = null;
|
|
176
|
+
row.current?.querySelector<HTMLElement>(selector)?.focus();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// The top bar floats over the panes, which scroll from the window's top beneath it, so its
|
|
180
|
+
// glass has something to frost. Each pane starts its content below it (PaneFrame).
|
|
181
|
+
const [bar, barHeight] = useMeasure('height', 56);
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div
|
|
185
|
+
className={wide ? 'relative h-dvh overflow-hidden' : undefined}
|
|
186
|
+
style={wide ? ({ '--studio-bar': `${String(barHeight)}px` } as CSSProperties) : undefined}
|
|
187
|
+
>
|
|
188
|
+
{wide && (
|
|
189
|
+
<div ref={bar} className="absolute inset-x-0 top-0 z-40">
|
|
190
|
+
<StudioHeader />
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
{/* Positioned and clipped: screen-reader-only text deep in a pane's scrolling content is
|
|
194
|
+
positioned absolutely, and with no positioned ancestor it lengthened the page itself. */}
|
|
195
|
+
<div ref={row} className={wide ? 'relative flex h-full overflow-hidden' : undefined}>
|
|
196
|
+
{panes.map((pane) => {
|
|
197
|
+
const deepest = pane === last;
|
|
198
|
+
const kind = pane.kind;
|
|
199
|
+
const info: PaneInfo | null = wide
|
|
200
|
+
? {
|
|
201
|
+
landmark: deepest ? 'main' : 'nav',
|
|
202
|
+
level: deepest ? 1 : 2,
|
|
203
|
+
focusTarget: deepest,
|
|
204
|
+
width: pane.width,
|
|
205
|
+
...(!deepest && kind
|
|
206
|
+
? {
|
|
207
|
+
onCollapse: () => {
|
|
208
|
+
focusNext.current = `[data-strip="${CSS.escape(pane.id)}"]`;
|
|
209
|
+
setCollapsed(kind, true);
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
: {}),
|
|
213
|
+
}
|
|
214
|
+
: null;
|
|
215
|
+
return fold.has(pane.id) ? (
|
|
216
|
+
<PaneStrip
|
|
217
|
+
key={pane.id}
|
|
218
|
+
id={pane.id}
|
|
219
|
+
title={pane.title}
|
|
220
|
+
onOpen={() => {
|
|
221
|
+
focusNext.current = `[data-pane="${CSS.escape(pane.id)}"] [data-collapse]`;
|
|
222
|
+
if (kind && collapsed.has(kind)) setCollapsed(kind, false);
|
|
223
|
+
else setOpened({ key: routeKey, pane: pane.id });
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
) : (
|
|
227
|
+
<div
|
|
228
|
+
key={pane.id}
|
|
229
|
+
data-pane={pane.id}
|
|
230
|
+
className={
|
|
231
|
+
wide ? cx('flex min-h-0 flex-col', paneEdge, widthClass[pane.width]) : undefined
|
|
232
|
+
}
|
|
233
|
+
>
|
|
234
|
+
<PaneContext.Provider value={info}>{pane.content}</PaneContext.Provider>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
})}
|
|
238
|
+
{wide && last && last.width !== 'flex' && (home ? <WaitingPane /> : <Filler />)}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The hairline between panes, from the top bar's bottom edge down. The panes run up
|
|
246
|
+
* beneath the bar, and a line there showed through its glass as a smudge. In forced
|
|
247
|
+
* colours the bar is solid, and the plain border takes the system's colour.
|
|
248
|
+
*/
|
|
249
|
+
const paneEdge = cx(
|
|
250
|
+
'border-r border-border',
|
|
251
|
+
// Sliced only at the right, so the line's top lands exactly on the bar's height.
|
|
252
|
+
'[border-image:linear-gradient(transparent_var(--studio-bar),var(--color-border)_var(--studio-bar))_0_1_0_0]',
|
|
253
|
+
'forced-colors:[border-image:none]',
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
/** Room for the entry a list opens: empty until one is chosen. */
|
|
257
|
+
function Filler() {
|
|
258
|
+
return <div aria-hidden="true" data-filler="" className="min-w-0 flex-1 bg-surface-sunken" />;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const WAITING: PaneInfo = { landmark: 'region', level: 2, focusTarget: false, width: 'flex' };
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Beside the collections on Home, the cloud's drafts waiting to be
|
|
265
|
+
* published, and the filler when there are none: absent, not empty (spec
|
|
266
|
+
* §6.9). A region beside the page's `main`, never the deepest pane itself:
|
|
267
|
+
* drafts arrive in the background, and a pane that took the lead would move
|
|
268
|
+
* the `h1` and focus with them.
|
|
269
|
+
*/
|
|
270
|
+
function WaitingPane() {
|
|
271
|
+
if (useDraftsWaiting() === 0) return <Filler />;
|
|
272
|
+
return (
|
|
273
|
+
<div data-pane="changes" className={cx('flex min-h-0 flex-col', paneEdge, widthClass.flex)}>
|
|
274
|
+
<PaneContext.Provider value={WAITING}>
|
|
275
|
+
<Screen title="Changes" afterglow>
|
|
276
|
+
<DraftsWaiting />
|
|
277
|
+
</Screen>
|
|
278
|
+
</PaneContext.Provider>
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A folded pane: its name, sideways, and a button that opens it again. */
|
|
284
|
+
function PaneStrip({ id, title, onOpen }: { id: string; title: string; onOpen: () => void }) {
|
|
285
|
+
return (
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
data-strip={id}
|
|
289
|
+
onClick={onOpen}
|
|
290
|
+
aria-label={`Show ${title}`}
|
|
291
|
+
title={`Show ${title}`}
|
|
292
|
+
className={cx(
|
|
293
|
+
'flex w-11 shrink-0 flex-col items-center gap-3 text-small text-content-muted hover:bg-surface-sunken hover:text-content',
|
|
294
|
+
paneEdge,
|
|
295
|
+
focusRing,
|
|
296
|
+
)}
|
|
297
|
+
// Below the floating top bar, as a pane's content is.
|
|
298
|
+
style={{ paddingTop: 'calc(var(--studio-bar, 0px) + var(--spacing) * 4)' }}
|
|
299
|
+
>
|
|
300
|
+
<Icon name="pane-expand" size={18} />
|
|
301
|
+
<span aria-hidden="true" className="[writing-mode:vertical-rl]">
|
|
302
|
+
{title}
|
|
303
|
+
</span>
|
|
304
|
+
</button>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The panes a route shows, from the structure pane to the deepest. */
|
|
309
|
+
function panesFor(route: Route, studio: Studio, wide: boolean): PaneSpec[] {
|
|
310
|
+
const structure: PaneSpec = {
|
|
311
|
+
id: 'structure',
|
|
312
|
+
kind: 'structure',
|
|
313
|
+
title: 'Content',
|
|
314
|
+
width: 'structure',
|
|
315
|
+
content: <StructurePane route={route} studio={studio} />,
|
|
316
|
+
};
|
|
317
|
+
const flex = (id: string, title: string, content: ReactNode): PaneSpec => ({
|
|
318
|
+
id,
|
|
319
|
+
title,
|
|
320
|
+
width: 'flex',
|
|
321
|
+
content,
|
|
322
|
+
});
|
|
323
|
+
if (route.screen === 'home' || route.screen === 'changes') {
|
|
324
|
+
// Wide, Home beside the collections only repeated them, so they're the page.
|
|
325
|
+
// Narrow, Home is the one place that lists them with their counts.
|
|
326
|
+
return wide ? [structure] : [structure, flex('home', 'Content', <Home />)];
|
|
327
|
+
}
|
|
328
|
+
if (route.screen === 'media') return [structure, flex('media', 'Media', <MediaBrowser />)];
|
|
329
|
+
if (route.screen === 'redirects') {
|
|
330
|
+
return [structure, flex('redirects', 'Redirects', <Redirects />)];
|
|
331
|
+
}
|
|
332
|
+
if (route.screen === 'scheduled') {
|
|
333
|
+
return [structure, flex('scheduled', 'Scheduled', <ScheduledView />)];
|
|
334
|
+
}
|
|
335
|
+
if (
|
|
336
|
+
route.screen === 'list' ||
|
|
337
|
+
route.screen === 'edit' ||
|
|
338
|
+
route.screen === 'new' ||
|
|
339
|
+
route.screen === 'copy'
|
|
340
|
+
) {
|
|
341
|
+
const collection = studio.collection(route.collection);
|
|
342
|
+
if (!collection) {
|
|
343
|
+
return [
|
|
344
|
+
structure,
|
|
345
|
+
flex(
|
|
346
|
+
'missing',
|
|
347
|
+
'Not found',
|
|
348
|
+
<NotFound message={`This site has no collection called “${route.collection}”.`} />,
|
|
349
|
+
),
|
|
350
|
+
];
|
|
351
|
+
}
|
|
352
|
+
const currentId = route.screen === 'edit' ? route.id : null;
|
|
353
|
+
const editor =
|
|
354
|
+
route.screen === 'list' ? null : (
|
|
355
|
+
<EntryEditor
|
|
356
|
+
key={JSON.stringify(route)}
|
|
357
|
+
collection={collection}
|
|
358
|
+
id={route.screen === 'copy' ? route.id : currentId}
|
|
359
|
+
copy={route.screen === 'copy'}
|
|
360
|
+
{...(route.screen === 'new' && route.parent !== undefined
|
|
361
|
+
? { parent: route.parent }
|
|
362
|
+
: {})}
|
|
363
|
+
/>
|
|
364
|
+
);
|
|
365
|
+
const tree = collection.presentation.tree === true && collection.source.loader === 'glob';
|
|
366
|
+
// A singleton's list only leads to its one entry, so it opens beside the collections.
|
|
367
|
+
if (collection.presentation.singleton) {
|
|
368
|
+
return [
|
|
369
|
+
structure,
|
|
370
|
+
flex('entry', collection.label, editor ?? <EntryList collection={collection} />),
|
|
371
|
+
];
|
|
372
|
+
}
|
|
373
|
+
// The list is a pane of its own, with all its tools, whether or not an entry is open
|
|
374
|
+
// beside it. One screen at a time, a flat collection's list is its table.
|
|
375
|
+
const list: PaneSpec = {
|
|
376
|
+
id: `list:${collection.name}`,
|
|
377
|
+
kind: 'list',
|
|
378
|
+
title: collection.label,
|
|
379
|
+
width: 'list',
|
|
380
|
+
content: tree ? (
|
|
381
|
+
<PageTree collection={collection} currentId={currentId} />
|
|
382
|
+
) : wide ? (
|
|
383
|
+
<ListPane collection={collection} currentId={currentId} />
|
|
384
|
+
) : (
|
|
385
|
+
<EntryList collection={collection} />
|
|
386
|
+
),
|
|
387
|
+
};
|
|
388
|
+
return editor ? [structure, list, flex('entry', 'Entry', editor)] : [structure, list];
|
|
389
|
+
}
|
|
390
|
+
return [structure, flex('missing', 'Not found', <NotFound />)];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Where a route is, for the structure pane's current item. */
|
|
394
|
+
function hereOf(route: Route): string | null {
|
|
395
|
+
if ('collection' in route) return `c:${route.collection}`;
|
|
396
|
+
if (route.screen === 'media') return 'media';
|
|
397
|
+
if (route.screen === 'redirects') return 'redirects';
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* A row of the structure pane: square and edge to edge, with the pane's
|
|
403
|
+
* padding inside it, as the list pane's rows are. Plain links get no
|
|
404
|
+
* `data-focus-visible`, so their outline is `focus-visible`'s, drawn inside
|
|
405
|
+
* the row where the pane's scrolling can't clip it.
|
|
406
|
+
*/
|
|
407
|
+
const structureRow = cx(
|
|
408
|
+
'flex min-h-10 items-center gap-2 px-4 text-body text-content no-underline',
|
|
409
|
+
'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-focus',
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The structure pane: every collection, then settings, then the site's own
|
|
414
|
+
* places. Always on screen, so the site's content is one click away. With
|
|
415
|
+
* nothing chosen it's the deepest pane, so its heading is the page's `h1`.
|
|
416
|
+
*/
|
|
417
|
+
function StructurePane({ route, studio }: { route: Route; studio: Studio }) {
|
|
418
|
+
const { manifest, api, cloud, siteUrl } = studio;
|
|
419
|
+
const [summary] = useLoad(() => api.summary(), 'structure');
|
|
420
|
+
const heading = useRef<HTMLHeadingElement>(null);
|
|
421
|
+
useLeadHeading('Content', heading);
|
|
422
|
+
const Subheading = usePane()?.level === 1 ? 'h2' : 'h3';
|
|
423
|
+
const counts = new Map(
|
|
424
|
+
summary.state === 'ready' ? summary.value.map((s) => [s.collection, s.count]) : [],
|
|
425
|
+
);
|
|
426
|
+
const shown = manifest.collections.filter((c) => shownTo(cloud, c.name));
|
|
427
|
+
const collections = shown.filter((c) => !c.presentation.singleton);
|
|
428
|
+
const singletons = shown.filter((c) => c.presentation.singleton);
|
|
429
|
+
const here = hereOf(route);
|
|
430
|
+
|
|
431
|
+
const item = (
|
|
432
|
+
key: string,
|
|
433
|
+
href: string,
|
|
434
|
+
label: string,
|
|
435
|
+
icon: ReactNode,
|
|
436
|
+
detail?: string,
|
|
437
|
+
): ReactNode => {
|
|
438
|
+
const current = here === key;
|
|
439
|
+
return (
|
|
440
|
+
<li key={key}>
|
|
441
|
+
<a
|
|
442
|
+
href={href}
|
|
443
|
+
aria-current={current ? 'page' : undefined}
|
|
444
|
+
className={cx(
|
|
445
|
+
structureRow,
|
|
446
|
+
current
|
|
447
|
+
? 'bg-accent-subtle font-semibold hover:bg-accent-subtle-hover'
|
|
448
|
+
: 'hover:bg-surface-sunken',
|
|
449
|
+
)}
|
|
450
|
+
>
|
|
451
|
+
<span className="shrink-0 text-content-muted">{icon}</span>
|
|
452
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
453
|
+
{detail && <span className="shrink-0 text-caption text-content-muted">{detail}</span>}
|
|
454
|
+
</a>
|
|
455
|
+
</li>
|
|
456
|
+
);
|
|
457
|
+
};
|
|
458
|
+
const collectionItem = (collection: CollectionManifest) => {
|
|
459
|
+
const icon = collectionIcon(collection);
|
|
460
|
+
const count = counts.get(collection.name);
|
|
461
|
+
return item(
|
|
462
|
+
`c:${collection.name}`,
|
|
463
|
+
hrefFor({ screen: 'list', collection: collection.name }),
|
|
464
|
+
collection.label,
|
|
465
|
+
<Icon {...icon} size={18} />,
|
|
466
|
+
count !== undefined && !collection.presentation.singleton
|
|
467
|
+
? new Intl.NumberFormat().format(count)
|
|
468
|
+
: undefined,
|
|
469
|
+
);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
<PaneFrame title="Content" headingRef={heading}>
|
|
474
|
+
<div className="py-3">
|
|
475
|
+
<ScheduledLine className="mb-3 px-4" />
|
|
476
|
+
<ul className="flex flex-col gap-0.5">{collections.map(collectionItem)}</ul>
|
|
477
|
+
{singletons.length > 0 && (
|
|
478
|
+
<>
|
|
479
|
+
<Subheading className="mt-6 mb-1 px-4 text-small text-content-muted">
|
|
480
|
+
Settings
|
|
481
|
+
</Subheading>
|
|
482
|
+
<ul className="flex flex-col gap-0.5">{singletons.map(collectionItem)}</ul>
|
|
483
|
+
</>
|
|
484
|
+
)}
|
|
485
|
+
<Subheading className="mt-6 mb-1 px-4 text-small text-content-muted">Site</Subheading>
|
|
486
|
+
<ul className="flex flex-col gap-0.5">
|
|
487
|
+
{item('media', hrefFor({ screen: 'media' }), 'Media', <Icon name="media" size={18} />)}
|
|
488
|
+
{/* Redirects belong to the whole site (spec §7.5): a folder grant doesn't reach them. */}
|
|
489
|
+
{(!cloud || cloud.wholeSite) &&
|
|
490
|
+
item(
|
|
491
|
+
'redirects',
|
|
492
|
+
hrefFor({ screen: 'redirects' }),
|
|
493
|
+
'Redirects',
|
|
494
|
+
<Icon name="redirects" size={18} />,
|
|
495
|
+
)}
|
|
496
|
+
{siteUrl && (
|
|
497
|
+
<li>
|
|
498
|
+
<a
|
|
499
|
+
href={siteUrl}
|
|
500
|
+
target="_blank"
|
|
501
|
+
rel="noopener"
|
|
502
|
+
className={cx(structureRow, 'hover:bg-surface-sunken')}
|
|
503
|
+
>
|
|
504
|
+
<Icon name="view-on-site" size={18} className="text-content-muted" />
|
|
505
|
+
View site
|
|
506
|
+
</a>
|
|
507
|
+
</li>
|
|
508
|
+
)}
|
|
509
|
+
</ul>
|
|
510
|
+
</div>
|
|
511
|
+
</PaneFrame>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Hash routing (spec §6.1): `/admin/#/c/blog/e/…`. Deep links and reloads
|
|
5
|
+
* work on any static host without rewrite rules (D16). Entry IDs can contain
|
|
6
|
+
* slashes, so they are encoded as one segment.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type Route =
|
|
10
|
+
| { screen: 'home' }
|
|
11
|
+
| { screen: 'list'; collection: string }
|
|
12
|
+
| { screen: 'edit'; collection: string; id: string }
|
|
13
|
+
| { screen: 'new'; collection: string; parent?: string }
|
|
14
|
+
/** A new entry that starts as a copy of another (Duplicate, spec §10.3). */
|
|
15
|
+
| { screen: 'copy'; collection: string; id: string }
|
|
16
|
+
| { screen: 'media' }
|
|
17
|
+
| { screen: 'redirects' }
|
|
18
|
+
/** Every change still to come across the site (spec §6.17). */
|
|
19
|
+
| { screen: 'scheduled' }
|
|
20
|
+
/** Home with the cloud's Changes open: review emails link here. Home alone elsewhere. */
|
|
21
|
+
| { screen: 'changes' }
|
|
22
|
+
| { screen: 'not-found'; path: string };
|
|
23
|
+
|
|
24
|
+
export type KnownRoute = Exclude<Route, { screen: 'not-found' }>;
|
|
25
|
+
|
|
26
|
+
export function parseRoute(hash: string): Route {
|
|
27
|
+
const path = hash.replace(/^#/, '') || '/';
|
|
28
|
+
const parts = path.split('/').filter(Boolean).map(decodeURIComponent);
|
|
29
|
+
if (parts.length === 0) return { screen: 'home' };
|
|
30
|
+
if (parts[0] === 'media' && parts.length === 1) return { screen: 'media' };
|
|
31
|
+
if (parts[0] === 'redirects' && parts.length === 1) return { screen: 'redirects' };
|
|
32
|
+
if (parts[0] === 'scheduled' && parts.length === 1) return { screen: 'scheduled' };
|
|
33
|
+
if (parts[0] === 'changes' && parts.length === 1) return { screen: 'changes' };
|
|
34
|
+
if (parts[0] === 'c' && parts[1]) {
|
|
35
|
+
if (parts.length === 2) return { screen: 'list', collection: parts[1] };
|
|
36
|
+
if (parts[2] === 'new' && parts.length === 3) return { screen: 'new', collection: parts[1] };
|
|
37
|
+
// A new page under a parent in a tree collection (spec §6.15.2: "Add child page").
|
|
38
|
+
if (parts[2] === 'new' && parts[3] !== undefined && parts.length === 4) {
|
|
39
|
+
return { screen: 'new', collection: parts[1], parent: parts[3] };
|
|
40
|
+
}
|
|
41
|
+
if (parts[2] === 'e' && parts[3] !== undefined && parts.length === 4) {
|
|
42
|
+
return { screen: 'edit', collection: parts[1], id: parts[3] };
|
|
43
|
+
}
|
|
44
|
+
if (parts[2] === 'e' && parts[3] !== undefined && parts[4] === 'copy' && parts.length === 5) {
|
|
45
|
+
return { screen: 'copy', collection: parts[1], id: parts[3] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { screen: 'not-found', path };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function hrefFor(route: KnownRoute): string {
|
|
52
|
+
const enc = encodeURIComponent;
|
|
53
|
+
switch (route.screen) {
|
|
54
|
+
case 'home':
|
|
55
|
+
return '#/';
|
|
56
|
+
case 'media':
|
|
57
|
+
return '#/media';
|
|
58
|
+
case 'redirects':
|
|
59
|
+
return '#/redirects';
|
|
60
|
+
case 'scheduled':
|
|
61
|
+
return '#/scheduled';
|
|
62
|
+
case 'changes':
|
|
63
|
+
return '#/changes';
|
|
64
|
+
case 'list':
|
|
65
|
+
return `#/c/${enc(route.collection)}`;
|
|
66
|
+
case 'new':
|
|
67
|
+
return route.parent !== undefined
|
|
68
|
+
? `#/c/${enc(route.collection)}/new/${enc(route.parent)}`
|
|
69
|
+
: `#/c/${enc(route.collection)}/new`;
|
|
70
|
+
case 'edit':
|
|
71
|
+
return `#/c/${enc(route.collection)}/e/${enc(route.id)}`;
|
|
72
|
+
case 'copy':
|
|
73
|
+
return `#/c/${enc(route.collection)}/e/${enc(route.id)}/copy`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Goes to a route. `replace` swaps the current history entry (after creating an entry, say). */
|
|
78
|
+
export function navigate(route: KnownRoute, options: { replace?: boolean } = {}): void {
|
|
79
|
+
if (options.replace) {
|
|
80
|
+
window.location.replace(hrefFor(route));
|
|
81
|
+
} else {
|
|
82
|
+
window.location.hash = hrefFor(route);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The open screen's unsaved-changes check. Only one screen holds one at a
|
|
88
|
+
* time. Returns a function that removes it.
|
|
89
|
+
*/
|
|
90
|
+
let leaveGuard: (() => boolean) | null = null;
|
|
91
|
+
|
|
92
|
+
export function setLeaveGuard(hasUnsavedChanges: () => boolean): () => void {
|
|
93
|
+
leaveGuard = hasUnsavedChanges;
|
|
94
|
+
return () => {
|
|
95
|
+
if (leaveGuard === hasUnsavedChanges) leaveGuard = null;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether the open screen has unsaved changes. */
|
|
100
|
+
export function hasUnsavedChanges(): boolean {
|
|
101
|
+
return leaveGuard?.() ?? false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type RouterState = {
|
|
105
|
+
route: Route;
|
|
106
|
+
/** A navigation held back because the open screen has unsaved changes. */
|
|
107
|
+
pending: string | null;
|
|
108
|
+
/** Leave anyway, discarding the changes. */
|
|
109
|
+
leave: () => void;
|
|
110
|
+
/** Stay on the current screen. */
|
|
111
|
+
stay: () => void;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The current route. Every in-app navigation is a hash change, so this is
|
|
116
|
+
* also where unsaved changes are protected: when the guard says so, the
|
|
117
|
+
* hash is put back and the navigation waits for `leave()` or `stay()`.
|
|
118
|
+
*/
|
|
119
|
+
export function useRouter(): RouterState {
|
|
120
|
+
const [route, setRoute] = useState(() => parseRoute(window.location.hash));
|
|
121
|
+
const [pending, setPending] = useState<string | null>(null);
|
|
122
|
+
const current = useRef(window.location.hash);
|
|
123
|
+
const allowNext = useRef(false);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const update = () => {
|
|
127
|
+
const next = window.location.hash;
|
|
128
|
+
// Browsers can report one change twice (and tests dispatch their own); nothing moved.
|
|
129
|
+
if (next === current.current) return;
|
|
130
|
+
if (!allowNext.current && leaveGuard?.()) {
|
|
131
|
+
// replaceState doesn't fire hashchange, so this doesn't loop.
|
|
132
|
+
window.history.replaceState(null, '', current.current || '#/');
|
|
133
|
+
setPending(next);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
allowNext.current = false;
|
|
137
|
+
current.current = next;
|
|
138
|
+
setRoute(parseRoute(next));
|
|
139
|
+
};
|
|
140
|
+
window.addEventListener('hashchange', update);
|
|
141
|
+
return () => {
|
|
142
|
+
window.removeEventListener('hashchange', update);
|
|
143
|
+
};
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
const leave = useCallback(() => {
|
|
147
|
+
if (pending === null) return;
|
|
148
|
+
allowNext.current = true;
|
|
149
|
+
leaveGuard = null;
|
|
150
|
+
setPending(null);
|
|
151
|
+
window.location.hash = pending;
|
|
152
|
+
}, [pending]);
|
|
153
|
+
const stay = useCallback(() => {
|
|
154
|
+
setPending(null);
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
return { route, pending, leave, stay };
|
|
158
|
+
}
|