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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-CAGNfofv.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-KUkv11EB.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 +37 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +18 -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/shortcuts-B_JMAvF9.js +4630 -0
  89. package/dist/studio.css +3 -0
  90. package/dist/studio.js +6312 -0
  91. package/dist/types.d.ts +76 -0
  92. package/dist/widgets/basic.d.ts +25 -0
  93. package/dist/widgets/crops.d.ts +43 -0
  94. package/dist/widgets/focal-point.d.ts +15 -0
  95. package/dist/widgets/geo-map.d.ts +10 -0
  96. package/dist/widgets/geo.d.ts +16 -0
  97. package/dist/widgets/group.d.ts +41 -0
  98. package/dist/widgets/icon.d.ts +9 -0
  99. package/dist/widgets/image.d.ts +16 -0
  100. package/dist/widgets/markdown-lazy.d.ts +2 -0
  101. package/dist/widgets/markdown.d.ts +2 -0
  102. package/dist/widgets/reference.d.ts +12 -0
  103. package/dist/widgets/registry.d.ts +32 -0
  104. package/dist/widgets/special.d.ts +20 -0
  105. package/dist/widgets/union.d.ts +36 -0
  106. package/package.json +63 -4
  107. package/src/a11y/FieldNotes.tsx +56 -0
  108. package/src/a11y/NotesDrawer.tsx +81 -0
  109. package/src/a11y/pdf.ts +45 -0
  110. package/src/a11y/run.ts +12 -0
  111. package/src/a11y/useNotes.ts +139 -0
  112. package/src/app/App.tsx +156 -0
  113. package/src/app/Screen.tsx +416 -0
  114. package/src/app/UserMenu.tsx +95 -0
  115. package/src/app/api.ts +176 -0
  116. package/src/app/appearance.ts +54 -0
  117. package/src/app/browser-api.ts +173 -0
  118. package/src/app/collection-icon.ts +15 -0
  119. package/src/app/context.tsx +32 -0
  120. package/src/app/entries-changed.ts +48 -0
  121. package/src/app/file-url.tsx +44 -0
  122. package/src/app/format.ts +39 -0
  123. package/src/app/idb.ts +82 -0
  124. package/src/app/load.ts +40 -0
  125. package/src/app/media.ts +20 -0
  126. package/src/app/navigation.ts +4 -0
  127. package/src/app/pane-context.ts +25 -0
  128. package/src/app/panes.tsx +513 -0
  129. package/src/app/router.ts +158 -0
  130. package/src/body/BodyEditor.tsx +1139 -0
  131. package/src/body/TableView.tsx +700 -0
  132. package/src/body/catalog.ts +198 -0
  133. package/src/body/checks.ts +83 -0
  134. package/src/body/chips.ts +150 -0
  135. package/src/body/commands.ts +191 -0
  136. package/src/body/document.ts +388 -0
  137. package/src/body/insert.tsx +301 -0
  138. package/src/body/nodes.ts +287 -0
  139. package/src/body/reveal.ts +93 -0
  140. package/src/body/scroll.ts +53 -0
  141. package/src/body/settings.tsx +510 -0
  142. package/src/body/shortcuts.ts +31 -0
  143. package/src/body/table.ts +192 -0
  144. package/src/body/ui.ts +77 -0
  145. package/src/body/views.tsx +268 -0
  146. package/src/build.ts +6 -0
  147. package/src/cloud/CloudGate.tsx +245 -0
  148. package/src/cloud/access.ts +52 -0
  149. package/src/cloud/changes.tsx +474 -0
  150. package/src/cloud/connection.ts +281 -0
  151. package/src/cloud/editor.tsx +250 -0
  152. package/src/cloud/session.ts +289 -0
  153. package/src/cloud/storage.ts +212 -0
  154. package/src/editor/Field.tsx +182 -0
  155. package/src/editor/PagePicker.tsx +102 -0
  156. package/src/editor/arrange.ts +98 -0
  157. package/src/editor/context.tsx +31 -0
  158. package/src/editor/copy.ts +30 -0
  159. package/src/editor/reveal.ts +38 -0
  160. package/src/editor/target.ts +104 -0
  161. package/src/editor/values.ts +71 -0
  162. package/src/github/GitHubGate.tsx +261 -0
  163. package/src/github/api.ts +81 -0
  164. package/src/github/cache.ts +64 -0
  165. package/src/github/session.ts +146 -0
  166. package/src/index.ts +10 -0
  167. package/src/media/MediaPicker.tsx +302 -0
  168. package/src/media/file-icon.ts +17 -0
  169. package/src/media/formats.ts +262 -0
  170. package/src/media/hygiene.ts +220 -0
  171. package/src/media/upload.ts +47 -0
  172. package/src/mount.tsx +20 -0
  173. package/src/schedule/ScheduleDialog.tsx +300 -0
  174. package/src/schedule/ScheduleSummary.tsx +59 -0
  175. package/src/schedule/ScheduledView.tsx +294 -0
  176. package/src/schedule/cloud.ts +66 -0
  177. package/src/schedule/now.ts +18 -0
  178. package/src/schedule/times.ts +133 -0
  179. package/src/screens/EntryEditor.tsx +1803 -0
  180. package/src/screens/EntryList.tsx +274 -0
  181. package/src/screens/Home.tsx +131 -0
  182. package/src/screens/ListPane.tsx +459 -0
  183. package/src/screens/MediaBrowser.tsx +253 -0
  184. package/src/screens/NotFound.tsx +19 -0
  185. package/src/screens/PageTree.tsx +718 -0
  186. package/src/screens/Redirects.tsx +522 -0
  187. package/src/screens/Unavailable.tsx +27 -0
  188. package/src/studio.css +163 -0
  189. package/src/types.ts +82 -0
  190. package/src/widgets/basic.tsx +311 -0
  191. package/src/widgets/crops.tsx +399 -0
  192. package/src/widgets/focal-point.tsx +240 -0
  193. package/src/widgets/geo-map.tsx +160 -0
  194. package/src/widgets/geo.tsx +170 -0
  195. package/src/widgets/group.tsx +827 -0
  196. package/src/widgets/icon.tsx +47 -0
  197. package/src/widgets/image.tsx +407 -0
  198. package/src/widgets/markdown-lazy.tsx +22 -0
  199. package/src/widgets/markdown.tsx +267 -0
  200. package/src/widgets/reference.tsx +196 -0
  201. package/src/widgets/registry.ts +104 -0
  202. package/src/widgets/special.tsx +286 -0
  203. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,54 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ /*
4
+ * Light, dark, or the system's choice (DECISIONS.md, "Appearance is in the
5
+ * user menu"). The tokens switch on `html[data-theme]`; with no attribute
6
+ * they follow the system. The choice is per viewer, in this browser, for this
7
+ * site. `admin.astro` applies it before the page paints, with the same key,
8
+ * so a dark choice doesn't flash light while the studio loads.
9
+ */
10
+
11
+ export type Appearance = 'system' | 'light' | 'dark';
12
+
13
+ export const APPEARANCE_KEY = 'vesper:appearance';
14
+
15
+ export const APPEARANCES: readonly { id: Appearance; label: string }[] = [
16
+ { id: 'system', label: 'System' },
17
+ { id: 'light', label: 'Light' },
18
+ { id: 'dark', label: 'Dark' },
19
+ ];
20
+
21
+ export function readAppearance(): Appearance {
22
+ try {
23
+ const value = localStorage.getItem(APPEARANCE_KEY);
24
+ return value === 'light' || value === 'dark' ? value : 'system';
25
+ } catch {
26
+ // Storage can be refused (a private window, a blocked site): follow the system.
27
+ return 'system';
28
+ }
29
+ }
30
+
31
+ export function applyAppearance(appearance: Appearance): void {
32
+ const root = document.documentElement;
33
+ if (appearance === 'system') delete root.dataset.theme;
34
+ else root.dataset.theme = appearance;
35
+ }
36
+
37
+ export function saveAppearance(appearance: Appearance): void {
38
+ applyAppearance(appearance);
39
+ try {
40
+ if (appearance === 'system') localStorage.removeItem(APPEARANCE_KEY);
41
+ else localStorage.setItem(APPEARANCE_KEY, appearance);
42
+ } catch {
43
+ // Kept for this visit only.
44
+ }
45
+ }
46
+
47
+ export function useAppearance(): [Appearance, (appearance: Appearance) => void] {
48
+ const [appearance, setAppearance] = useState(readAppearance);
49
+ const choose = useCallback((next: Appearance) => {
50
+ saveAppearance(next);
51
+ setAppearance(next);
52
+ }, []);
53
+ return [appearance, choose];
54
+ }
@@ -0,0 +1,173 @@
1
+ import {
2
+ ContentService,
3
+ MEDIA_TYPES,
4
+ MediaService,
5
+ PageService,
6
+ extensionOf,
7
+ fieldModelIssues,
8
+ issuesFromZod,
9
+ type FieldIssue,
10
+ type Manifest,
11
+ type StorageAdapter,
12
+ type UrlEntry,
13
+ } from '@vespercms/core';
14
+ import { ApiError, type StudioApi } from './api.ts';
15
+
16
+ /*
17
+ * The studio's back end in the browser (DECISIONS.md, "GitHub mode runs
18
+ * core's services in the browser"): the same core services local mode's dev
19
+ * API runs, over a storage adapter. GitHub mode passes its repository, the
20
+ * cloud its drafts; every screen, check and splice stays the same code.
21
+ *
22
+ * Saves are validated with the site's own Zod schemas, built for the
23
+ * browser at `astro build`. If that file can't load, the field model's
24
+ * checks stand in, and the site's build is the last check.
25
+ */
26
+
27
+ type SafeParse = (
28
+ value: unknown,
29
+ params?: object,
30
+ ) => Promise<{ success: boolean; error?: { issues: unknown[] } }>;
31
+
32
+ export type BrowserApiOptions = {
33
+ storage: StorageAdapter;
34
+ manifest: Manifest;
35
+ /** The site's schemas built for the browser. */
36
+ schemas: string;
37
+ loadMdxProps: () => Promise<Readonly<Record<string, unknown>>>;
38
+ loadUrlFunctions?: () => Promise<Readonly<Record<string, (entry: UrlEntry) => unknown>>>;
39
+ /** The site's static routes at build, for collision checks. */
40
+ routes?: readonly (readonly [string, string])[];
41
+ /** Whether a save publishes (GitHub mode) or keeps a draft (the cloud). */
42
+ publishesOnSave: boolean;
43
+ /** The storage's own errors in the studio's terms. */
44
+ mapError: (error: unknown) => unknown;
45
+ };
46
+
47
+ export function createBrowserApi(options: BrowserApiOptions): StudioApi {
48
+ const { storage, manifest } = options;
49
+
50
+ let schemas: Promise<Readonly<Record<string, unknown>> | null> | null = null;
51
+ const loadSchemas = () => {
52
+ schemas ??= (import(/* @vite-ignore */ options.schemas) as Promise<{ schemas?: unknown }>).then(
53
+ (module) =>
54
+ typeof module.schemas === 'object' && module.schemas !== null
55
+ ? (module.schemas as Record<string, unknown>)
56
+ : null,
57
+ () => null,
58
+ );
59
+ return schemas;
60
+ };
61
+ const validate = async (name: string, data: Record<string, unknown>): Promise<FieldIssue[]> => {
62
+ const all = await loadSchemas();
63
+ const schema = all?.[name] as { safeParseAsync?: SafeParse } | undefined;
64
+ if (typeof schema?.safeParseAsync === 'function') {
65
+ const result = await schema.safeParseAsync(data, { reportInput: true });
66
+ return result.success ? [] : issuesFromZod(result.error?.issues ?? []);
67
+ }
68
+ if (all !== null) return [];
69
+ const collection = manifest.collections.find((c) => c.name === name);
70
+ return fieldModelIssues(collection?.fields ?? null, data as never);
71
+ };
72
+
73
+ let ready: Promise<{ content: ContentService; pages: PageService; media: MediaService }> | null =
74
+ null;
75
+ const services = () => {
76
+ ready ??= (async () => {
77
+ const urlFunctions = (await options.loadUrlFunctions?.().catch(() => ({}))) ?? {};
78
+ const routes = new Map(options.routes ?? []);
79
+ const content: ContentService = new ContentService(storage, manifest, {
80
+ validate: (name, data) => validate(name, data),
81
+ validateBody: async ({ format, body, path }) => {
82
+ const { bodyChecks } = await import('../body/checks.ts');
83
+ const partials = format === 'mdoc' ? await content.listPartials() : [];
84
+ return bodyChecks({
85
+ manifest,
86
+ format,
87
+ loadMdxProps: options.loadMdxProps,
88
+ partials,
89
+ entryPath: path,
90
+ }).validate(body);
91
+ },
92
+ tree: () => pages.hooks(),
93
+ });
94
+ const pages: PageService = new PageService(storage, manifest, content, {
95
+ routes: () => routes,
96
+ urlFunctions,
97
+ });
98
+ const media = new MediaService(storage, content, {
99
+ roots: [`${manifest.site.srcDir}/assets`, 'public'],
100
+ });
101
+ return { content, pages, media };
102
+ })();
103
+ return ready;
104
+ };
105
+
106
+ /** Runs a request, with the storage's errors in the studio's terms. */
107
+ const call = async <T>(work: () => Promise<T>): Promise<T> => {
108
+ try {
109
+ return await work();
110
+ } catch (error) {
111
+ throw options.mapError(error);
112
+ }
113
+ };
114
+ const known = (name: string) => manifest.collections.some((c) => c.name === name);
115
+ const objectUrls = new Map<string, string>();
116
+
117
+ return {
118
+ publishesOnSave: () => options.publishesOnSave,
119
+ summary: () => call(async () => (await services()).content.summary()),
120
+ listEntries: (collection) =>
121
+ call(async () => {
122
+ if (!known(collection))
123
+ throw new ApiError(404, `There's no collection called "${collection}".`);
124
+ return (await services()).content.listEntries(collection);
125
+ }),
126
+ readEntry: (collection, id) =>
127
+ call(async () => {
128
+ const doc = known(collection)
129
+ ? await (await services()).content.readEntry(collection, id)
130
+ : null;
131
+ if (!doc) throw new ApiError(404, `"${id}" wasn't found in ${collection || 'this site'}.`);
132
+ return doc;
133
+ }),
134
+ saveEntry: (request) => call(async () => (await services()).content.saveEntry(request)),
135
+ deleteEntry: (collection, id, version) =>
136
+ call(async () => (await services()).content.deleteEntry(collection, id, version)),
137
+ listMedia: () => call(async () => (await services()).media.list()),
138
+ upload: (file, name, folder, uploadOptions) =>
139
+ call(async () =>
140
+ (await services()).media.upload(new Uint8Array(await file.arrayBuffer()), name, folder, {
141
+ defer: uploadOptions?.withNextSave === true,
142
+ }),
143
+ ),
144
+ deleteMedia: (path) => call(async () => (await services()).media.remove(path)),
145
+ listPartials: () => call(async () => (await services()).content.listPartials()),
146
+ fileUrl: (path) =>
147
+ call(async () => {
148
+ const file = await storage.read(path);
149
+ if (!file) throw new ApiError(404, `${path} wasn't found.`);
150
+ let url = objectUrls.get(file.version);
151
+ if (!url) {
152
+ const type = MEDIA_TYPES[extensionOf(path)] ?? 'application/octet-stream';
153
+ url = URL.createObjectURL(new Blob([file.bytes as BlobPart], { type }));
154
+ objectUrls.set(file.version, url);
155
+ }
156
+ return url;
157
+ }),
158
+ listPages: () => call(async () => (await services()).pages.pages()),
159
+ listScheduled: () => call(async () => (await services()).content.scheduledChanges(new Date())),
160
+ pageTree: (collection) => call(async () => (await services()).pages.tree(collection)),
161
+ movePage: (request, moveOptions) =>
162
+ call(async () => {
163
+ const { pages } = await services();
164
+ return moveOptions?.dryRun ? pages.planMove(request) : pages.move(request);
165
+ }),
166
+ reorderPage: (request) => call(async () => (await services()).pages.reorder(request)),
167
+ reorderEntry: (request) => call(async () => (await services()).content.reorderEntry(request)),
168
+ createSection: (collection, path) =>
169
+ call(async () => (await services()).pages.createSection(collection, path)),
170
+ readRedirects: () => call(async () => (await services()).pages.redirects()),
171
+ saveRedirects: (request) => call(async () => (await services()).pages.saveRedirects(request)),
172
+ };
173
+ }
@@ -0,0 +1,15 @@
1
+ import type { CollectionManifest } from '@vespercms/core';
2
+ import type { IconName, IconNode } from '@vespercms/ui';
3
+
4
+ /**
5
+ * A collection's icon: the Lucide icon vesper.config.ts names for it,
6
+ * resolved into the manifest at build, or a default by kind (DECISIONS.md,
7
+ * "A collection's icon is named in vesper.config.ts").
8
+ */
9
+ export function collectionIcon(
10
+ collection: CollectionManifest,
11
+ ): { node: IconNode } | { name: IconName } {
12
+ const { icon, singleton, tree } = collection.presentation;
13
+ if (icon) return { node: icon.node };
14
+ return { name: singleton ? 'singleton' : tree ? 'tree' : 'collection' };
15
+ }
@@ -0,0 +1,32 @@
1
+ import type { CollectionManifest, Manifest } from '@vespercms/core';
2
+ import { createContext, useContext } from 'react';
3
+ import type { Account, WidgetComponent } from '../types.ts';
4
+ import type { CloudConnection } from '../cloud/connection.ts';
5
+ import type { StudioApi } from './api.ts';
6
+
7
+ export type Studio = {
8
+ manifest: Manifest;
9
+ api: StudioApi;
10
+ /** Custom widgets, as lazy components: each loads the first time a field uses it. */
11
+ widgets: Record<string, WidgetComponent>;
12
+ /** Loads declared MDX components' Zod props schemas, from vesper.config.ts (spec §6.5.2). */
13
+ loadMdxProps: () => Promise<Readonly<Record<string, unknown>>>;
14
+ siteName: string;
15
+ siteUrl: string | undefined;
16
+ collection: (name: string) => CollectionManifest | undefined;
17
+ /** The signed-in editor, in GitHub mode or the cloud. */
18
+ account?: Account;
19
+ /** The cloud, when the studio is connected to it. Absent, every cloud feature is absent (spec §6.9). */
20
+ cloud?: CloudConnection;
21
+ };
22
+
23
+ /** The name the studio shows when the site gives none: Vesper's own, set lowercase as its wordmark. */
24
+ export const VESPER_NAME = 'Vesper';
25
+
26
+ export const StudioContext = createContext<Studio | null>(null);
27
+
28
+ export function useStudio(): Studio {
29
+ const studio = useContext(StudioContext);
30
+ if (!studio) throw new Error('useStudio() must be used inside <App>.');
31
+ return studio;
32
+ }
@@ -0,0 +1,48 @@
1
+ /*
2
+ * After a save, the lists beside the editor read their collection again, so
3
+ * a new title or status shows there (DECISIONS.md, "The studio is panes,
4
+ * full width").
5
+ */
6
+
7
+ const listeners = new Set<(collection: string) => void>();
8
+
9
+ export function entriesChanged(collection: string): void {
10
+ for (const listener of listeners) listener(collection);
11
+ }
12
+
13
+ const rewrites = new Set<(collection: string, ids: readonly string[]) => void>();
14
+
15
+ /**
16
+ * Entries rewritten from outside their editor: a move in the list writes
17
+ * order numbers into them, and an editor open on one reads it again, or its
18
+ * next save would find a version it didn't start from.
19
+ */
20
+ export function entriesRewritten(collection: string, ids: readonly string[]): void {
21
+ for (const listener of rewrites) listener(collection, ids);
22
+ entriesChanged(collection);
23
+ }
24
+
25
+ /** Calls `listener` with the IDs whenever entries in `collection` are rewritten; returns the way to stop. */
26
+ export function onEntriesRewritten(
27
+ collection: string,
28
+ listener: (ids: readonly string[]) => void,
29
+ ): () => void {
30
+ const each = (changed: string, ids: readonly string[]) => {
31
+ if (changed === collection) listener(ids);
32
+ };
33
+ rewrites.add(each);
34
+ return () => {
35
+ rewrites.delete(each);
36
+ };
37
+ }
38
+
39
+ /** Calls `listener` after each save in `collection`; returns the way to stop. */
40
+ export function onEntriesChanged(collection: string, listener: () => void): () => void {
41
+ const each = (changed: string) => {
42
+ if (changed === collection) listener();
43
+ };
44
+ listeners.add(each);
45
+ return () => {
46
+ listeners.delete(each);
47
+ };
48
+ }
@@ -0,0 +1,44 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useStudio } from './context.tsx';
3
+
4
+ /**
5
+ * A URL the studio can show a site file from, or `''` until it's ready. In
6
+ * local mode it's the dev API's `/file`; in GitHub mode an object URL for
7
+ * the blob, since a private repository's files have no address a browser
8
+ * can load without the token.
9
+ */
10
+ export function useFileUrl(path: string | null): string {
11
+ const { api } = useStudio();
12
+ const [found, setFound] = useState<{ path: string; url: string } | null>(null);
13
+ useEffect(() => {
14
+ if (!path) return;
15
+ let live = true;
16
+ api.fileUrl(path).then(
17
+ (url) => {
18
+ if (live) setFound({ path, url });
19
+ },
20
+ () => {
21
+ // No preview; the field still works.
22
+ },
23
+ );
24
+ return () => {
25
+ live = false;
26
+ };
27
+ }, [api, path]);
28
+ return path && found?.path === path ? found.url : '';
29
+ }
30
+
31
+ /** A site image, shown once its URL is ready. Decorative: whatever lists it names it. */
32
+ export function FileImage({
33
+ path,
34
+ className,
35
+ loading,
36
+ }: {
37
+ path: string;
38
+ className?: string;
39
+ loading?: 'lazy' | 'eager';
40
+ }) {
41
+ const url = useFileUrl(path);
42
+ if (!url) return <span aria-hidden="true" className={className} />;
43
+ return <img src={url} alt="" loading={loading} className={className} />;
44
+ }
@@ -0,0 +1,39 @@
1
+ /*
2
+ * Words for numbers and times (spec §10.2: words over badges). Everything
3
+ * takes `now` and a locale so tests are deterministic.
4
+ */
5
+
6
+ const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
7
+ ['year', 365 * 24 * 3600],
8
+ ['month', 30 * 24 * 3600],
9
+ ['week', 7 * 24 * 3600],
10
+ ['day', 24 * 3600],
11
+ ['hour', 3600],
12
+ ['minute', 60],
13
+ ];
14
+
15
+ /** "just now", "5 minutes ago", "yesterday", "3 weeks ago". */
16
+ export function formatRelative(iso: string, now: Date = new Date(), locale?: string): string {
17
+ const then = new Date(iso);
18
+ const seconds = Math.round((then.getTime() - now.getTime()) / 1000);
19
+ if (Number.isNaN(seconds)) return '';
20
+ if (Math.abs(seconds) < 45) return 'just now';
21
+ const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
22
+ for (const [unit, size] of UNITS) {
23
+ if (Math.abs(seconds) >= size || unit === 'minute') {
24
+ return format.format(Math.round(seconds / size), unit);
25
+ }
26
+ }
27
+ return 'just now';
28
+ }
29
+
30
+ /** "1 entry", "12 entries". */
31
+ export function formatCount(
32
+ count: number,
33
+ singular: string,
34
+ plural: string,
35
+ locale?: string,
36
+ ): string {
37
+ const number = new Intl.NumberFormat(locale).format(count);
38
+ return `${number} ${count === 1 ? singular : plural}`;
39
+ }
package/src/app/idb.ts ADDED
@@ -0,0 +1,82 @@
1
+ /*
2
+ * A small key-value store in IndexedDB. Without IndexedDB (a private window,
3
+ * blocked storage, jsdom) it keeps values in memory for the visit, so the
4
+ * studio still works; it only forgets them on reload.
5
+ */
6
+
7
+ export type KeyValueStore<T> = {
8
+ get(key: string): Promise<T | undefined>;
9
+ put(key: string, value: T): Promise<void>;
10
+ delete(key: string): Promise<void>;
11
+ keys(): Promise<string[]>;
12
+ };
13
+
14
+ export function keyValueStore<T>(database: string, store: string): KeyValueStore<T> {
15
+ const memory = new Map<string, T>();
16
+ let opened: Promise<IDBDatabase | null> | null = null;
17
+ const open = () => {
18
+ opened ??= new Promise((resolve) => {
19
+ try {
20
+ if (typeof indexedDB === 'undefined') {
21
+ resolve(null);
22
+ return;
23
+ }
24
+ const request = indexedDB.open(database, 1);
25
+ request.onupgradeneeded = () => {
26
+ request.result.createObjectStore(store);
27
+ };
28
+ request.onsuccess = () => {
29
+ resolve(request.result);
30
+ };
31
+ request.onerror = () => {
32
+ resolve(null);
33
+ };
34
+ } catch {
35
+ resolve(null);
36
+ }
37
+ });
38
+ return opened;
39
+ };
40
+ const run = <R>(
41
+ mode: IDBTransactionMode,
42
+ act: (objects: IDBObjectStore) => IDBRequest<R>,
43
+ ): Promise<{ ok: true; value: R } | { ok: false }> =>
44
+ open().then(
45
+ (db) =>
46
+ new Promise((resolve) => {
47
+ if (!db) {
48
+ resolve({ ok: false });
49
+ return;
50
+ }
51
+ try {
52
+ const request = act(db.transaction(store, mode).objectStore(store));
53
+ request.onsuccess = () => {
54
+ resolve({ ok: true, value: request.result });
55
+ };
56
+ request.onerror = () => {
57
+ resolve({ ok: false });
58
+ };
59
+ } catch {
60
+ resolve({ ok: false });
61
+ }
62
+ }),
63
+ );
64
+ return {
65
+ get: async (key) => {
66
+ const found = await run<unknown>('readonly', (objects) => objects.get(key));
67
+ return found.ok ? (found.value as T | undefined) : memory.get(key);
68
+ },
69
+ put: async (key, value) => {
70
+ const done = await run('readwrite', (objects) => objects.put(value, key));
71
+ if (!done.ok) memory.set(key, value);
72
+ },
73
+ delete: async (key) => {
74
+ memory.delete(key);
75
+ await run('readwrite', (objects) => objects.delete(key));
76
+ },
77
+ keys: async () => {
78
+ const found = await run('readonly', (objects) => objects.getAllKeys());
79
+ return found.ok ? found.value.map(String) : [...memory.keys()];
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,40 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ export type Load<T> =
4
+ { state: 'loading' } | { state: 'ready'; value: T } | { state: 'failed'; message: string };
5
+
6
+ /**
7
+ * Loads data for a screen. The loader runs again when `key` changes, or when
8
+ * `reload()` is called (after a save, for example); a reload keeps showing
9
+ * the old value until the new one arrives. A response that arrives after
10
+ * the key moved on is dropped.
11
+ */
12
+ export function useLoad<T>(loader: () => Promise<T>, key = ''): [Load<T>, () => void] {
13
+ const [result, setResult] = useState<{ key: string; load: Load<T> } | null>(null);
14
+ const [generation, setGeneration] = useState(0);
15
+ const latest = useRef(loader);
16
+ useEffect(() => {
17
+ latest.current = loader;
18
+ });
19
+
20
+ useEffect(() => {
21
+ let current = true;
22
+ latest.current().then(
23
+ (value) => {
24
+ if (current) setResult({ key, load: { state: 'ready', value } });
25
+ },
26
+ (error: unknown) => {
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ if (current) setResult({ key, load: { state: 'failed', message } });
29
+ },
30
+ );
31
+ return () => {
32
+ current = false;
33
+ };
34
+ }, [key, generation]);
35
+
36
+ const reload = useCallback(() => {
37
+ setGeneration((n) => n + 1);
38
+ }, []);
39
+ return [result?.key === key ? result.load : { state: 'loading' }, reload];
40
+ }
@@ -0,0 +1,20 @@
1
+ import { useSyncExternalStore } from 'react';
2
+
3
+ /**
4
+ * Whether a CSS media query matches, kept current as the window changes.
5
+ * False where there's no `matchMedia` (and so in jsdom's stub).
6
+ */
7
+ export function useMediaQuery(query: string): boolean {
8
+ return useSyncExternalStore(
9
+ (onChange) => {
10
+ if (typeof window.matchMedia !== 'function') return () => undefined;
11
+ const list = window.matchMedia(query);
12
+ list.addEventListener('change', onChange);
13
+ return () => {
14
+ list.removeEventListener('change', onChange);
15
+ };
16
+ },
17
+ () => typeof window.matchMedia === 'function' && window.matchMedia(query).matches,
18
+ () => false,
19
+ );
20
+ }
@@ -0,0 +1,4 @@
1
+ import { createContext } from 'react';
2
+
3
+ /** How many in-app navigations have happened. 0 on the screen the studio opened on. */
4
+ export const NavigationContext = createContext<{ count: number }>({ count: 0 });
@@ -0,0 +1,25 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ /** Where a screen sits among the panes (app/panes.tsx), or null one screen at a time. */
4
+ export type PaneInfo = {
5
+ /**
6
+ * `main` for the deepest pane, `nav` for the ones that lead to it, and
7
+ * `region` for a pane beside the deepest that leads nowhere (the cloud's
8
+ * waiting drafts on Home).
9
+ */
10
+ landmark: 'main' | 'nav' | 'region';
11
+ /** The deepest pane's title is the page's `h1`; the others are `h2`s. */
12
+ level: 1 | 2;
13
+ /** The deepest pane takes focus after a navigation, and names the browser tab. */
14
+ focusTarget: boolean;
15
+ /** How wide it is: the narrow panes get tighter padding and a smaller heading. */
16
+ width: 'structure' | 'list' | 'flex';
17
+ /** Folds the pane into a strip, when people may: every pane but the deepest. */
18
+ onCollapse?: () => void;
19
+ };
20
+
21
+ export const PaneContext = createContext<PaneInfo | null>(null);
22
+
23
+ export function usePane(): PaneInfo | null {
24
+ return useContext(PaneContext);
25
+ }