@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.
- package/README.md +119 -2
- package/dist/BodyEditor-_mkh0BSQ.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-4fVeej3c.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 +35 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +16 -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/screens/status.d.ts +36 -0
- package/dist/shortcuts-CarKeDqp.js +4666 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6449 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/blank-date.d.ts +8 -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 +298 -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 +303 -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 +122 -0
- package/src/screens/EntryEditor.tsx +1817 -0
- package/src/screens/EntryList.tsx +272 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +477 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +738 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/screens/status.tsx +104 -0
- package/src/studio.css +168 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +315 -0
- package/src/widgets/blank-date.ts +32 -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 +290 -0
- 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
|
+
}
|