@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,791 @@
1
+ /**
2
+ * Files page: download files used by File download blocks and large uploads,
3
+ * with downloads, where each is used, a large-file uploader (through the site to
4
+ * R2), and delete.
5
+ */
6
+ import { Badge, Banner, Button, Dialog, DropdownMenu, Input, Loader, Meter, Select, Tabs } from "@cloudflare/kumo";
7
+ import { ArrowsClockwise, CloudArrowUp, DotsThree, DownloadSimple, FileArrowDown, LinkSimple, Trash, X } from "@phosphor-icons/react";
8
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
9
+ import * as React from "react";
10
+
11
+ import { FILE_CSS, SAMPLE_FILE, renderFileCardHtml } from "../files/card.js";
12
+ import { filesToCsv } from "../files/export.js";
13
+ import { formatSize, iconFor } from "../files/format.js";
14
+ import { saveFile, siteSlug, today } from "./download.js";
15
+ import { CredentialGuide } from "./guides.js";
16
+ import { PreviewSection } from "./preview.js";
17
+ import { SaveBar, isDirty } from "./save-bar.js";
18
+ import { SecretField, SettingsSection, SetupCard } from "./settings-ui.js";
19
+ import { PartSwitches } from "./feature-switches.js";
20
+
21
+ const API = "/_emdash/api/plugins/coywolf-pack/files";
22
+ const CONCURRENCY = 4;
23
+
24
+ interface Usage {
25
+ collection: string;
26
+ id: string;
27
+ title: string;
28
+ }
29
+
30
+ interface FileItem {
31
+ id: string;
32
+ source: "upload" | "media" | "missing";
33
+ status: "ready" | "uploading";
34
+ name: string;
35
+ type: string;
36
+ ext: string;
37
+ size: number;
38
+ uploadedAt: string | null;
39
+ url: string | null;
40
+ downloads: number;
41
+ lastDownload: string | null;
42
+ usedIn: Usage[];
43
+ }
44
+
45
+ interface ListResponse {
46
+ items: FileItem[];
47
+ base: string;
48
+ countsEnabled: boolean;
49
+ largeUploadsEnabled: boolean;
50
+ r2Configured: boolean;
51
+ }
52
+
53
+ interface FilesSettings {
54
+ filesBase: string;
55
+ filesPublicBaseUrl: string;
56
+ filesScheme: "auto" | "light" | "dark";
57
+ filesAccent: string;
58
+ filesMaxUploadGb: number;
59
+ filesR2AccountId: string;
60
+ filesR2AccessKeyId: string;
61
+ filesR2Bucket: string;
62
+ r2SecretSet: boolean;
63
+ }
64
+
65
+ type Filter = "all" | "used" | "unused";
66
+
67
+ const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
68
+ const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium" });
69
+ const formatDate = (iso: string | null) => (iso ? dateFormat.format(new Date(iso)) : "");
70
+
71
+ async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
72
+ const response = await apiFetch(`${API}/${path}`, {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/json" },
75
+ body: JSON.stringify(body),
76
+ signal,
77
+ });
78
+ return parseApiResponse<T>(response, "The request failed");
79
+ }
80
+
81
+ function TypeBadge({ ext }: { ext: string }) {
82
+ const icon = iconFor(ext);
83
+ return (
84
+ <svg width="28" height="34" viewBox="0 0 40 48" fill="none" aria-hidden="true" focusable="false" className="shrink-0">
85
+ <path
86
+ d="M10 3 h14 l10 10 v28 a4 4 0 0 1 -4 4 H10 a4 4 0 0 1 -4 -4 V7 a4 4 0 0 1 4 -4 z"
87
+ className="fill-kumo-base cw-stroke-line"
88
+ strokeWidth="1.5"
89
+ />
90
+ <path d="M24 3 L24 13 L34 13" className="cw-fill-tint cw-stroke-line" strokeWidth="1.5" strokeLinejoin="round" />
91
+ <rect x="2" y="26" width="27" height="15" rx="3.5" fill={icon.color} />
92
+ <text x="15.5" y="36.5" textAnchor="middle" fontSize="8.5" fontWeight="700" fill="#fff">
93
+ {icon.label}
94
+ </text>
95
+ </svg>
96
+ );
97
+ }
98
+
99
+ // ── Large upload (multipart, through the site) ───────────────────
100
+
101
+ interface UploadProgress {
102
+ name: string;
103
+ loaded: number;
104
+ total: number;
105
+ }
106
+
107
+ /**
108
+ * One part, sent to files/upload-part, which uploads it to R2. EmDash's admin
109
+ * Content-Security-Policy (connect-src 'self', since EmDash 1.3.1) blocks the browser
110
+ * from sending parts to R2 itself.
111
+ */
112
+ function sendPart(id: string, partNumber: number, body: Blob, onProgress: (loaded: number) => void, signal: AbortSignal): Promise<string> {
113
+ return new Promise((resolve, reject) => {
114
+ const xhr = new XMLHttpRequest();
115
+ xhr.open("POST", `${API}/upload-part`);
116
+ xhr.setRequestHeader("X-EmDash-Request", "1");
117
+ xhr.setRequestHeader("Content-Type", "application/octet-stream");
118
+ xhr.setRequestHeader("x-cw-upload-id", id);
119
+ xhr.setRequestHeader("x-cw-part-number", String(partNumber));
120
+ xhr.upload.onprogress = (e) => onProgress(e.loaded);
121
+ xhr.onload = () => {
122
+ let body: { success?: boolean; data?: { etag?: string }; error?: { message?: string } } | null = null;
123
+ try {
124
+ body = JSON.parse(xhr.responseText);
125
+ } catch {
126
+ // Not JSON: report the status below.
127
+ }
128
+ if (xhr.status >= 200 && xhr.status < 300 && body?.data?.etag) return resolve(body.data.etag);
129
+ reject(new Error(body?.error?.message || `The site couldn't store a part (HTTP ${xhr.status}).`));
130
+ };
131
+ xhr.onerror = () => reject(new Error("The upload was interrupted. Check your connection and try again."));
132
+ xhr.onabort = () => reject(new DOMException("Upload canceled", "AbortError"));
133
+ signal.addEventListener("abort", () => xhr.abort(), { once: true });
134
+ xhr.send(body);
135
+ });
136
+ }
137
+
138
+ async function uploadLarge(file: File, onProgress: (p: UploadProgress) => void, signal: AbortSignal): Promise<{ id: string; url: string }> {
139
+ const start = await post<{ id: string; partSize: number; partCount: number }>("upload-start", { name: file.name, size: file.size, type: file.type }, signal);
140
+ const { id, partSize, partCount } = start;
141
+ try {
142
+ const loaded = new Array<number>(partCount + 1).fill(0);
143
+ const report = () => onProgress({ name: file.name, loaded: loaded.reduce((a, b) => a + b, 0), total: file.size });
144
+ const etags: Array<{ partNumber: number; etag: string }> = [];
145
+
146
+ let next = 1;
147
+ async function worker() {
148
+ while (next <= partCount) {
149
+ const partNumber = next++;
150
+ const blob = file.slice((partNumber - 1) * partSize, Math.min(file.size, partNumber * partSize));
151
+ for (let attempt = 1; ; attempt++) {
152
+ try {
153
+ const etag = await sendPart(
154
+ id,
155
+ partNumber,
156
+ blob,
157
+ (n) => {
158
+ loaded[partNumber] = n;
159
+ report();
160
+ },
161
+ signal,
162
+ );
163
+ loaded[partNumber] = blob.size;
164
+ report();
165
+ etags.push({ partNumber, etag });
166
+ break;
167
+ } catch (error) {
168
+ if (signal.aborted || attempt >= 3) throw error;
169
+ loaded[partNumber] = 0;
170
+ await new Promise((r) => setTimeout(r, 1000 * attempt));
171
+ }
172
+ }
173
+ }
174
+ }
175
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, partCount) }, worker));
176
+ return await post<{ id: string; url: string }>("upload-complete", { id, parts: etags }, signal);
177
+ } catch (error) {
178
+ await post("upload-abort", { id }).catch(() => undefined);
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ // ── Dialogs ──────────────────────────────────────────────────────
184
+
185
+ function UsageList({ usedIn }: { usedIn: Usage[] }) {
186
+ return (
187
+ <ul className="mt-2 max-h-48 list-disc space-y-1 overflow-y-auto ps-5 text-sm">
188
+ {usedIn.map((u) => (
189
+ <li key={`${u.collection}:${u.id}`}>
190
+ <a className="text-kumo-link underline" href={`/_emdash/admin/content/${encodeURIComponent(u.collection)}/${encodeURIComponent(u.id)}`}>
191
+ {u.title}
192
+ </a>{" "}
193
+ <span className="text-kumo-subtle">({u.collection})</span>
194
+ </li>
195
+ ))}
196
+ </ul>
197
+ );
198
+ }
199
+
200
+ function DeleteDialog(props: { file: FileItem | null; onClose: () => void; onDeleted: (file: FileItem) => void }) {
201
+ const [pending, setPending] = React.useState(false);
202
+ const [error, setError] = React.useState<string>();
203
+ React.useEffect(() => setError(undefined), [props.file]);
204
+ const file = props.file;
205
+ const run = async () => {
206
+ if (!file) return;
207
+ setPending(true);
208
+ setError(undefined);
209
+ try {
210
+ await post("delete", { id: file.id });
211
+ props.onDeleted(file);
212
+ } catch (cause) {
213
+ setError(errorText(cause, "Delete failed"));
214
+ } finally {
215
+ setPending(false);
216
+ }
217
+ };
218
+ return (
219
+ <Dialog.Root open={file !== null} onOpenChange={(open) => !open && !pending && props.onClose()}>
220
+ <Dialog className="p-6" size="lg">
221
+ <Dialog.Title className="text-lg font-semibold">{file?.status === "uploading" ? "Discard incomplete upload?" : `Delete ${file?.name}?`}</Dialog.Title>
222
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">
223
+ The file is removed from the R2 bucket and can't be recovered. Its download link stops working.
224
+ </Dialog.Description>
225
+ {file && file.usedIn.length > 0 && (
226
+ <Banner
227
+ className="mt-4"
228
+ variant="alert"
229
+ title={`Used in ${file.usedIn.length} ${file.usedIn.length === 1 ? "entry" : "entries"}`}
230
+ description={
231
+ <>
232
+ Their File download blocks will render nothing until you remove or replace them:
233
+ <UsageList usedIn={file.usedIn} />
234
+ </>
235
+ }
236
+ />
237
+ )}
238
+ {error && <Banner className="mt-3" variant="error" role="alert" description={error} />}
239
+ <div className="mt-6 flex justify-end gap-2">
240
+ <Button variant="secondary" disabled={pending} onClick={props.onClose}>
241
+ Cancel
242
+ </Button>
243
+ <Button variant="destructive" disabled={pending} onClick={() => void run()}>
244
+ {pending ? "Deleting…" : "Delete file"}
245
+ </Button>
246
+ </div>
247
+ </Dialog>
248
+ </Dialog.Root>
249
+ );
250
+ }
251
+
252
+ // ── Settings ─────────────────────────────────────────────────────
253
+
254
+ type SettingsDraft = Omit<FilesSettings, "filesMaxUploadGb" | "r2SecretSet"> & { filesMaxUploadGb: string; filesR2SecretAccessKey: string };
255
+
256
+ function SettingsPanel(props: { largeUploadsEnabled: boolean; onSaved: (message: string) => void }) {
257
+ const [saved, setSaved] = React.useState<FilesSettings>();
258
+ const [draft, setDraft] = React.useState<SettingsDraft>();
259
+ const [error, setError] = React.useState<string>();
260
+ const [pending, setPending] = React.useState<"save" | "clear">();
261
+
262
+ const toDraft = (s: FilesSettings): SettingsDraft => {
263
+ const { r2SecretSet: _set, filesMaxUploadGb, ...rest } = s;
264
+ return { ...rest, filesMaxUploadGb: String(filesMaxUploadGb), filesR2SecretAccessKey: "" };
265
+ };
266
+ const apply = (s: FilesSettings) => {
267
+ setSaved(s);
268
+ setDraft(toDraft(s));
269
+ };
270
+ React.useEffect(() => {
271
+ apiFetch(`${API}/settings`)
272
+ .then((response) => parseApiResponse<FilesSettings>(response, "Could not load the settings"))
273
+ .then(apply)
274
+ .catch((cause) => setError(errorText(cause, "Could not load the settings")));
275
+ }, []);
276
+
277
+ const set = (patch: Partial<SettingsDraft>) => setDraft((d) => (d ? { ...d, ...patch } : d));
278
+ const save = async (clearR2Secret = false) => {
279
+ if (!draft) return;
280
+ setPending(clearR2Secret ? "clear" : "save");
281
+ setError(undefined);
282
+ try {
283
+ const { filesR2SecretAccessKey, filesMaxUploadGb, ...rest } = draft;
284
+ const result = await post<FilesSettings>("settings/save", {
285
+ ...rest,
286
+ filesMaxUploadGb: Number(filesMaxUploadGb),
287
+ ...(clearR2Secret ? { clearR2Secret: true } : filesR2SecretAccessKey.trim() ? { filesR2SecretAccessKey: filesR2SecretAccessKey.trim() } : {}),
288
+ });
289
+ apply(result);
290
+ props.onSaved(clearR2Secret ? "R2 secret access key removed." : "File Downloads settings saved.");
291
+ } catch (cause) {
292
+ setError(errorText(cause, "Could not save the settings"));
293
+ } finally {
294
+ setPending(undefined);
295
+ }
296
+ };
297
+
298
+ if (!draft || !saved)
299
+ return error ? (
300
+ <Banner variant="error" role="alert" title="Could not load the settings" description={error} />
301
+ ) : (
302
+ <div className="py-12 text-center">
303
+ <Loader />
304
+ </div>
305
+ );
306
+
307
+ const field = (key: "filesBase" | "filesPublicBaseUrl" | "filesAccent" | "filesR2AccountId" | "filesR2AccessKeyId" | "filesR2Bucket") => ({
308
+ value: draft[key],
309
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => set({ [key]: e.target.value }),
310
+ });
311
+
312
+ const dirty = isDirty(draft, toDraft(saved));
313
+
314
+ return (
315
+ <>
316
+ <form
317
+ id="cw-files-settings-form"
318
+ className="space-y-6"
319
+ onSubmit={(e) => {
320
+ e.preventDefault();
321
+ void save();
322
+ }}
323
+ >
324
+ <SettingsSection id="files-links" title="Download links" description="Where download links point. Changing the base changes every download link on the site.">
325
+ <div className="grid gap-4 sm:grid-cols-2">
326
+ <Input label="Download URL base" placeholder="download" description={`Links look like /${draft.filesBase || "download"}/<id>/<file name>. One path segment.`} {...field("filesBase")} />
327
+ <Input
328
+ label="Public bucket or CDN URL (optional)"
329
+ placeholder="https://files.example.com"
330
+ description="Redirect downloads to this URL plus the object key instead of streaming them through the Worker. Use only when media and large uploads share that bucket."
331
+ {...field("filesPublicBaseUrl")}
332
+ />
333
+ </div>
334
+ </SettingsSection>
335
+
336
+ <SettingsSection id="files-card" title="Download card" description="How the File download block looks on the site.">
337
+ <div className="grid gap-4 sm:grid-cols-2">
338
+ <Select
339
+ label="Color scheme"
340
+ value={draft.filesScheme}
341
+ onValueChange={(value: string | null) => set({ filesScheme: value === "light" || value === "dark" ? value : "auto" })}
342
+ items={[
343
+ { value: "auto", label: "Auto (follow the visitor's system setting)" },
344
+ { value: "light", label: "Light" },
345
+ { value: "dark", label: "Dark" },
346
+ ]}
347
+ />
348
+ <Input label="Accent color (optional)" placeholder="#007392" description="Hex color for the download button and focus ring. Empty uses the default." {...field("filesAccent")} />
349
+ </div>
350
+ <PreviewSection
351
+ id="files-card-preview"
352
+ title="Download card preview"
353
+ css={FILE_CSS}
354
+ html={renderFileCardHtml(SAMPLE_FILE, { scheme: draft.filesScheme, accent: draft.filesAccent })}
355
+ note="A sample file with every part of the block shown. Auto follows your computer's light or dark setting here, and each visitor's on the site."
356
+ />
357
+ </SettingsSection>
358
+
359
+ <SettingsSection
360
+ id="files-r2"
361
+ title="Large uploads"
362
+ description={
363
+ <>
364
+ Files over 50 MB, or of any type, upload straight from the browser to R2 through its S3 API. Create an R2 API token with Object Read &amp; Write
365
+ on the bucket. R2 storage and operations are billed to your account.
366
+ {!props.largeUploadsEnabled && " Turn on Large uploads under Plugins → Coywolf Pack to use them."}
367
+ </>
368
+ }
369
+ >
370
+ <div className="grid gap-4 sm:grid-cols-2">
371
+ <Input label="R2 account ID" description="Your Cloudflare account ID (32 characters)." {...field("filesR2AccountId")} />
372
+ <Input label="R2 bucket name" placeholder="mysite-media" description="The bucket bound as MEDIA, unless you set a separate uploads binding." {...field("filesR2Bucket")} />
373
+ <Input label="R2 access key ID" autoComplete="off" {...field("filesR2AccessKeyId")} />
374
+ <SecretField
375
+ label="R2 secret access key"
376
+ saved={saved.r2SecretSet}
377
+ value={draft.filesR2SecretAccessKey}
378
+ onChange={(value) => set({ filesR2SecretAccessKey: value })}
379
+ description="Stored encrypted."
380
+ onClear={() => void save(true)}
381
+ clearing={pending === "clear"}
382
+ disabled={Boolean(pending)}
383
+ />
384
+ <Input
385
+ type="number"
386
+ min={1}
387
+ max={50}
388
+ label="Largest upload (GB)"
389
+ description="Larger uploads are refused. Default 5."
390
+ value={draft.filesMaxUploadGb}
391
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ filesMaxUploadGb: e.target.value })}
392
+ />
393
+ </div>
394
+ <CredentialGuide id="r2" />
395
+ </SettingsSection>
396
+
397
+ {error && <Banner variant="error" role="alert" description={error} />}
398
+ </form>
399
+ <SaveBar
400
+ form="cw-files-settings-form"
401
+ dirty={dirty}
402
+ saving={pending === "save"}
403
+ canSave={!pending}
404
+ onDiscard={() => apply(saved)}
405
+ />
406
+ </>
407
+ );
408
+ }
409
+
410
+ // ── Page ─────────────────────────────────────────────────────────
411
+
412
+ export function FilesPage() {
413
+ const [data, setData] = React.useState<ListResponse>();
414
+ const [error, setError] = React.useState<string>();
415
+ const [notice, setNotice] = React.useState<string>();
416
+ const [query, setQuery] = React.useState("");
417
+ const [filter, setFilter] = React.useState<Filter>("all");
418
+ const [deleting, setDeleting] = React.useState<FileItem | null>(null);
419
+ const [busy, setBusy] = React.useState<string>();
420
+ const [progress, setProgress] = React.useState<UploadProgress | null>(null);
421
+ const [tab, setTab] = React.useState("files");
422
+ const abortRef = React.useRef<AbortController | null>(null);
423
+ const inputRef = React.useRef<HTMLInputElement>(null);
424
+
425
+ const load = React.useCallback(async () => {
426
+ setError(undefined);
427
+ try {
428
+ setData(await parseApiResponse<ListResponse>(await apiFetch(`${API}/list`), "Could not load files"));
429
+ } catch (cause) {
430
+ setError(errorText(cause, "Could not load files"));
431
+ }
432
+ }, []);
433
+ React.useEffect(() => {
434
+ void load();
435
+ }, [load]);
436
+
437
+ // Stop an upload in progress if the page is left.
438
+ React.useEffect(() => () => abortRef.current?.abort(), []);
439
+
440
+ const upload = async (file: File) => {
441
+ const controller = new AbortController();
442
+ abortRef.current = controller;
443
+ setError(undefined);
444
+ setNotice(undefined);
445
+ setProgress({ name: file.name, loaded: 0, total: file.size });
446
+ try {
447
+ await uploadLarge(file, setProgress, controller.signal);
448
+ setNotice(`Uploaded ${file.name}. Add it to an entry with the File download block.`);
449
+ await load();
450
+ } catch (cause) {
451
+ if (controller.signal.aborted) setNotice(`Canceled the upload of ${file.name}.`);
452
+ else setError(errorText(cause, "Upload failed"));
453
+ } finally {
454
+ abortRef.current = null;
455
+ setProgress(null);
456
+ }
457
+ };
458
+
459
+ const reindex = async () => {
460
+ setBusy("reindex");
461
+ setError(undefined);
462
+ setNotice("Rebuilding the usage index…");
463
+ try {
464
+ let next: unknown;
465
+ let scanned = 0;
466
+ for (let i = 0; i < 1000; i++) {
467
+ const result = await post<{ done: boolean; scanned: number; next?: unknown }>("reindex", next ? { next } : {});
468
+ scanned += result.scanned;
469
+ if (result.done) break;
470
+ next = result.next;
471
+ }
472
+ setNotice(`Usage index rebuilt from ${scanned.toLocaleString()} ${scanned === 1 ? "entry" : "entries"}.`);
473
+ await load();
474
+ } catch (cause) {
475
+ setNotice(undefined);
476
+ setError(errorText(cause, "Could not rebuild the usage index"));
477
+ } finally {
478
+ setBusy(undefined);
479
+ }
480
+ };
481
+
482
+ /** All files (not just the filtered ones), newest first. */
483
+ const exportCsv = () => {
484
+ if (!data) return;
485
+ const files = [...data.items].sort((a, b) => (b.uploadedAt ?? "").localeCompare(a.uploadedAt ?? ""));
486
+ saveFile(filesToCsv(files, window.location.origin), `files-${siteSlug() || "site"}-${today()}.csv`, "text/csv");
487
+ setNotice(`Exported ${files.length.toLocaleString()} ${files.length === 1 ? "file" : "files"}.`);
488
+ };
489
+
490
+ const copy = async (file: FileItem) => {
491
+ if (!file.url) return;
492
+ try {
493
+ await navigator.clipboard.writeText(new URL(file.url, window.location.origin).href);
494
+ setNotice(`Copied the download link for ${file.name}.`);
495
+ } catch {
496
+ setError("Could not copy the link.");
497
+ }
498
+ };
499
+
500
+ const q = query.trim().toLowerCase();
501
+ const visible = (data?.items ?? [])
502
+ .filter((f) => (filter === "used" ? f.usedIn.length > 0 : filter === "unused" ? f.usedIn.length === 0 : true))
503
+ .filter((f) => !q || f.name.toLowerCase().includes(q) || f.ext.includes(q) || f.usedIn.some((u) => u.title.toLowerCase().includes(q)))
504
+ .sort((a, b) => (b.uploadedAt ?? "").localeCompare(a.uploadedAt ?? ""));
505
+ const pct = progress && progress.total ? Math.min(100, Math.round((progress.loaded / progress.total) * 100)) : 0;
506
+
507
+ return (
508
+ <div className="space-y-6">
509
+ <header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
510
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
511
+ <h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Files</h1>
512
+ <div className="flex shrink-0 flex-wrap justify-end gap-2">
513
+ {tab === "files" && (
514
+ <Button variant="secondary" icon={<DownloadSimple />} disabled={!data?.items.length} title="Download the list of files, with download counts and where each is used, as a CSV file" onClick={exportCsv}>
515
+ Export CSV
516
+ </Button>
517
+ )}
518
+ {tab === "files" && (
519
+ <Button variant="secondary" icon={<ArrowsClockwise />} disabled={!!busy} onClick={() => void reindex()}>
520
+ {busy === "reindex" ? "Rebuilding…" : "Rebuild usage"}
521
+ </Button>
522
+ )}
523
+ {tab === "files" && data?.largeUploadsEnabled && data.r2Configured && (
524
+ <>
525
+ <Button variant="primary" icon={<CloudArrowUp />} disabled={!!progress} onClick={() => inputRef.current?.click()}>
526
+ Upload large file
527
+ </Button>
528
+ <input
529
+ ref={inputRef}
530
+ type="file"
531
+ className="sr-only"
532
+ tabIndex={-1}
533
+ aria-hidden="true"
534
+ onChange={(e) => {
535
+ const file = e.target.files?.[0];
536
+ e.target.value = "";
537
+ if (file) void upload(file);
538
+ }}
539
+ />
540
+ </>
541
+ )}
542
+ </div>
543
+ <p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
544
+ Files offered for download with the File download block: Media Library files used in blocks, and large uploads.
545
+ {data ? (
546
+ <>
547
+ {" "}
548
+ Download links look like <code>/{data.base}/&lt;id&gt;/&lt;file name&gt;</code>.
549
+ </>
550
+ ) : null}
551
+ {data && !data.largeUploadsEnabled ? " Turn on Large uploads (Plugins → Coywolf Pack) to upload files over 50 MB or of any type." : ""}
552
+ </p>
553
+ </div>
554
+ {tab === "files" && (
555
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
556
+ <div className="sm:w-72">
557
+ <Input
558
+ label="Search"
559
+ placeholder="File names, types, entries"
560
+ value={query}
561
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
562
+ />
563
+ </div>
564
+ <div className="sm:w-44">
565
+ <Select
566
+ label="Show"
567
+ value={filter}
568
+ onValueChange={(value: string | null) => setFilter((value as Filter) ?? "all")}
569
+ items={[
570
+ { value: "all", label: "All files" },
571
+ { value: "used", label: "In use" },
572
+ { value: "unused", label: "Unused" },
573
+ ]}
574
+ />
575
+ </div>
576
+ </div>
577
+ )}
578
+ </header>
579
+
580
+ <PartSwitches module="files" />
581
+
582
+ <Tabs
583
+ value={tab}
584
+ onValueChange={setTab}
585
+ tabs={[
586
+ { value: "files", label: "Files" },
587
+ { value: "settings", label: "Settings" },
588
+ ]}
589
+ />
590
+
591
+ <div aria-live="polite" className="space-y-3">
592
+ {progress && (
593
+ <div className="flex items-end gap-3 rounded-lg border border-kumo-line p-4">
594
+ <div className="min-w-0 flex-1">
595
+ <Meter label={`Uploading ${progress.name}`} value={pct} customValue={`${formatSize(progress.loaded)} of ${formatSize(progress.total)} (${pct}%)`} />
596
+ </div>
597
+ <Button variant="secondary" icon={<X />} onClick={() => abortRef.current?.abort()}>
598
+ Cancel
599
+ </Button>
600
+ </div>
601
+ )}
602
+ {notice && <Banner variant="default" role="status" title={notice} />}
603
+ </div>
604
+ {error && <Banner variant="error" role="alert" title="Something went wrong" description={error} />}
605
+
606
+ {tab === "settings" ? (
607
+ <SettingsPanel
608
+ largeUploadsEnabled={data?.largeUploadsEnabled ?? false}
609
+ onSaved={(message) => {
610
+ setError(undefined);
611
+ setNotice(message);
612
+ void load();
613
+ }}
614
+ />
615
+ ) : null}
616
+
617
+ {tab === "files" && data?.largeUploadsEnabled && !data.r2Configured && (
618
+ <SetupCard
619
+ title="Add R2 credentials to upload large files"
620
+ description="Large uploads go straight from the browser to your R2 bucket, so they need an R2 API token: the account ID, access key ID, secret access key and bucket name."
621
+ >
622
+ <CredentialGuide id="r2" />
623
+ <Button variant="secondary" onClick={() => setTab("settings")}>
624
+ Open Settings
625
+ </Button>
626
+ </SetupCard>
627
+ )}
628
+
629
+ {tab !== "files" ? null : !data && !error ? (
630
+ <div className="py-12 text-center text-kumo-subtle">
631
+ <Loader />
632
+ </div>
633
+ ) : data && data.items.length === 0 ? (
634
+ <div className="py-10 text-center text-kumo-subtle">
635
+ <FileArrowDown size={40} className="mx-auto mb-3 opacity-30" aria-hidden="true" />
636
+ <p className="text-base font-medium">No download files yet</p>
637
+ <p className="mt-1 text-sm">Add a File download block to an entry, or upload a large file.</p>
638
+ </div>
639
+ ) : data ? (
640
+ <div className="rounded-lg border" role="table" aria-label="Download files">
641
+ <div role="row" className="flex items-center gap-4 border-b bg-kumo-tint/50 px-4 py-2 text-sm font-medium text-kumo-subtle">
642
+ <div role="columnheader" className="min-w-0 flex-1">
643
+ File
644
+ </div>
645
+ <div role="columnheader" className="hidden w-20 text-end sm:block">
646
+ Size
647
+ </div>
648
+ <div role="columnheader" className="w-32 cw-lg-block">
649
+ Uploaded
650
+ </div>
651
+ {data.countsEnabled && (
652
+ <div role="columnheader" className="hidden w-24 text-end md:block">
653
+ Downloads
654
+ </div>
655
+ )}
656
+ <div role="columnheader" className="w-20 text-end">
657
+ Used in
658
+ </div>
659
+ <div role="columnheader" className="w-10">
660
+ <span className="sr-only">Actions</span>
661
+ </div>
662
+ </div>
663
+ {visible.map((file) => (
664
+ <div role="row" key={file.id} className={`flex items-center gap-4 border-b px-4 py-2 text-sm last:border-0 ${file.status === "uploading" ? "opacity-60" : ""}`}>
665
+ <div role="cell" className="flex min-w-0 flex-1 items-center gap-3">
666
+ <TypeBadge ext={file.ext} />
667
+ <div className="min-w-0">
668
+ <div className="truncate font-medium" title={file.name}>
669
+ {file.name}
670
+ </div>
671
+ <div className="mt-0.5 flex items-center gap-1 text-xs text-kumo-subtle">
672
+ {file.source === "upload" && file.status === "ready" && <Badge variant="outline">Large upload</Badge>}
673
+ {file.source === "media" && <Badge variant="outline">Media Library</Badge>}
674
+ {file.status === "uploading" && <Badge variant="secondary">Incomplete upload</Badge>}
675
+ {file.source === "missing" && <Badge variant="destructive">Missing</Badge>}
676
+ <span className="truncate">{file.type}</span>
677
+ </div>
678
+ </div>
679
+ </div>
680
+ <div role="cell" className="hidden w-20 text-end tabular-nums sm:block">
681
+ {file.size ? formatSize(file.size) : "—"}
682
+ </div>
683
+ <div role="cell" className="w-32 text-xs text-kumo-subtle cw-lg-block">
684
+ {formatDate(file.uploadedAt) || "—"}
685
+ </div>
686
+ {data.countsEnabled && (
687
+ <div role="cell" className="hidden w-24 text-end tabular-nums md:block" title={file.lastDownload ? `Last download ${formatDate(file.lastDownload)}` : undefined}>
688
+ {file.downloads.toLocaleString()}
689
+ {file.lastDownload && (
690
+ <span className="block text-xs text-kumo-subtle">
691
+ <span className="sr-only">Last download </span>
692
+ {formatDate(file.lastDownload)}
693
+ </span>
694
+ )}
695
+ </div>
696
+ )}
697
+ <div role="cell" className="w-20 text-end">
698
+ {file.usedIn.length ? (
699
+ <DropdownMenu>
700
+ <DropdownMenu.Trigger
701
+ render={
702
+ <Button type="button" variant="ghost" size="sm" aria-label={`${file.name} is used in ${file.usedIn.length} entries`}>
703
+ {file.usedIn.length}
704
+ </Button>
705
+ }
706
+ />
707
+ <DropdownMenu.Content className="max-h-80 overflow-y-auto p-1">
708
+ {file.usedIn.map((u) => (
709
+ <DropdownMenu.Item
710
+ key={`${u.collection}:${u.id}`}
711
+ className="py-1 data-highlighted:bg-kumo-fill"
712
+ onClick={() => {
713
+ window.location.href = `/_emdash/admin/content/${encodeURIComponent(u.collection)}/${encodeURIComponent(u.id)}`;
714
+ }}
715
+ >
716
+ {u.title} <span className="ms-1 text-kumo-subtle">({u.collection})</span>
717
+ </DropdownMenu.Item>
718
+ ))}
719
+ </DropdownMenu.Content>
720
+ </DropdownMenu>
721
+ ) : (
722
+ <span className="text-kumo-subtle">Unused</span>
723
+ )}
724
+ </div>
725
+ <div role="cell" className="flex w-10 justify-end">
726
+ <DropdownMenu>
727
+ <DropdownMenu.Trigger
728
+ render={<Button type="button" variant="ghost" shape="square" icon={<DotsThree aria-hidden="true" />} aria-label={`Actions for ${file.name}`} />}
729
+ />
730
+ <DropdownMenu.Content className="p-1">
731
+ {file.url && (
732
+ <>
733
+ <DropdownMenu.Item
734
+ className="py-1 data-highlighted:bg-kumo-fill"
735
+ icon={<LinkSimple className="me-1.5 size-3.5" aria-hidden="true" />}
736
+ onClick={() => void copy(file)}
737
+ >
738
+ Copy download link
739
+ </DropdownMenu.Item>
740
+ <DropdownMenu.Item
741
+ className="py-1 data-highlighted:bg-kumo-fill"
742
+ icon={<FileArrowDown className="me-1.5 size-3.5" aria-hidden="true" />}
743
+ onClick={() => window.open(file.url!, "_blank", "noopener")}
744
+ >
745
+ Download
746
+ </DropdownMenu.Item>
747
+ </>
748
+ )}
749
+ {file.source === "upload" && (
750
+ <>
751
+ {file.url && <DropdownMenu.Separator className="my-0.5" />}
752
+ <DropdownMenu.Item
753
+ className="py-1 data-highlighted:bg-kumo-fill"
754
+ icon={<Trash className="me-1.5 size-3.5" aria-hidden="true" />}
755
+ onClick={() => setDeleting(file)}
756
+ >
757
+ {file.status === "uploading" ? "Discard" : "Delete"}
758
+ </DropdownMenu.Item>
759
+ </>
760
+ )}
761
+ {file.source === "media" && (
762
+ <DropdownMenu.Item
763
+ className="py-1 data-highlighted:bg-kumo-fill"
764
+ onClick={() => {
765
+ window.location.href = "/_emdash/admin/media";
766
+ }}
767
+ >
768
+ Manage in Media Library
769
+ </DropdownMenu.Item>
770
+ )}
771
+ </DropdownMenu.Content>
772
+ </DropdownMenu>
773
+ </div>
774
+ </div>
775
+ ))}
776
+ {visible.length === 0 && <p className="px-4 py-6 text-center text-sm text-kumo-subtle">No files match.</p>}
777
+ </div>
778
+ ) : null}
779
+
780
+ <DeleteDialog
781
+ file={deleting}
782
+ onClose={() => setDeleting(null)}
783
+ onDeleted={(file) => {
784
+ setDeleting(null);
785
+ setNotice(`Deleted ${file.name}.`);
786
+ void load();
787
+ }}
788
+ />
789
+ </div>
790
+ );
791
+ }