@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,416 @@
1
+ import {
2
+ cx,
3
+ Icon,
4
+ IconButton,
5
+ Menu,
6
+ Tooltip,
7
+ VesperMark,
8
+ type IconName,
9
+ type IconNode,
10
+ } from '@vespercms/ui';
11
+ import {
12
+ useContext,
13
+ useEffect,
14
+ useId,
15
+ useLayoutEffect,
16
+ useRef,
17
+ useState,
18
+ type CSSProperties,
19
+ type ReactNode,
20
+ type Ref,
21
+ type RefObject,
22
+ type UIEvent,
23
+ } from 'react';
24
+ import { shownTo } from '../cloud/access.ts';
25
+ import { ChangesButton } from '../cloud/changes.tsx';
26
+ import { useStudio, VESPER_NAME } from './context.tsx';
27
+ import { NavigationContext } from './navigation.ts';
28
+ import { collectionIcon } from './collection-icon.ts';
29
+ import { hrefFor, navigate, parseRoute } from './router.ts';
30
+ import { usePane } from './pane-context.ts';
31
+ import { UserMenu } from './UserMenu.tsx';
32
+
33
+ export type ScreenProps = {
34
+ /** The screen's one `h1`, and the start of the page title. */
35
+ title: string;
36
+ /** Visually hide the `h1` when the screen shows its title another way (the editor's title field). */
37
+ hideTitle?: boolean;
38
+ /** Replaces the top bar's middle and right side (the editor's bar, spec §10.3). */
39
+ bar?: ReactNode;
40
+ /** The accent's wash at the top of a welcoming screen: home and empty states, never a form. */
41
+ afterglow?: boolean;
42
+ /** Line the heading up with the editor's 720px column. */
43
+ column?: boolean;
44
+ /** An icon before the heading: a collection's. */
45
+ icon?: { node: IconNode } | { name: IconName };
46
+ children: ReactNode;
47
+ };
48
+
49
+ /**
50
+ * Every screen's frame: the skip link, the top bar, and `<main>` with one
51
+ * `h1` (spec §10.6). After an in-app navigation, focus moves to the new
52
+ * screen's heading, so keyboard and screen-reader users land on the content
53
+ * they asked for instead of at the top of the page.
54
+ *
55
+ * Inside the pane shell (app/panes.tsx) a screen is the deepest pane
56
+ * instead: its heading and bar head the pane, and it scrolls on its own,
57
+ * beneath them.
58
+ */
59
+ export function Screen({
60
+ title,
61
+ hideTitle = false,
62
+ bar,
63
+ afterglow = false,
64
+ column = false,
65
+ icon,
66
+ children,
67
+ }: ScreenProps) {
68
+ const pane = usePane();
69
+ const heading = useRef<HTMLHeadingElement>(null);
70
+ useLeadHeading(title, heading);
71
+ const [header, headerHeight] = useMeasure('height', 56);
72
+
73
+ if (pane) {
74
+ return (
75
+ <PaneFrame
76
+ title={title}
77
+ headingRef={heading}
78
+ hideTitle={hideTitle}
79
+ icon={icon}
80
+ actions={bar}
81
+ className={cx(afterglow && 'afterglow')}
82
+ >
83
+ <div className={pane.width === 'flex' ? 'px-6 pt-8 pb-16' : 'px-3 pt-3 pb-6'}>
84
+ {children}
85
+ </div>
86
+ </PaneFrame>
87
+ );
88
+ }
89
+
90
+ return (
91
+ <div
92
+ className={cx('min-h-screen', afterglow && 'afterglow')}
93
+ // What sticks inside the screen (the formatting toolbar) sticks below the top bar.
94
+ style={{ '--sticky-top': `${String(headerHeight)}px` } as CSSProperties}
95
+ >
96
+ {/* The editor's bar has its own way back, so on a narrow screen it needs the room. */}
97
+ <StudioHeader bar={bar ?? <DefaultBar />} headerRef={header} homeLink={bar === undefined} />
98
+ <main
99
+ id="main"
100
+ tabIndex={-1}
101
+ className={cx('mx-auto max-w-6xl px-4 pb-16 outline-none', hideTitle ? 'pt-8' : 'pt-12')}
102
+ >
103
+ <h1
104
+ ref={heading}
105
+ tabIndex={-1}
106
+ className={
107
+ hideTitle
108
+ ? 'sr-only'
109
+ : cx(
110
+ 'mb-8 text-heading-1 text-content outline-none',
111
+ icon && 'flex items-center gap-3',
112
+ column && 'mx-auto max-w-editor',
113
+ )
114
+ }
115
+ >
116
+ {icon && !hideTitle && <Icon {...icon} size={24} className="text-content-muted" />}
117
+ {title}
118
+ </h1>
119
+ {children}
120
+ </main>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ /**
126
+ * The pane, or the screen, that leads names the browser tab, and takes focus
127
+ * after an in-app navigation (spec §10.6). Among panes that's the deepest.
128
+ */
129
+ export function useLeadHeading(title: string, heading: RefObject<HTMLElement | null>): void {
130
+ const { siteName } = useStudio();
131
+ const navigation = useContext(NavigationContext);
132
+ const pane = usePane();
133
+ const leads = !pane || pane.focusTarget;
134
+
135
+ useEffect(() => {
136
+ if (leads) document.title = `${title} · ${siteName}`;
137
+ }, [title, siteName, leads]);
138
+
139
+ useEffect(() => {
140
+ if (leads && navigation.count > 0) heading.current?.focus({ preventScroll: false });
141
+ }, [navigation.count, leads, heading]);
142
+ }
143
+
144
+ /**
145
+ * An element's width or height in pixels, kept current as it wraps or the
146
+ * window changes: give it the returned ref. A callback ref, never a ref
147
+ * object, because what's measured may be replaced or come and go: the top
148
+ * bar among panes leaves as the window narrows and comes back new as it
149
+ * widens, and a ref object went on measuring the bar that left, at 0, so
150
+ * every pane's header sat beneath the new one. The editor's bar moves from
151
+ * the top bar into a pane's header the same way.
152
+ */
153
+ export function useMeasure(
154
+ side: 'width' | 'height',
155
+ fallback: number,
156
+ ): [(element: HTMLElement | null) => void, number] {
157
+ const [element, setElement] = useState<HTMLElement | null>(null);
158
+ const [size, setSize] = useState(fallback);
159
+ useLayoutEffect(() => {
160
+ if (!element || typeof ResizeObserver === 'undefined') return;
161
+ const measure = () => {
162
+ // A detached element measures 0 on its way out.
163
+ if (!element.isConnected) return;
164
+ setSize(side === 'width' ? element.offsetWidth : element.offsetHeight);
165
+ };
166
+ const observer = new ResizeObserver(measure);
167
+ observer.observe(element);
168
+ measure();
169
+ return () => {
170
+ observer.disconnect();
171
+ };
172
+ }, [element, side]);
173
+ return [setElement, size];
174
+ }
175
+
176
+ /**
177
+ * A pane's frame: a landmark named by its heading, a header with the heading,
178
+ * the pane's actions and its collapse button (and the list's tools, as
179
+ * `toolbar`), then the pane's body. The deepest pane is the page's `main`,
180
+ * with its `h1`; the others are navigation, or a region, with `h2`s. The
181
+ * element stays the same as a pane's role changes, so what's in it isn't
182
+ * drawn afresh when an entry opens beside it.
183
+ *
184
+ * The frame is the pane's scroller, and it starts at the window's top: its
185
+ * body scrolls beneath its own header and the top bar, which are glass, so
186
+ * the content stays present beneath what floats (DECISIONS.md, "Panes scroll
187
+ * beneath glass"). Its scroll padding keeps a focused control clear of both.
188
+ */
189
+ export function PaneFrame({
190
+ title,
191
+ headingRef,
192
+ hideTitle = false,
193
+ icon,
194
+ actions,
195
+ toolbar,
196
+ scrollRef,
197
+ onScroll,
198
+ className,
199
+ children,
200
+ }: {
201
+ title: string;
202
+ headingRef: RefObject<HTMLHeadingElement | null>;
203
+ hideTitle?: boolean;
204
+ icon?: { node: IconNode } | { name: IconName } | undefined;
205
+ actions?: ReactNode;
206
+ /** Below the heading, in the header that stays put: the list's search, Sort and Show. */
207
+ toolbar?: ReactNode;
208
+ scrollRef?: RefObject<HTMLDivElement | null>;
209
+ onScroll?: (event: UIEvent<HTMLDivElement>) => void;
210
+ className?: string | false | undefined;
211
+ children: ReactNode;
212
+ }) {
213
+ const pane = usePane();
214
+ const id = useId();
215
+ const landmark = pane?.landmark ?? 'main';
216
+ const main = landmark === 'main';
217
+ const Heading = pane?.level === 2 ? 'h2' : 'h1';
218
+ const [header, headerHeight] = useMeasure('height', 56);
219
+ return (
220
+ <div
221
+ ref={scrollRef}
222
+ role={main ? 'main' : landmark === 'nav' ? 'navigation' : 'region'}
223
+ aria-labelledby={id}
224
+ {...(main ? { id: 'main', tabIndex: -1 } : {})}
225
+ onScroll={onScroll}
226
+ className={cx('min-h-0 flex-1 overflow-y-auto outline-none', className)}
227
+ style={
228
+ {
229
+ paddingTop: 'var(--studio-bar, 0px)',
230
+ scrollPaddingTop: `calc(var(--studio-bar, 0px) + ${String(headerHeight)}px)`,
231
+ // What sticks inside the pane (the formatting toolbar) sticks below its header.
232
+ '--sticky-top': `${String(headerHeight)}px`,
233
+ } as CSSProperties
234
+ }
235
+ >
236
+ {/* A sticky offset counts from inside the scroller's padding, so top-0 holds it just below the bar. */}
237
+ <div ref={header} className="glass-bar sticky top-0 z-10 border-b border-border">
238
+ <div
239
+ className={cx(
240
+ 'flex min-h-14 flex-wrap items-center gap-x-3 gap-y-2 py-2',
241
+ pane?.width === 'flex' ? 'px-6' : 'px-4',
242
+ )}
243
+ >
244
+ <Heading
245
+ id={id}
246
+ ref={headingRef}
247
+ tabIndex={-1}
248
+ className={
249
+ hideTitle
250
+ ? 'sr-only'
251
+ : cx(
252
+ 'min-w-0 flex-1 truncate outline-none',
253
+ pane?.width === 'flex' ? 'text-heading-3' : 'text-label',
254
+ 'text-content',
255
+ icon && 'flex items-center gap-2',
256
+ )
257
+ }
258
+ >
259
+ {icon && !hideTitle && <Icon {...icon} size={18} className="text-content-muted" />}
260
+ {title}
261
+ </Heading>
262
+ {actions}
263
+ {pane?.onCollapse && (
264
+ <Tooltip text={`Collapse ${title}`}>
265
+ <IconButton
266
+ data-collapse=""
267
+ icon="pane-collapse"
268
+ label={`Collapse ${title}`}
269
+ onPress={pane.onCollapse}
270
+ className="-mr-2"
271
+ />
272
+ </Tooltip>
273
+ )}
274
+ </div>
275
+ {toolbar}
276
+ </div>
277
+ {children}
278
+ </div>
279
+ );
280
+ }
281
+
282
+ /**
283
+ * The skip link and the top bar. One screen at a time, it holds the screen's
284
+ * bar (the menus, or the editor's own); among panes it spans the full width
285
+ * and holds what's the person's, since the structure pane holds the site's.
286
+ */
287
+ export function StudioHeader({
288
+ bar,
289
+ headerRef,
290
+ homeLink = true,
291
+ }: {
292
+ bar?: ReactNode;
293
+ headerRef?: Ref<HTMLElement>;
294
+ /** False drops the site's name, the link home, on a narrow screen (the editor's bar). */
295
+ homeLink?: boolean;
296
+ }) {
297
+ const { siteName } = useStudio();
298
+ const pane = usePane();
299
+ return (
300
+ <>
301
+ <a
302
+ href="#main"
303
+ onClick={(event) => {
304
+ // A plain `#main` link would replace the route in the hash.
305
+ event.preventDefault();
306
+ document.getElementById('main')?.focus();
307
+ }}
308
+ className="sr-only rounded-md bg-surface-raised px-3 py-2 text-accent shadow-overlay focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50"
309
+ >
310
+ Skip to content
311
+ </a>
312
+ {/* Sticky and frosted, so the page stays present as it scrolls beneath. */}
313
+ <header ref={headerRef} className="glass-bar sticky top-0 z-40 shadow-glass-edge">
314
+ <div
315
+ className={cx(
316
+ 'flex min-h-14 flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2 sm:gap-x-4',
317
+ bar !== undefined && !pane && 'mx-auto max-w-6xl',
318
+ )}
319
+ >
320
+ {/*
321
+ * The site's name takes no room of its own in the wrap, then grows into
322
+ * what's left, so a long one is cut short instead of pushing the
323
+ * person's menu onto a line of its own. Beside the editor's bar, it's
324
+ * its own width.
325
+ */}
326
+ <div className={cx('min-w-0', homeLink ? 'flex flex-1 basis-0' : 'hidden sm:flex')}>
327
+ <a
328
+ href={hrefFor({ screen: 'home' })}
329
+ className={cx(
330
+ 'inline-flex min-w-0 items-center gap-2 rounded-sm text-heading-3 tracking-tight text-content no-underline',
331
+ // Only Vesper's own name is lowercase; a site's shows as the site writes it.
332
+ siteName === VESPER_NAME && 'lowercase',
333
+ 'focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-focus',
334
+ 'focus-visible:ring-6 focus-visible:ring-(color:--glow-focus)',
335
+ )}
336
+ >
337
+ {/*
338
+ * Decorative: the link is named by the site's name beside it. Left
339
+ * out on a narrow screen, where the editor's bar needs the room
340
+ * (DECISIONS.md).
341
+ */}
342
+ <VesperMark size={20} className="hidden shrink-0 sm:block" />
343
+ <span className="truncate">{siteName}</span>
344
+ </a>
345
+ </div>
346
+ {bar ?? (
347
+ <div className="ml-auto flex items-center gap-2">
348
+ <ChangesButton />
349
+ <UserMenu />
350
+ </div>
351
+ )}
352
+ </div>
353
+ </header>
354
+ </>
355
+ );
356
+ }
357
+
358
+ /**
359
+ * The top bar on every screen but the editor, one screen at a time: the
360
+ * person's menu on the site's line, then the Content menu, the Site menu
361
+ * and the cloud's Changes on a line of their own.
362
+ */
363
+ function DefaultBar() {
364
+ const { manifest, siteUrl, cloud } = useStudio();
365
+ const route = parseRoute(window.location.hash);
366
+ const here = 'collection' in route ? route.collection : null;
367
+ const items = manifest.collections
368
+ .filter((c) => shownTo(cloud, c.name))
369
+ .map((collection) => {
370
+ const icon = collectionIcon(collection);
371
+ return {
372
+ id: collection.name,
373
+ label: collection.label,
374
+ isCurrent: collection.name === here,
375
+ icon: 'node' in icon ? icon.node : icon.name,
376
+ };
377
+ });
378
+ // Two lines, the person's menu beside the site's name and the site's menus below, flush
379
+ // with it: one line wrapped as it pleased, the menus pushed right under the name.
380
+ return (
381
+ <>
382
+ <div className="ml-auto flex min-w-0">
383
+ <UserMenu />
384
+ </div>
385
+ {/* Pulled out by a trigger's padding, so "Content" lines up with the site's name. */}
386
+ <nav aria-label="Studio" className="-ml-3 flex basis-full items-center gap-2">
387
+ <Menu
388
+ label="Content"
389
+ triggerText="Content"
390
+ items={items}
391
+ onAction={(name) => {
392
+ navigate({ screen: 'list', collection: name });
393
+ }}
394
+ />
395
+ <Menu
396
+ label="Site"
397
+ triggerText="Site"
398
+ items={[
399
+ { id: 'media', label: 'Media', icon: 'media' as const },
400
+ // Redirects belong to the whole site (spec §7.5): a folder grant doesn't reach them.
401
+ ...(!cloud || cloud.wholeSite
402
+ ? [{ id: 'redirects', label: 'Redirects', icon: 'redirects' as const }]
403
+ : []),
404
+ ...(siteUrl ? [{ id: 'view', label: 'View site', icon: 'view-on-site' as const }] : []),
405
+ ]}
406
+ onAction={(id) => {
407
+ if (id === 'media') navigate({ screen: 'media' });
408
+ if (id === 'redirects') navigate({ screen: 'redirects' });
409
+ if (id === 'view' && siteUrl) window.open(siteUrl, '_blank', 'noopener');
410
+ }}
411
+ />
412
+ <ChangesButton />
413
+ </nav>
414
+ </>
415
+ );
416
+ }
@@ -0,0 +1,95 @@
1
+ import { Avatar, Button, Menu, type MenuItem } from '@vespercms/ui';
2
+ import { useEffect, useState } from 'react';
3
+ import type { StudioApi } from './api.ts';
4
+ import { APPEARANCES, useAppearance, type Appearance } from './appearance.ts';
5
+ import { useStudio } from './context.tsx';
6
+
7
+ const localNames = new WeakMap<StudioApi, Promise<string | null>>();
8
+
9
+ /** Local mode's editor, from git on this computer; asked once per visit. */
10
+ function useLocalName(api: StudioApi | undefined): string | null {
11
+ const [name, setName] = useState<string | null>(null);
12
+ useEffect(() => {
13
+ if (!api?.person) return;
14
+ let asked = localNames.get(api);
15
+ if (!asked) {
16
+ asked = api.person().then(
17
+ (person) => person.name,
18
+ () => null,
19
+ );
20
+ localNames.set(api, asked);
21
+ }
22
+ let live = true;
23
+ void asked.then((found) => {
24
+ if (live) setName(found);
25
+ });
26
+ return () => {
27
+ live = false;
28
+ };
29
+ }, [api]);
30
+ return name;
31
+ }
32
+
33
+ /**
34
+ * The user menu at the end of the top bar (spec §10.3): who is signed in and
35
+ * the way out in GitHub mode and the cloud (spec §6.9), and Appearance in
36
+ * every mode, local mode included, named by git's user there (DECISIONS.md,
37
+ * "Appearance is in the user menu, and local mode has a user menu").
38
+ */
39
+ export function UserMenu() {
40
+ const { account, api } = useStudio();
41
+ const [appearance, choose] = useAppearance();
42
+ const localName = useLocalName(account ? undefined : api);
43
+
44
+ if (account?.expired) {
45
+ return (
46
+ <Button variant="quiet" onPress={account.signInAgain}>
47
+ Sign in again
48
+ </Button>
49
+ );
50
+ }
51
+
52
+ const name = account?.name ?? localName ?? 'You';
53
+ const items: MenuItem[] = account
54
+ ? [
55
+ ...(account.dashboardUrl ? [{ id: 'dashboard', label: 'Open dashboard' }] : []),
56
+ { id: 'sign-out', label: account.signOutLabel },
57
+ ]
58
+ : [];
59
+
60
+ return (
61
+ <Menu
62
+ label={
63
+ account ? account.description : localName ? `Appearance, for ${localName}` : 'Appearance'
64
+ }
65
+ triggerText={account?.label ?? name}
66
+ triggerPicture={<Avatar name={name} src={account?.picture} />}
67
+ header={
68
+ <div className="flex items-center gap-3">
69
+ <Avatar name={name} src={account?.picture} size={32} />
70
+ <div className="min-w-0">
71
+ <p className="truncate text-label text-content">{name}</p>
72
+ <p className="truncate text-small text-content-muted">
73
+ {account ? account.description : 'Editing on this computer'}
74
+ </p>
75
+ </div>
76
+ </div>
77
+ }
78
+ choice={{
79
+ title: 'Appearance',
80
+ options: [...APPEARANCES],
81
+ selected: appearance,
82
+ onChange: (id) => {
83
+ choose(id as Appearance);
84
+ },
85
+ }}
86
+ items={items}
87
+ onAction={(id) => {
88
+ if (id === 'dashboard' && account?.dashboardUrl) {
89
+ window.open(account.dashboardUrl, '_blank', 'noopener');
90
+ }
91
+ if (id === 'sign-out') account?.signOut();
92
+ }}
93
+ />
94
+ );
95
+ }
package/src/app/api.ts ADDED
@@ -0,0 +1,176 @@
1
+ import type {
2
+ CollectionSummary,
3
+ EntryDocument,
4
+ EntrySummary,
5
+ MediaItem,
6
+ MoveRequest,
7
+ MoveResult,
8
+ PageLink,
9
+ ScheduledChange,
10
+ PageTree,
11
+ RedirectsDocument,
12
+ ReorderEntryRequest,
13
+ ReorderEntryResult,
14
+ ReorderRequest,
15
+ ReorderResult,
16
+ SaveRedirectsRequest,
17
+ SaveRedirectsResult,
18
+ SaveRequest,
19
+ SaveResponse,
20
+ UploadResponse,
21
+ } from '@vespercms/core';
22
+
23
+ /**
24
+ * Everything the studio needs from a storage back end. Local mode talks to
25
+ * the dev middleware; GitHub and cloud modes (Phases 3 and 4) implement the
26
+ * same interface, so screens never know which mode they're in.
27
+ */
28
+ export interface StudioApi {
29
+ summary(): Promise<CollectionSummary[]>;
30
+ listEntries(collection: string): Promise<EntrySummary[]>;
31
+ readEntry(collection: string, id: string): Promise<EntryDocument>;
32
+ saveEntry(request: SaveRequest): Promise<SaveResponse>;
33
+ deleteEntry(collection: string, id: string, version: string): Promise<SaveResponse>;
34
+ listMedia(): Promise<MediaItem[]>;
35
+ /**
36
+ * Stores an upload. `withNextSave`: where saving publishes, the file waits
37
+ * and goes out with the next save, so an entry and its image are one
38
+ * commit (spec §6.8). Local mode writes it at once either way.
39
+ */
40
+ upload(
41
+ file: Blob,
42
+ name: string,
43
+ folder: string,
44
+ options?: { withNextSave?: boolean },
45
+ ): Promise<UploadResponse>;
46
+ deleteMedia(path: string): Promise<SaveResponse>;
47
+ /** Site-relative paths of the Markdoc and Markdown files a `{% partial %}` can point at. */
48
+ listPartials(): Promise<string[]>;
49
+ /**
50
+ * A URL the studio can show a file from, given its site-relative path.
51
+ * Asynchronous: GitHub mode fetches the blob and hands back an object URL.
52
+ */
53
+ fileUrl(path: string): Promise<string>;
54
+ /** Every page with a URL, in every collection, for the link picker (spec §6.15.6). */
55
+ listPages(): Promise<PageLink[]>;
56
+ /** Every change still to come in the site's scheduled collections, soonest first (spec §6.17). */
57
+ listScheduled(): Promise<ScheduledChange[]>;
58
+ /** A tree collection's outline (spec §6.15.2). */
59
+ pageTree(collection: string): Promise<PageTree>;
60
+ /** Moves a page and everything under it; `dryRun` only describes what would happen. */
61
+ movePage(request: MoveRequest, options?: { dryRun?: boolean }): Promise<MoveResult>;
62
+ reorderPage(request: ReorderRequest): Promise<ReorderResult>;
63
+ /** Puts an entry of a collection with `orderBy` in a new place, by writing its order numbers. */
64
+ reorderEntry(request: ReorderEntryRequest): Promise<ReorderEntryResult>;
65
+ /** Creates the missing section page of a folder in the tree. */
66
+ createSection(collection: string, path: string): Promise<SaveResponse>;
67
+ readRedirects(): Promise<RedirectsDocument>;
68
+ saveRedirects(request: SaveRedirectsRequest): Promise<SaveRedirectsResult>;
69
+ /**
70
+ * Whether Save publishes (GitHub and cloud modes) or only writes the file on
71
+ * this computer (local mode), where the site's publish gate can be saved past.
72
+ */
73
+ publishesOnSave(): boolean;
74
+ /**
75
+ * Local mode only: who is editing, from git's `user.name` on this computer,
76
+ * for the user menu (DECISIONS.md, "Appearance is in the user menu").
77
+ */
78
+ person?(): Promise<{ name: string | null }>;
79
+ }
80
+
81
+ export class ApiError extends Error {
82
+ readonly status: number;
83
+ constructor(status: number, message: string) {
84
+ super(message);
85
+ this.name = 'ApiError';
86
+ this.status = status;
87
+ }
88
+ }
89
+
90
+ /** The header every studio request carries (spec §6.7). */
91
+ export const STUDIO_HEADER = 'X-Vesper';
92
+
93
+ function messageOf(body: unknown, fallback: string): string {
94
+ if (
95
+ typeof body === 'object' &&
96
+ body !== null &&
97
+ 'message' in body &&
98
+ typeof body.message === 'string'
99
+ ) {
100
+ return body.message;
101
+ }
102
+ return fallback;
103
+ }
104
+
105
+ /**
106
+ * The local-mode client (spec §6.7). Every request carries the `X-Vesper`
107
+ * header: the dev middleware refuses requests without it, so another site
108
+ * open in the same browser can't reach it with a simple cross-origin request.
109
+ *
110
+ * Saves answer with a `SaveResponse` even when they fail (a conflict or an
111
+ * invalid value is an answer, not an error). Anything else that isn't 2xx
112
+ * throws an `ApiError`.
113
+ */
114
+ export function createLocalApi(base: string, fetcher: typeof fetch = fetch): StudioApi {
115
+ const root = base.replace(/\/$/, '');
116
+
117
+ async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
118
+ const headers = new Headers(init.headers);
119
+ headers.set(STUDIO_HEADER, '1');
120
+ if (typeof init.body === 'string') headers.set('Content-Type', 'application/json');
121
+ const response = await fetcher(`${root}${path}`, { ...init, headers });
122
+ const text = await response.text();
123
+ let body: unknown = null;
124
+ try {
125
+ body = text ? JSON.parse(text) : null;
126
+ } catch {
127
+ throw new ApiError(
128
+ response.status,
129
+ response.ok ? 'The server sent a response the studio couldn’t read.' : response.statusText,
130
+ );
131
+ }
132
+ const isAnswer = typeof body === 'object' && body !== null && 'ok' in body;
133
+ if (!response.ok && !isAnswer)
134
+ throw new ApiError(response.status, messageOf(body, response.statusText));
135
+ return body as T;
136
+ }
137
+
138
+ const query = (params: Record<string, string>) => `?${new URLSearchParams(params).toString()}`;
139
+
140
+ return {
141
+ publishesOnSave: () => false,
142
+ person: () => call('/person'),
143
+ summary: () => call('/summary'),
144
+ listEntries: (collection) => call(`/entries${query({ collection })}`),
145
+ readEntry: (collection, id) => call(`/entry${query({ collection, id })}`),
146
+ saveEntry: (request) => call('/entry', { method: 'PUT', body: JSON.stringify(request) }),
147
+ deleteEntry: (collection, id, version) =>
148
+ call(`/entry${query({ collection, id, version })}`, { method: 'DELETE' }),
149
+ listMedia: () => call('/media'),
150
+ upload: (file, name, folder) =>
151
+ call(`/media${query({ name, folder })}`, {
152
+ method: 'POST',
153
+ body: file,
154
+ headers: { 'Content-Type': 'application/octet-stream' },
155
+ }),
156
+ deleteMedia: (path) => call(`/media${query({ path })}`, { method: 'DELETE' }),
157
+ listPartials: () => call('/partials'),
158
+ fileUrl: (path) => Promise.resolve(`${root}/file${query({ path })}`),
159
+ listPages: () => call('/pages'),
160
+ listScheduled: () => call('/scheduled'),
161
+ pageTree: (collection) => call(`/tree${query({ collection })}`),
162
+ movePage: (request, options = {}) =>
163
+ call(`/move${options.dryRun ? query({ dryRun: '1' }) : ''}`, {
164
+ method: 'POST',
165
+ body: JSON.stringify(request),
166
+ }),
167
+ reorderPage: (request) => call('/reorder', { method: 'POST', body: JSON.stringify(request) }),
168
+ reorderEntry: (request) =>
169
+ call('/reorder-entry', { method: 'POST', body: JSON.stringify(request) }),
170
+ createSection: (collection, path) =>
171
+ call('/section', { method: 'POST', body: JSON.stringify({ collection, path }) }),
172
+ readRedirects: () => call('/redirects'),
173
+ saveRedirects: (request) =>
174
+ call('/redirects', { method: 'PUT', body: JSON.stringify(request) }),
175
+ };
176
+ }