@vespercms/admin 0.0.0-stage → 0.1.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-_mkh0BSQ.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-4fVeej3c.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +35 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +16 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/screens/status.d.ts +36 -0
  89. package/dist/shortcuts-CarKeDqp.js +4666 -0
  90. package/dist/studio.css +3 -0
  91. package/dist/studio.js +6449 -0
  92. package/dist/types.d.ts +76 -0
  93. package/dist/widgets/basic.d.ts +25 -0
  94. package/dist/widgets/blank-date.d.ts +8 -0
  95. package/dist/widgets/crops.d.ts +43 -0
  96. package/dist/widgets/focal-point.d.ts +15 -0
  97. package/dist/widgets/geo-map.d.ts +10 -0
  98. package/dist/widgets/geo.d.ts +16 -0
  99. package/dist/widgets/group.d.ts +41 -0
  100. package/dist/widgets/icon.d.ts +9 -0
  101. package/dist/widgets/image.d.ts +16 -0
  102. package/dist/widgets/markdown-lazy.d.ts +2 -0
  103. package/dist/widgets/markdown.d.ts +2 -0
  104. package/dist/widgets/reference.d.ts +12 -0
  105. package/dist/widgets/registry.d.ts +32 -0
  106. package/dist/widgets/special.d.ts +20 -0
  107. package/dist/widgets/union.d.ts +36 -0
  108. package/package.json +63 -4
  109. package/src/a11y/FieldNotes.tsx +56 -0
  110. package/src/a11y/NotesDrawer.tsx +81 -0
  111. package/src/a11y/pdf.ts +45 -0
  112. package/src/a11y/run.ts +12 -0
  113. package/src/a11y/useNotes.ts +139 -0
  114. package/src/app/App.tsx +156 -0
  115. package/src/app/Screen.tsx +416 -0
  116. package/src/app/UserMenu.tsx +95 -0
  117. package/src/app/api.ts +176 -0
  118. package/src/app/appearance.ts +54 -0
  119. package/src/app/browser-api.ts +173 -0
  120. package/src/app/collection-icon.ts +15 -0
  121. package/src/app/context.tsx +32 -0
  122. package/src/app/entries-changed.ts +48 -0
  123. package/src/app/file-url.tsx +44 -0
  124. package/src/app/format.ts +39 -0
  125. package/src/app/idb.ts +82 -0
  126. package/src/app/load.ts +40 -0
  127. package/src/app/media.ts +20 -0
  128. package/src/app/navigation.ts +4 -0
  129. package/src/app/pane-context.ts +25 -0
  130. package/src/app/panes.tsx +513 -0
  131. package/src/app/router.ts +158 -0
  132. package/src/body/BodyEditor.tsx +1139 -0
  133. package/src/body/TableView.tsx +700 -0
  134. package/src/body/catalog.ts +198 -0
  135. package/src/body/checks.ts +83 -0
  136. package/src/body/chips.ts +150 -0
  137. package/src/body/commands.ts +191 -0
  138. package/src/body/document.ts +388 -0
  139. package/src/body/insert.tsx +301 -0
  140. package/src/body/nodes.ts +287 -0
  141. package/src/body/reveal.ts +93 -0
  142. package/src/body/scroll.ts +53 -0
  143. package/src/body/settings.tsx +510 -0
  144. package/src/body/shortcuts.ts +31 -0
  145. package/src/body/table.ts +192 -0
  146. package/src/body/ui.ts +77 -0
  147. package/src/body/views.tsx +268 -0
  148. package/src/build.ts +6 -0
  149. package/src/cloud/CloudGate.tsx +245 -0
  150. package/src/cloud/access.ts +52 -0
  151. package/src/cloud/changes.tsx +474 -0
  152. package/src/cloud/connection.ts +281 -0
  153. package/src/cloud/editor.tsx +250 -0
  154. package/src/cloud/session.ts +289 -0
  155. package/src/cloud/storage.ts +212 -0
  156. package/src/editor/Field.tsx +182 -0
  157. package/src/editor/PagePicker.tsx +102 -0
  158. package/src/editor/arrange.ts +98 -0
  159. package/src/editor/context.tsx +31 -0
  160. package/src/editor/copy.ts +30 -0
  161. package/src/editor/reveal.ts +38 -0
  162. package/src/editor/target.ts +104 -0
  163. package/src/editor/values.ts +71 -0
  164. package/src/github/GitHubGate.tsx +298 -0
  165. package/src/github/api.ts +81 -0
  166. package/src/github/cache.ts +64 -0
  167. package/src/github/session.ts +146 -0
  168. package/src/index.ts +10 -0
  169. package/src/media/MediaPicker.tsx +302 -0
  170. package/src/media/file-icon.ts +17 -0
  171. package/src/media/formats.ts +262 -0
  172. package/src/media/hygiene.ts +220 -0
  173. package/src/media/upload.ts +47 -0
  174. package/src/mount.tsx +20 -0
  175. package/src/schedule/ScheduleDialog.tsx +303 -0
  176. package/src/schedule/ScheduleSummary.tsx +59 -0
  177. package/src/schedule/ScheduledView.tsx +294 -0
  178. package/src/schedule/cloud.ts +66 -0
  179. package/src/schedule/now.ts +18 -0
  180. package/src/schedule/times.ts +122 -0
  181. package/src/screens/EntryEditor.tsx +1817 -0
  182. package/src/screens/EntryList.tsx +272 -0
  183. package/src/screens/Home.tsx +131 -0
  184. package/src/screens/ListPane.tsx +477 -0
  185. package/src/screens/MediaBrowser.tsx +253 -0
  186. package/src/screens/NotFound.tsx +19 -0
  187. package/src/screens/PageTree.tsx +738 -0
  188. package/src/screens/Redirects.tsx +522 -0
  189. package/src/screens/Unavailable.tsx +27 -0
  190. package/src/screens/status.tsx +104 -0
  191. package/src/studio.css +168 -0
  192. package/src/types.ts +82 -0
  193. package/src/widgets/basic.tsx +315 -0
  194. package/src/widgets/blank-date.ts +32 -0
  195. package/src/widgets/crops.tsx +399 -0
  196. package/src/widgets/focal-point.tsx +240 -0
  197. package/src/widgets/geo-map.tsx +160 -0
  198. package/src/widgets/geo.tsx +170 -0
  199. package/src/widgets/group.tsx +827 -0
  200. package/src/widgets/icon.tsx +47 -0
  201. package/src/widgets/image.tsx +407 -0
  202. package/src/widgets/markdown-lazy.tsx +22 -0
  203. package/src/widgets/markdown.tsx +267 -0
  204. package/src/widgets/reference.tsx +196 -0
  205. package/src/widgets/registry.ts +104 -0
  206. package/src/widgets/special.tsx +290 -0
  207. 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
+ }