@coywolf/emdash 0.0.0-stage → 0.35.14

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 (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,613 @@
1
+ /**
2
+ * Backups admin page and dashboard widget. Layout and components follow the
3
+ * EmDash admin's own list pages (Redirects, Bylines): Kumo controls, a bordered
4
+ * row list, and confirmation dialogs.
5
+ */
6
+ import { Badge, Banner, Button, Dialog, DropdownMenu, Input, Loader, Switch } from "@cloudflare/kumo";
7
+ import {
8
+ ArrowCounterClockwise,
9
+ ClockCounterClockwise,
10
+ Database,
11
+ DotsThree,
12
+ DownloadSimple,
13
+ ImagesSquare,
14
+ Plus,
15
+ } from "@phosphor-icons/react";
16
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
17
+ import * as React from "react";
18
+
19
+ import { SaveBar } from "./save-bar.js";
20
+ import { SettingsSection } from "./settings-ui.js";
21
+
22
+ const API = "/_emdash/api/plugins/coywolf-pack/backups";
23
+
24
+ interface Backup {
25
+ stamp: string;
26
+ createdAt: string;
27
+ file: string;
28
+ bytes: number;
29
+ source?: string;
30
+ tables?: number;
31
+ rows?: number;
32
+ media?: { copied: number; preserved: number; total: number; pending?: number };
33
+ uploads?: { copied: number; preserved: number; total: number; pending?: number };
34
+ }
35
+
36
+ interface UndoPoint {
37
+ bookmark: string;
38
+ rewoundAt: string;
39
+ rewoundTo: string;
40
+ }
41
+
42
+ interface ListResponse {
43
+ items: Backup[];
44
+ scheduled: boolean;
45
+ retentionDays: number;
46
+ staleAfterHours: number;
47
+ restore: { enabled: true } | { enabled: false; reason: string };
48
+ undo: UndoPoint | null;
49
+ }
50
+
51
+ interface NewDatabaseResult {
52
+ databaseId: string;
53
+ databaseName: string;
54
+ tables: number;
55
+ rows: number;
56
+ mismatches: Record<string, [number, number | null]>;
57
+ }
58
+
59
+ const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
60
+ const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short" });
61
+ const formatDate = (iso: string) => dateFormat.format(new Date(iso));
62
+
63
+ function formatBytes(bytes: number): string {
64
+ if (bytes < 1024) return `${bytes} B`;
65
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
66
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
67
+ }
68
+
69
+ function ago(iso: string): string {
70
+ const hours = (Date.now() - new Date(iso).getTime()) / 3_600_000;
71
+ if (hours < 1) return `${Math.max(1, Math.round(hours * 60))} min ago`;
72
+ if (hours < 48) return `${Math.round(hours)} h ago`;
73
+ return `${Math.round(hours / 24)} days ago`;
74
+ }
75
+
76
+ function sourceLabel(source?: string): string {
77
+ if (source === "admin") return "Manual";
78
+ if (source === "scheduled") return "Scheduled";
79
+ return "Nightly job";
80
+ }
81
+
82
+ async function post<T>(path: string, body?: unknown): Promise<T> {
83
+ const response = await apiFetch(`${API}/${path}`, {
84
+ method: "POST",
85
+ ...(body === undefined ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
86
+ });
87
+ return parseApiResponse<T>(response, "The request failed");
88
+ }
89
+
90
+ function useBackups() {
91
+ const [data, setData] = React.useState<ListResponse>();
92
+ const [error, setError] = React.useState<string>();
93
+ const load = React.useCallback(async () => {
94
+ setError(undefined);
95
+ try {
96
+ setData(await parseApiResponse<ListResponse>(await apiFetch(`${API}/list`), "Could not load backups"));
97
+ } catch (cause) {
98
+ setError(errorText(cause, "Could not load backups"));
99
+ }
100
+ }, []);
101
+ React.useEffect(() => {
102
+ void load();
103
+ }, [load]);
104
+ return { data, error, load };
105
+ }
106
+
107
+ function isStale(data: ListResponse): boolean {
108
+ const latest = data.items[0];
109
+ return !latest || (Date.now() - new Date(latest.createdAt).getTime()) / 3_600_000 > data.staleAfterHours;
110
+ }
111
+
112
+ /**
113
+ * Download a backup's database dump. The server makes a short-lived link that
114
+ * streams the file from R2 (so any size works), served by the Coywolf Pack
115
+ * site middleware; the browser saves it like any other download.
116
+ */
117
+ async function download(backup: Backup) {
118
+ const link = await post<{ url: string; filename: string }>("download-link", { stamp: backup.stamp, file: backup.file });
119
+ const check = await fetch(link.url, { method: "HEAD", credentials: "same-origin" }).catch(() => null);
120
+ if (!check?.ok) {
121
+ throw new Error(
122
+ "The download link didn't answer. Make sure coywolfPack() from @coywolf/emdash/middleware is in the site's src/middleware.ts (see Getting started in the Coywolf Pack README).",
123
+ );
124
+ }
125
+ const a = document.createElement("a");
126
+ a.href = link.url;
127
+ a.download = link.filename;
128
+ a.style.display = "none";
129
+ document.body.appendChild(a);
130
+ a.click();
131
+ a.remove();
132
+ }
133
+
134
+ /** Destructive confirmation that requires typing a word, styled like the admin's ConfirmDialog. */
135
+ function TypedConfirmDialog(props: {
136
+ open: boolean;
137
+ title: string;
138
+ word: string;
139
+ confirmLabel: string;
140
+ pendingLabel: string;
141
+ pending: boolean;
142
+ error?: string;
143
+ onClose: () => void;
144
+ onConfirm: () => void;
145
+ children: React.ReactNode;
146
+ }) {
147
+ const [typed, setTyped] = React.useState("");
148
+ React.useEffect(() => {
149
+ if (props.open) setTyped("");
150
+ }, [props.open]);
151
+ return (
152
+ <Dialog.Root
153
+ role="alertdialog"
154
+ open={props.open}
155
+ onOpenChange={(next) => !next && !props.pending && props.onClose()}
156
+ >
157
+ <Dialog className="p-6" size="sm">
158
+ <Dialog.Title className="text-lg font-semibold">{props.title}</Dialog.Title>
159
+ <Dialog.Description className="mt-2 space-y-2 text-sm text-kumo-subtle">{props.children}</Dialog.Description>
160
+ <div className="mt-4">
161
+ <Input
162
+ label={`Type ${props.word} to confirm`}
163
+ value={typed}
164
+ autoComplete="off"
165
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTyped(e.target.value)}
166
+ />
167
+ </div>
168
+ {props.error && (
169
+ <Banner variant="error" role="alert" className="mt-3" description={props.error} />
170
+ )}
171
+ <div className="mt-6 flex justify-end gap-2">
172
+ <Button variant="secondary" disabled={props.pending} onClick={props.onClose}>
173
+ Cancel
174
+ </Button>
175
+ <Button variant="destructive" disabled={props.pending || typed !== props.word} onClick={props.onConfirm}>
176
+ {props.pending ? props.pendingLabel : props.confirmLabel}
177
+ </Button>
178
+ </div>
179
+ </Dialog>
180
+ </Dialog.Root>
181
+ );
182
+ }
183
+
184
+ /** Schedule and retention (stored as plugin settings; not on the generic Settings page). */
185
+ function ScheduleSettings(props: { data: ListResponse; onSaved: () => Promise<void> }) {
186
+ const initial = React.useCallback(
187
+ () => ({ scheduled: props.data.scheduled, retentionDays: String(props.data.retentionDays), staleAfterHours: String(props.data.staleAfterHours) }),
188
+ [props.data.scheduled, props.data.retentionDays, props.data.staleAfterHours],
189
+ );
190
+ const [draft, setDraft] = React.useState(initial);
191
+ React.useEffect(() => setDraft(initial()), [initial]);
192
+ const [pending, setPending] = React.useState(false);
193
+ const [status, setStatus] = React.useState<{ error: boolean; text: string }>();
194
+ const dirty =
195
+ draft.scheduled !== props.data.scheduled ||
196
+ draft.retentionDays !== String(props.data.retentionDays) ||
197
+ draft.staleAfterHours !== String(props.data.staleAfterHours);
198
+
199
+ const save = async () => {
200
+ setPending(true);
201
+ setStatus(undefined);
202
+ try {
203
+ await post("settings/save", {
204
+ scheduled: draft.scheduled,
205
+ retentionDays: Number(draft.retentionDays),
206
+ staleAfterHours: Number(draft.staleAfterHours),
207
+ });
208
+ await props.onSaved();
209
+ setStatus({ error: false, text: "Backup settings saved." });
210
+ } catch (cause) {
211
+ setStatus({ error: true, text: errorText(cause, "Could not save the backup settings") });
212
+ } finally {
213
+ setPending(false);
214
+ }
215
+ };
216
+
217
+ return (
218
+ <>
219
+ <form
220
+ id="cw-backups-settings-form"
221
+ onSubmit={(e) => {
222
+ e.preventDefault();
223
+ void save();
224
+ }}
225
+ >
226
+ <SettingsSection
227
+ id="backups-schedule"
228
+ title="Schedule and retention"
229
+ description="How often the site backs itself up, how long backups are kept, and when to warn that backups have stopped."
230
+ >
231
+ <Switch className="shrink-0"
232
+ label="Back up once a day from the site itself"
233
+ checked={draft.scheduled}
234
+ onCheckedChange={(checked: boolean) => setDraft((d) => ({ ...d, scheduled: checked }))}
235
+ />
236
+ <p className="-mt-2 text-sm text-kumo-subtle">Leave this off if an external job (such as a GitHub Action) already backs up the site.</p>
237
+ <div className="grid gap-4 sm:grid-cols-2">
238
+ <Input
239
+ type="number"
240
+ min={1}
241
+ max={365}
242
+ label="Keep backups for (days)"
243
+ description="Older database backups and replaced media and upload copies are deleted daily. The mirrors themselves are kept."
244
+ value={draft.retentionDays}
245
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDraft((d) => ({ ...d, retentionDays: e.target.value }))}
246
+ />
247
+ <Input
248
+ type="number"
249
+ min={1}
250
+ max={720}
251
+ label="Warn when the newest backup is older than (hours)"
252
+ description="Shown on this page and the dashboard widget."
253
+ value={draft.staleAfterHours}
254
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDraft((d) => ({ ...d, staleAfterHours: e.target.value }))}
255
+ />
256
+ </div>
257
+ <div aria-live="polite">
258
+ {status && (status.error ? <Banner variant="error" role="alert" description={status.text} /> : <p className="text-sm text-kumo-subtle">{status.text}</p>)}
259
+ </div>
260
+ </SettingsSection>
261
+ </form>
262
+ <SaveBar form="cw-backups-settings-form" dirty={dirty} saving={pending} onDiscard={() => setDraft(initial())} />
263
+ </>
264
+ );
265
+ }
266
+
267
+ type Action =
268
+ | { kind: "rewind"; backup: Backup }
269
+ | { kind: "restore-new"; backup: Backup }
270
+ | { kind: "undo"; undo: UndoPoint };
271
+
272
+ export function BackupsPage() {
273
+ const { data, error, load } = useBackups();
274
+ const [running, setRunning] = React.useState(false);
275
+ const [notice, setNotice] = React.useState<{ variant: "default" | "error"; title: string; description?: React.ReactNode }>();
276
+ const [action, setAction] = React.useState<Action>();
277
+ const [pending, setPending] = React.useState(false);
278
+ const [actionError, setActionError] = React.useState<string>();
279
+ const [mediaPending, setMediaPending] = React.useState(false);
280
+
281
+ const runBackup = async () => {
282
+ setRunning(true);
283
+ setNotice(undefined);
284
+ try {
285
+ const result = await post<Backup>("run");
286
+ setNotice({ variant: "default", title: `Backup saved (${formatBytes(result.bytes)}).` });
287
+ await load();
288
+ } catch (cause) {
289
+ setNotice({ variant: "error", title: "Backup failed", description: errorText(cause, "") });
290
+ } finally {
291
+ setRunning(false);
292
+ }
293
+ };
294
+
295
+ const restoreMedia = async () => {
296
+ setMediaPending(true);
297
+ setNotice(undefined);
298
+ try {
299
+ // The server copies a batch per request; keep asking until nothing is left.
300
+ let restored = 0;
301
+ for (;;) {
302
+ const result = await post<{ restored: number; checked: number; pending?: number }>("restore-media");
303
+ restored += result.restored;
304
+ if (!result.pending || !result.restored) break;
305
+ setNotice({ variant: "default", title: `Restoring files: ${restored} copied so far…` });
306
+ }
307
+ setNotice({
308
+ variant: "default",
309
+ title: restored ? `Restored ${restored} missing ${restored === 1 ? "file" : "files"}.` : "No files were missing.",
310
+ });
311
+ } catch (cause) {
312
+ setNotice({ variant: "error", title: "Media restore failed", description: errorText(cause, "") });
313
+ } finally {
314
+ setMediaPending(false);
315
+ }
316
+ };
317
+
318
+ const confirmAction = async () => {
319
+ if (!action) return;
320
+ setPending(true);
321
+ setActionError(undefined);
322
+ try {
323
+ if (action.kind === "rewind") {
324
+ await post("rewind", { stamp: action.backup.stamp, confirm: "REWIND" });
325
+ setNotice({
326
+ variant: "default",
327
+ title: `The database was rewound to ${formatDate(action.backup.createdAt)}.`,
328
+ description: "Reload any open admin tabs. Use Undo rewind below if this was a mistake.",
329
+ });
330
+ } else if (action.kind === "undo") {
331
+ await post("undo", { confirm: "UNDO" });
332
+ setNotice({ variant: "default", title: "The rewind was undone." });
333
+ } else {
334
+ const result = await post<NewDatabaseResult>("restore-new", { stamp: action.backup.stamp, confirm: "RESTORE" });
335
+ const mismatched = Object.keys(result.mismatches);
336
+ setNotice({
337
+ variant: mismatched.length ? "error" : "default",
338
+ title: mismatched.length
339
+ ? `Restored to ${result.databaseName}, but ${mismatched.length} tables don't match the backup.`
340
+ : `Restored and verified ${result.rows.toLocaleString()} rows in ${result.tables} tables.`,
341
+ description: (
342
+ <div className="space-y-2">
343
+ <p>
344
+ The site still runs on its current database. To switch, set the <code>DB</code> binding in{" "}
345
+ <code>wrangler.jsonc</code> and deploy:
346
+ </p>
347
+ <pre className="overflow-x-auto rounded-md bg-kumo-tint p-3 text-xs">
348
+ {`"database_name": "${result.databaseName}",\n"database_id": "${result.databaseId}"`}
349
+ </pre>
350
+ <p>Keep the old database until you've checked the site.</p>
351
+ </div>
352
+ ),
353
+ });
354
+ }
355
+ setAction(undefined);
356
+ await load();
357
+ } catch (cause) {
358
+ setActionError(errorText(cause, "The restore failed"));
359
+ } finally {
360
+ setPending(false);
361
+ }
362
+ };
363
+
364
+ const latest = data?.items[0];
365
+ const restoreEnabled = data?.restore.enabled ?? false;
366
+
367
+ return (
368
+ <div className="space-y-6">
369
+ <header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
370
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
371
+ <h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Backups</h1>
372
+ <div className="flex shrink-0 justify-end gap-2">
373
+ <Button variant="primary" icon={<Plus />} onClick={() => void runBackup()} disabled={running}>
374
+ {running ? "Backing up…" : "Back up now"}
375
+ </Button>
376
+ </div>
377
+ <p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
378
+ Each backup is a full copy of the database (content, users, settings, menus, redirects) plus a mirror of the
379
+ media library and of private form uploads{data ? `, kept for ${data.retentionDays} days` : ""}. Theme code lives in your Git repository. Download
380
+ any backup to keep your own copy: it's a standard SQL file that restores into any D1 database.
381
+ </p>
382
+ </div>
383
+ </header>
384
+
385
+ {notice && (
386
+ <Banner
387
+ variant={notice.variant === "error" ? "error" : "default"}
388
+ role={notice.variant === "error" ? "alert" : "status"}
389
+ title={notice.title}
390
+ description={notice.description}
391
+ />
392
+ )}
393
+ {error && <Banner variant="error" role="alert" title="Could not load backups" description={error} />}
394
+ {data && isStale(data) && (
395
+ <Banner
396
+ variant="alert"
397
+ role="alert"
398
+ title={latest ? `The newest backup is from ${ago(latest.createdAt)}.` : "There are no backups yet."}
399
+ description={
400
+ data.scheduled
401
+ ? "Scheduled backups may not be running. Check the site's logs."
402
+ : "Turn on daily scheduled backups under Schedule and retention below, or check your external backup job."
403
+ }
404
+ />
405
+ )}
406
+ {data?.undo && (
407
+ <Banner
408
+ variant="default"
409
+ title={`The database was rewound to ${formatDate(new Date(`${data.undo.rewoundTo.slice(0, 13)}:${data.undo.rewoundTo.slice(13, 15)}:00Z`).toISOString())} on ${formatDate(data.undo.rewoundAt)}.`}
410
+ description={
411
+ <Button
412
+ variant="secondary"
413
+ size="sm"
414
+ className="mt-2"
415
+ icon={<ArrowCounterClockwise />}
416
+ disabled={!restoreEnabled}
417
+ onClick={() => setAction({ kind: "undo", undo: data.undo! })}
418
+ >
419
+ Undo rewind
420
+ </Button>
421
+ }
422
+ />
423
+ )}
424
+ {data && !data.restore.enabled && (
425
+ <Banner variant="default" title="Restore isn't set up yet." description={`${data.restore.reason} See docs/backups.md in the Coywolf Pack repository.`} />
426
+ )}
427
+
428
+ {!data && !error ? (
429
+ <div className="py-12 text-center text-kumo-subtle">
430
+ <Loader />
431
+ </div>
432
+ ) : data && data.items.length === 0 ? (
433
+ <div className="py-10 text-center text-kumo-subtle">
434
+ <Database size={40} className="mx-auto mb-3 opacity-30" aria-hidden="true" />
435
+ <p className="text-base font-medium">No backups yet</p>
436
+ <p className="mt-1 text-sm">Back up now, or turn on daily backups under Schedule and retention below.</p>
437
+ </div>
438
+ ) : data ? (
439
+ <div className="rounded-lg border">
440
+ <div className="flex items-center gap-4 border-b bg-kumo-tint/50 px-4 py-2 text-sm font-medium text-kumo-subtle">
441
+ <div className="flex-1">Backup</div>
442
+ <div className="hidden w-32 sm:block">Type</div>
443
+ <div className="w-20 text-end">Size</div>
444
+ <div className="hidden flex-1 md:block">Contents</div>
445
+ <div className="w-10" />
446
+ </div>
447
+ {data.items.map((backup) => (
448
+ <div key={backup.stamp} className="flex items-center gap-4 border-b px-4 py-2 text-sm last:border-0">
449
+ <div className="min-w-0 flex-1">
450
+ <div className="truncate">{formatDate(backup.createdAt)}</div>
451
+ <div className="text-xs text-kumo-subtle">{ago(backup.createdAt)}</div>
452
+ </div>
453
+ <div className="hidden w-32 sm:block">
454
+ <Badge variant={backup.source === "admin" ? "secondary" : "outline"}>{sourceLabel(backup.source)}</Badge>
455
+ </div>
456
+ <div className="w-20 text-end tabular-nums">{formatBytes(backup.bytes)}</div>
457
+ <div className="hidden min-w-0 flex-1 truncate text-kumo-subtle md:block">
458
+ {backup.rows !== undefined ? `${backup.rows.toLocaleString()} rows, ${backup.tables} tables` : "Full database"}
459
+ {backup.media ? ` · ${backup.media.total} media files${backup.media.pending ? ` (${backup.media.pending} still copying)` : ""}` : ""}
460
+ {backup.uploads ? ` · ${backup.uploads.total} form uploads${backup.uploads.pending ? ` (${backup.uploads.pending} still copying)` : ""}` : ""}
461
+ </div>
462
+ <div className="flex w-10 justify-end">
463
+ <DropdownMenu>
464
+ <DropdownMenu.Trigger
465
+ render={
466
+ <Button
467
+ type="button"
468
+ variant="ghost"
469
+ shape="square"
470
+ icon={<DotsThree aria-hidden="true" />}
471
+ aria-label={`Actions for the ${formatDate(backup.createdAt)} backup`}
472
+ />
473
+ }
474
+ />
475
+ <DropdownMenu.Content className="p-1">
476
+ <DropdownMenu.Item
477
+ className="py-1 data-highlighted:bg-kumo-fill"
478
+ icon={<DownloadSimple className="me-1.5 size-3.5" aria-hidden="true" />}
479
+ onClick={() =>
480
+ void download(backup).catch((cause) =>
481
+ setNotice({ variant: "error", title: "Download failed", description: errorText(cause, "") }),
482
+ )
483
+ }
484
+ >
485
+ Download (.sql.gz)
486
+ </DropdownMenu.Item>
487
+ <DropdownMenu.Separator className="my-0.5" />
488
+ <DropdownMenu.Item
489
+ className="py-1 data-highlighted:bg-kumo-fill"
490
+ disabled={!restoreEnabled}
491
+ icon={<ClockCounterClockwise className="me-1.5 size-3.5" aria-hidden="true" />}
492
+ onClick={() => {
493
+ setActionError(undefined);
494
+ setAction({ kind: "rewind", backup });
495
+ }}
496
+ >
497
+ Rewind to this backup…
498
+ </DropdownMenu.Item>
499
+ <DropdownMenu.Item
500
+ className="py-1 data-highlighted:bg-kumo-fill"
501
+ disabled={!restoreEnabled}
502
+ icon={<Database className="me-1.5 size-3.5" aria-hidden="true" />}
503
+ onClick={() => {
504
+ setActionError(undefined);
505
+ setAction({ kind: "restore-new", backup });
506
+ }}
507
+ >
508
+ Restore to a new database…
509
+ </DropdownMenu.Item>
510
+ </DropdownMenu.Content>
511
+ </DropdownMenu>
512
+ </div>
513
+ </div>
514
+ ))}
515
+ </div>
516
+ ) : null}
517
+
518
+ {data && data.items.length > 0 && (
519
+ <section className="flex flex-wrap items-center justify-between gap-4 rounded-lg border p-4">
520
+ <div>
521
+ <h2 className="text-base font-semibold">Media library</h2>
522
+ <p className="text-sm text-kumo-subtle">
523
+ Copy back any media files and private form uploads that are missing. Existing files are never overwritten.
524
+ </p>
525
+ </div>
526
+ <Button variant="secondary" icon={<ImagesSquare />} disabled={mediaPending} onClick={() => void restoreMedia()}>
527
+ {mediaPending ? "Restoring…" : "Restore missing media"}
528
+ </Button>
529
+ </section>
530
+ )}
531
+
532
+ {data && <ScheduleSettings data={data} onSaved={load} />}
533
+
534
+ <TypedConfirmDialog
535
+ open={action?.kind === "rewind"}
536
+ title="Rewind the database?"
537
+ word="REWIND"
538
+ confirmLabel="Rewind database"
539
+ pendingLabel="Rewinding…"
540
+ pending={pending}
541
+ error={actionError}
542
+ onClose={() => setAction(undefined)}
543
+ onConfirm={() => void confirmAction()}
544
+ >
545
+ {action?.kind === "rewind" && (
546
+ <>
547
+ <p>
548
+ Everything in the database returns to {formatDate(action.backup.createdAt)}: content, users, settings,
549
+ menus, and redirects. Changes made since then are undone.
550
+ </p>
551
+ <p>
552
+ An undo point is saved first, so you can reverse this. Media files aren't changed. Anyone who signed in
553
+ after that time may need to sign in again.
554
+ </p>
555
+ </>
556
+ )}
557
+ </TypedConfirmDialog>
558
+
559
+ <TypedConfirmDialog
560
+ open={action?.kind === "restore-new"}
561
+ title="Restore to a new database?"
562
+ word="RESTORE"
563
+ confirmLabel="Create and restore"
564
+ pendingLabel="Restoring…"
565
+ pending={pending}
566
+ error={actionError}
567
+ onClose={() => setAction(undefined)}
568
+ onConfirm={() => void confirmAction()}
569
+ >
570
+ {action?.kind === "restore-new" && (
571
+ <>
572
+ <p>
573
+ This creates a new D1 database from the {formatDate(action.backup.createdAt)} backup and checks it row by
574
+ row. The live site isn't touched.
575
+ </p>
576
+ <p>Use this when the database is gone or the backup is older than Time Travel's 30-day window.</p>
577
+ </>
578
+ )}
579
+ </TypedConfirmDialog>
580
+
581
+ <TypedConfirmDialog
582
+ open={action?.kind === "undo"}
583
+ title="Undo the rewind?"
584
+ word="UNDO"
585
+ confirmLabel="Undo rewind"
586
+ pendingLabel="Undoing…"
587
+ pending={pending}
588
+ error={actionError}
589
+ onClose={() => setAction(undefined)}
590
+ onConfirm={() => void confirmAction()}
591
+ >
592
+ <p>The database returns to the moment just before the rewind. Changes made since the rewind are undone.</p>
593
+ </TypedConfirmDialog>
594
+ </div>
595
+ );
596
+ }
597
+
598
+ export function BackupStatusWidget() {
599
+ const { data, error } = useBackups();
600
+ if (error) return <p className="text-sm text-kumo-danger">{error}</p>;
601
+ if (!data) return <Loader />;
602
+ const latest = data.items[0];
603
+ if (!latest) return <p className="text-sm text-kumo-danger">No backups yet.</p>;
604
+ return (
605
+ <div className="space-y-1 text-sm">
606
+ <p className={isStale(data) ? "font-medium text-kumo-danger" : "font-medium"}>Last backup {ago(latest.createdAt)}</p>
607
+ <p className="text-kumo-subtle">
608
+ {sourceLabel(latest.source)} · {data.items.length} kept
609
+ </p>
610
+ </div>
611
+ );
612
+ }
613
+