@coywolf/emdash 0.0.0-stage → 0.35.13

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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Form uploads page: files sent through Forms plugin forms, kept in a private
3
+ * bucket. Lists them with the submission they came with, downloads them
4
+ * (admins only, always saved as a file), deletes them, and sets which forms
5
+ * keep files private. Turning the feature on or off happens on the Coywolf
6
+ * Pack page.
7
+ *
8
+ * The routes live on the Forms plugin, which privateFormUploads() wraps
9
+ * (see src/formUploads/wrap.ts).
10
+ */
11
+ import { Badge, Banner, Button, Checkbox, Dialog, Input, Loader, Select } from "@cloudflare/kumo";
12
+ import { ArrowClockwise, DownloadSimple, Trash } from "@phosphor-icons/react";
13
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
14
+ import * as React from "react";
15
+
16
+ import { formatBytes, totalFromContentRange, type FormUploadsConfig, type UploadEntry } from "../formUploads/lib.js";
17
+ import { saveFile } from "./download.js";
18
+ import { SaveBar, isDirty } from "./save-bar.js";
19
+ import { SettingsSection, SetupCard, errorText, wholeNumber } from "./settings-ui.js";
20
+
21
+ const API = "/_emdash/api/plugins/emdash-forms/coywolf-private-uploads";
22
+
23
+ interface FormInfo {
24
+ id: string;
25
+ slug: string;
26
+ name: string;
27
+ fileFields: number;
28
+ }
29
+
30
+ interface Status {
31
+ wrapped: true;
32
+ enabled: boolean;
33
+ bucketBound: boolean;
34
+ mediaBucketBound: boolean;
35
+ config: FormUploadsConfig;
36
+ forms: FormInfo[];
37
+ mediaLibraryFiles: number;
38
+ }
39
+
40
+ interface Item extends UploadEntry {
41
+ submission: { id: string; createdAt: string; status: string; preview: string } | null;
42
+ }
43
+
44
+ const SCOPE_ITEMS = [
45
+ { value: "all", label: "All forms" },
46
+ { value: "selected", label: "Only the forms checked below" },
47
+ ];
48
+
49
+ async function post<T>(path: string, body: unknown, fallback: string): Promise<T> {
50
+ const response = await apiFetch(`${API}/${path}`, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify(body),
54
+ });
55
+ return parseApiResponse<T>(response, fallback);
56
+ }
57
+
58
+ async function errorFrom(response: Response, fallback: string): Promise<Error> {
59
+ try {
60
+ const body = (await response.json()) as { error?: { message?: string } };
61
+ return new Error(body.error?.message || fallback);
62
+ } catch {
63
+ return new Error(fallback);
64
+ }
65
+ }
66
+
67
+ /** Fetch a file in 4 MB parts (plugin responses are capped at 8 MB) and save it. */
68
+ async function download(item: UploadEntry): Promise<void> {
69
+ const parts: ArrayBuffer[] = [];
70
+ let total: number | null = null;
71
+ let received = 0;
72
+ let type = "application/octet-stream";
73
+ for (let part = 0; part < 100; part++) {
74
+ const response = await apiFetch(`${API}/download?id=${encodeURIComponent(item.id)}&part=${part}`);
75
+ if (!response.ok) throw await errorFrom(response, "Couldn't download the file");
76
+ type = response.headers.get("Content-Type") ?? type;
77
+ total ??= totalFromContentRange(response.headers.get("Content-Range"));
78
+ const bytes = await response.arrayBuffer();
79
+ parts.push(bytes);
80
+ received += bytes.byteLength;
81
+ if (total === null || received >= total || bytes.byteLength === 0) break;
82
+ }
83
+ // Saved, never opened: the blob is a download, whatever the file claims to be.
84
+ saveFile(new Blob(parts, { type }), item.filename);
85
+ }
86
+
87
+ const dateTime = (iso: string) => {
88
+ const d = new Date(iso);
89
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
90
+ };
91
+
92
+ function DeleteDialog(props: { item: Item | null; onClose: () => void; onDeleted: (item: Item) => void }) {
93
+ const [pending, setPending] = React.useState(false);
94
+ const [error, setError] = React.useState<string>();
95
+ React.useEffect(() => setError(undefined), [props.item]);
96
+ const item = props.item;
97
+ const run = async () => {
98
+ if (!item) return;
99
+ setPending(true);
100
+ setError(undefined);
101
+ try {
102
+ await post("delete", { id: item.id }, "Delete failed");
103
+ props.onDeleted(item);
104
+ } catch (cause) {
105
+ setError(errorText(cause, "Delete failed"));
106
+ } finally {
107
+ setPending(false);
108
+ }
109
+ };
110
+ return (
111
+ <Dialog.Root open={item !== null} onOpenChange={(open) => !open && !pending && props.onClose()}>
112
+ <Dialog className="p-6" size="lg">
113
+ <Dialog.Title className="text-lg font-semibold">Delete {item?.filename}?</Dialog.Title>
114
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">
115
+ The file is removed from the private bucket and can't be recovered. The submission stays, and still shows the file name.
116
+ </Dialog.Description>
117
+ {error && <Banner className="mt-3" variant="error" role="alert" description={error} />}
118
+ <div className="mt-6 flex justify-end gap-2">
119
+ <Button variant="secondary" disabled={pending} onClick={props.onClose}>
120
+ Cancel
121
+ </Button>
122
+ <Button variant="destructive" disabled={pending} onClick={() => void run()}>
123
+ {pending ? "Deleting…" : "Delete file"}
124
+ </Button>
125
+ </div>
126
+ </Dialog>
127
+ </Dialog.Root>
128
+ );
129
+ }
130
+
131
+ function UploadsTable(props: { items: Item[]; onDelete: (item: Item) => void }) {
132
+ const [busy, setBusy] = React.useState<string | null>(null);
133
+ const [error, setError] = React.useState<string | null>(null);
134
+ if (props.items.length === 0) return <p className="text-sm text-kumo-subtle">No private uploads yet.</p>;
135
+ return (
136
+ <div className="space-y-3">
137
+ {error && <Banner variant="error" role="alert" description={error} />}
138
+ <div className="overflow-x-auto">
139
+ <table className="w-full text-left text-sm">
140
+ <thead className="text-kumo-subtle">
141
+ <tr className="border-b border-kumo-line">
142
+ <th className="py-2 pr-4 font-medium">File</th>
143
+ <th className="py-2 pr-4 font-medium">Form</th>
144
+ <th className="py-2 pr-4 font-medium">Submission</th>
145
+ <th className="py-2 font-medium">
146
+ <span className="sr-only">Actions</span>
147
+ </th>
148
+ </tr>
149
+ </thead>
150
+ <tbody>
151
+ {props.items.map((item) => (
152
+ <tr key={item.id} className="border-b border-kumo-line align-top">
153
+ <td className="py-3 pr-4" style={{ minWidth: 200, maxWidth: 320 }}>
154
+ <div className="font-medium" style={{ overflowWrap: "anywhere" }}>
155
+ {item.filename}
156
+ </div>
157
+ <div className="text-kumo-subtle">
158
+ {formatBytes(item.size)} · {item.contentType || "unknown type"}
159
+ </div>
160
+ <div className="text-kumo-subtle">Uploaded {dateTime(item.uploadedAt)}</div>
161
+ {item.source === "media-library" && <Badge variant="secondary">Moved from the media library</Badge>}
162
+ </td>
163
+ <td className="py-3 pr-4" style={{ minWidth: 140 }}>
164
+ <div>{item.formName || item.formSlug || "Unknown form"}</div>
165
+ {item.fieldLabel && <div className="text-kumo-subtle">{item.fieldLabel}</div>}
166
+ </td>
167
+ <td className="py-3 pr-4" style={{ minWidth: 200, maxWidth: 360 }}>
168
+ {item.submission ? (
169
+ <>
170
+ <div>
171
+ {dateTime(item.submission.createdAt)} {item.submission.status === "new" && <Badge>New</Badge>}
172
+ </div>
173
+ {item.submission.preview && (
174
+ <div className="text-kumo-subtle" style={{ overflowWrap: "anywhere" }}>
175
+ {item.submission.preview}
176
+ </div>
177
+ )}
178
+ </>
179
+ ) : (
180
+ <span className="text-kumo-subtle">{item.submissionId ? "Submission deleted" : "Not linked to a submission yet"}</span>
181
+ )}
182
+ </td>
183
+ <td className="py-3">
184
+ <div className="flex justify-end gap-2">
185
+ <Button
186
+ variant="secondary"
187
+ size="sm"
188
+ icon={<DownloadSimple />}
189
+ disabled={busy === item.id}
190
+ aria-label={`Download ${item.filename}`}
191
+ onClick={() => {
192
+ setBusy(item.id);
193
+ setError(null);
194
+ download(item)
195
+ .catch((cause) => setError(errorText(cause, "Couldn't download the file")))
196
+ .finally(() => setBusy(null));
197
+ }}
198
+ >
199
+ {busy === item.id ? "Downloading…" : "Download"}
200
+ </Button>
201
+ <Button variant="ghost" size="sm" icon={<Trash />} aria-label={`Delete ${item.filename}`} onClick={() => props.onDelete(item)}>
202
+ Delete
203
+ </Button>
204
+ </div>
205
+ </td>
206
+ </tr>
207
+ ))}
208
+ </tbody>
209
+ </table>
210
+ </div>
211
+ </div>
212
+ );
213
+ }
214
+
215
+ function MoveEarlierUploads(props: { count: number; onDone: () => void }) {
216
+ const [running, setRunning] = React.useState(false);
217
+ const [progress, setProgress] = React.useState("");
218
+ const [error, setError] = React.useState<string | null>(null);
219
+ const run = async () => {
220
+ setRunning(true);
221
+ setError(null);
222
+ let moved = 0;
223
+ const failed: string[] = [];
224
+ try {
225
+ for (let round = 0; round < 200; round++) {
226
+ const result = await post<{ moved: number; failed: Array<{ filename: string; reason: string }>; remaining: number }>(
227
+ "migrate",
228
+ { limit: 20 },
229
+ "Couldn't move the files",
230
+ );
231
+ moved += result.moved;
232
+ failed.push(...result.failed.map((f) => `${f.filename}: ${f.reason}`));
233
+ setProgress(`Moved ${moved} so far…`);
234
+ if (result.moved === 0 || result.remaining === 0) break;
235
+ }
236
+ setProgress(`Moved ${moved} ${moved === 1 ? "file" : "files"}.`);
237
+ if (failed.length) setError(`Couldn't move ${failed.length}: ${failed.slice(0, 5).join("; ")}${failed.length > 5 ? "…" : ""}`);
238
+ props.onDone();
239
+ } catch (cause) {
240
+ setError(errorText(cause, "Couldn't move the files"));
241
+ } finally {
242
+ setRunning(false);
243
+ }
244
+ };
245
+ return (
246
+ <SettingsSection
247
+ id="cw-form-uploads-move"
248
+ title="Files from before"
249
+ description={`${props.count} ${props.count === 1 ? "file" : "files"} sent before private uploads were on ${props.count === 1 ? "is" : "are"} still in the public media library. Move them to the private bucket: each submission is pointed at its private copy, and the media library copy is deleted.`}
250
+ actions={
251
+ <Button variant="secondary" disabled={running} onClick={() => void run()}>
252
+ {running ? "Moving…" : "Move to private bucket"}
253
+ </Button>
254
+ }
255
+ >
256
+ {progress && (
257
+ <p className="text-sm" role="status">
258
+ {progress}
259
+ </p>
260
+ )}
261
+ {error && <Banner variant="error" role="alert" description={error} />}
262
+ <p className="text-sm text-kumo-subtle">
263
+ Files are moved only for the forms selected above. A copy a browser or CDN already cached can linger until it expires.
264
+ </p>
265
+ </SettingsSection>
266
+ );
267
+ }
268
+
269
+ export function FormUploadsPage() {
270
+ const [status, setStatus] = React.useState<Status | null>(null);
271
+ const [missing, setMissing] = React.useState(false);
272
+ const [items, setItems] = React.useState<Item[] | null>(null);
273
+ const [draft, setDraft] = React.useState<FormUploadsConfig | null>(null);
274
+ const [saving, setSaving] = React.useState(false);
275
+ const [error, setError] = React.useState<string | null>(null);
276
+ const [message, setMessage] = React.useState("");
277
+ const [deleting, setDeleting] = React.useState<Item | null>(null);
278
+
279
+ const load = React.useCallback(async () => {
280
+ setError(null);
281
+ try {
282
+ const response = await apiFetch(`${API}/status`);
283
+ if (response.status === 404) {
284
+ setMissing(true);
285
+ return;
286
+ }
287
+ const next = await parseApiResponse<Status>(response, "Couldn't load the settings");
288
+ setStatus(next);
289
+ setDraft((cur) => cur ?? next.config);
290
+ const list = await apiFetch(`${API}/list`).then((r) => parseApiResponse<{ items: Item[] }>(r, "Couldn't load the uploads"));
291
+ setItems(list.items);
292
+ } catch (cause) {
293
+ setError(errorText(cause, "Couldn't load private uploads"));
294
+ }
295
+ }, []);
296
+
297
+ React.useEffect(() => {
298
+ void load();
299
+ }, [load]);
300
+
301
+ const dirty = isDirty(draft, status?.config);
302
+ const set = (patch: Partial<FormUploadsConfig>) => setDraft((cur) => (cur ? { ...cur, ...patch } : cur));
303
+
304
+ async function save(event?: React.FormEvent) {
305
+ event?.preventDefault();
306
+ if (!draft) return;
307
+ setSaving(true);
308
+ setError(null);
309
+ try {
310
+ const config = await post<FormUploadsConfig>("settings", draft, "Couldn't save");
311
+ setDraft(config);
312
+ setStatus((cur) => (cur ? { ...cur, config } : cur));
313
+ setMessage("Saved.");
314
+ } catch (cause) {
315
+ setError(errorText(cause, "Couldn't save"));
316
+ } finally {
317
+ setSaving(false);
318
+ }
319
+ }
320
+
321
+ const header = (
322
+ <header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
323
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
324
+ <h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Form uploads</h1>
325
+ <div className="flex shrink-0 justify-end">
326
+ <Button variant="secondary" icon={<ArrowClockwise />} onClick={() => void load()} aria-label="Refresh the list">
327
+ Refresh
328
+ </Button>
329
+ </div>
330
+ <p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
331
+ Files people send with your forms are kept in a private storage bucket, not the public media library. Only admins can download them,
332
+ here. Deleting a submission in Forms deletes its files too.
333
+ </p>
334
+ </div>
335
+ </header>
336
+ );
337
+
338
+ if (missing) {
339
+ return (
340
+ <div className="space-y-6">
341
+ {header}
342
+ <SetupCard
343
+ title="Set up private form uploads"
344
+ description={
345
+ <>
346
+ Wrap the Forms plugin in astro.config.mjs with <code>privateFormUploads(formsPlugin())</code> (imported from @coywolf/emdash), add an R2
347
+ bucket binding named FORM_UPLOADS in wrangler.jsonc (a bucket with no public access), and deploy. See docs/form-uploads.md in the Coywolf Pack repository.
348
+ </>
349
+ }
350
+ />
351
+ </div>
352
+ );
353
+ }
354
+
355
+ return (
356
+ <div className="space-y-6">
357
+ {header}
358
+ {error && <Banner variant="error" role="alert" description={error} />}
359
+ <p className="sr-only" role="status" aria-live="polite">
360
+ {message}
361
+ </p>
362
+
363
+ {!status && !error && (
364
+ <div className="flex justify-center py-12">
365
+ <Loader />
366
+ </div>
367
+ )}
368
+
369
+ {status && !status.bucketBound && (
370
+ <SetupCard
371
+ title="Add the private bucket"
372
+ description="Create an R2 bucket with no public access, bind it as FORM_UPLOADS in wrangler.jsonc, and deploy. Until then, forms with files show an error instead of storing files publicly."
373
+ />
374
+ )}
375
+ {status && !status.enabled && (
376
+ <Banner
377
+ variant="alert"
378
+ title="Private form uploads is off"
379
+ description="New files go to the public media library. Turn on Private form uploads on the Coywolf Pack page. Files already here stay private."
380
+ />
381
+ )}
382
+
383
+ {status && draft && (
384
+ <form id="cw-form-uploads-form" className="space-y-6" onSubmit={(e) => void save(e)}>
385
+ <SettingsSection id="cw-form-uploads-settings" title="Which forms" description="Files from these forms go to the private bucket.">
386
+ <Select
387
+ label="Keep files private for"
388
+ value={draft.scope}
389
+ onValueChange={(value: string | null) => set({ scope: value === "selected" ? "selected" : "all" })}
390
+ items={SCOPE_ITEMS}
391
+ />
392
+ {draft.scope === "selected" && (
393
+ <div className="space-y-2">
394
+ {status.forms.length === 0 && <p className="text-sm text-kumo-subtle">No forms yet.</p>}
395
+ {status.forms.map((form) => (
396
+ <Checkbox
397
+ key={form.id}
398
+ label={`${form.name} (${form.slug})${form.fileFields ? "" : " · no file fields"}`}
399
+ checked={draft.forms.includes(form.id) || draft.forms.includes(form.slug)}
400
+ onCheckedChange={(checked: boolean) =>
401
+ set({ forms: checked ? [...draft.forms.filter((f) => f !== form.slug), form.id] : draft.forms.filter((f) => f !== form.id && f !== form.slug) })
402
+ }
403
+ />
404
+ ))}
405
+ </div>
406
+ )}
407
+ <div style={{ maxWidth: 240 }}>
408
+ <Input
409
+ label="Delete files after (days)"
410
+ description="0 keeps each file until its submission is deleted. The submission itself stays either way."
411
+ type="number"
412
+ min={0}
413
+ max={3650}
414
+ value={String(draft.retentionDays)}
415
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ retentionDays: Math.max(0, Math.min(3650, wholeNumber(e.target.value, 0))) })}
416
+ />
417
+ </div>
418
+ </SettingsSection>
419
+ </form>
420
+ )}
421
+
422
+ {status && status.mediaLibraryFiles > 0 && status.enabled && status.bucketBound && (
423
+ <MoveEarlierUploads count={status.mediaLibraryFiles} onDone={() => void load()} />
424
+ )}
425
+
426
+ {status && (
427
+ <SettingsSection id="cw-form-uploads-list" title="Private uploads" description="Newest first. Downloads are saved as files and never open in the browser.">
428
+ {items === null ? <Loader /> : <UploadsTable items={items} onDelete={setDeleting} />}
429
+ </SettingsSection>
430
+ )}
431
+
432
+ <DeleteDialog
433
+ item={deleting}
434
+ onClose={() => setDeleting(null)}
435
+ onDeleted={(item) => {
436
+ setDeleting(null);
437
+ setItems((cur) => (cur ? cur.filter((i) => i.id !== item.id) : cur));
438
+ setMessage(`Deleted ${item.filename}.`);
439
+ }}
440
+ />
441
+
442
+ {status && draft && <SaveBar form="cw-form-uploads-form" dirty={dirty} saving={saving} onDiscard={() => setDraft(status.config)} />}
443
+ </div>
444
+ );
445
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Step-by-step guides for getting the API keys and tokens some features need,
3
+ * shown next to each credential field in a native <details> (closed until
4
+ * opened). The text lives in src/core/guide-content.ts, which EmDash's generic
5
+ * Settings page uses too (through src/core/settings-enhance.ts).
6
+ */
7
+ import * as React from "react";
8
+
9
+ import { GUIDES, type GuideId, guideHtml } from "../core/guide-content.js";
10
+
11
+ export type { GuideId };
12
+
13
+ /** A guide in a closed <details>, using the browser's own disclosure triangle. */
14
+ export function CredentialGuide({ id }: { id: GuideId }) {
15
+ // Static, trusted HTML written in guide-content.ts (no user data).
16
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted constant markup.
17
+ return <div dangerouslySetInnerHTML={{ __html: guideHtml(GUIDES[id]) }} />;
18
+ }