@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,980 @@
1
+ /**
2
+ * Link Manager admin page and dashboard widget.
3
+ */
4
+ import { Badge, Banner, Button, Checkbox, Dialog, DropdownMenu, Input, Loader, Select, Switch } from "@cloudflare/kumo";
5
+ import {
6
+ ArrowClockwise,
7
+ ArrowSquareOut,
8
+ CaretDown,
9
+ CaretRight,
10
+ DotsThree,
11
+ DownloadSimple,
12
+ EyeSlash,
13
+ GearSix,
14
+ LinkBreak,
15
+ LinkSimple,
16
+ MagnifyingGlass,
17
+ PencilSimple,
18
+ Prohibit,
19
+ } from "@phosphor-icons/react";
20
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
21
+ import * as React from "react";
22
+
23
+ import { linksToCsv } from "../links/export.js";
24
+ import { saveFile, siteSlug, today } from "./download.js";
25
+ import { PartSwitches } from "./feature-switches.js";
26
+
27
+ const API = "/_emdash/api/plugins/coywolf-pack/links";
28
+ const ADMIN = "/_emdash/admin";
29
+
30
+ type LinkStatus = "unchecked" | "ok" | "redirect" | "broken" | "blocked" | "error";
31
+ type StatusFilter = "all" | "ignored" | LinkStatus;
32
+ type BadgeVariant = "secondary" | "error" | "warning" | "success" | "info" | "outline";
33
+
34
+ interface Usage {
35
+ collection: string;
36
+ entryId: string;
37
+ title: string;
38
+ status: string;
39
+ kinds: string[];
40
+ anchors: string[];
41
+ count: number;
42
+ }
43
+
44
+ interface LinkRow {
45
+ id: string;
46
+ url: string;
47
+ resolved: string | null;
48
+ host: string;
49
+ internal: boolean;
50
+ ignored: boolean;
51
+ status: LinkStatus;
52
+ code: number | null;
53
+ finalUrl: string | null;
54
+ chain: Array<{ url: string; code: number }>;
55
+ note: string;
56
+ checkedAt: string | null;
57
+ refs: number;
58
+ usedIn: Usage[];
59
+ }
60
+
61
+ interface IgnoreRule {
62
+ id: string;
63
+ type: "domain" | "url" | "wildcard" | "regex";
64
+ value: string;
65
+ }
66
+
67
+ interface ScanState {
68
+ status: "idle" | "running";
69
+ startedAt: string | null;
70
+ finishedAt: string | null;
71
+ processed: number;
72
+ error?: string;
73
+ }
74
+
75
+ interface ListResponse {
76
+ items: LinkRow[];
77
+ total: number;
78
+ pageSize: number;
79
+ nextCursor: string | null;
80
+ counts: Record<string, number>;
81
+ scan: ScanState;
82
+ checking: boolean;
83
+ siteUrlKnown: boolean;
84
+ ignores: IgnoreRule[];
85
+ }
86
+
87
+ interface EditResult {
88
+ entries: number;
89
+ links: number;
90
+ published: number;
91
+ staged: number;
92
+ scheduled: number;
93
+ skipped: number;
94
+ conflicts: number;
95
+ failed: Array<{ entry: string; error: string }>;
96
+ remaining: number;
97
+ next: string | null;
98
+ }
99
+
100
+ const STATUS: Record<LinkStatus, { label: string; variant: BadgeVariant }> = {
101
+ broken: { label: "Broken", variant: "error" },
102
+ error: { label: "Error", variant: "error" },
103
+ blocked: { label: "Blocked", variant: "warning" },
104
+ redirect: { label: "Redirect", variant: "info" },
105
+ unchecked: { label: "Not checked", variant: "outline" },
106
+ ok: { label: "OK", variant: "success" },
107
+ };
108
+ const FILTERS: Array<{ id: StatusFilter; label: string }> = [
109
+ { id: "all", label: "All" },
110
+ { id: "broken", label: "Broken" },
111
+ { id: "error", label: "Error" },
112
+ { id: "blocked", label: "Blocked" },
113
+ { id: "redirect", label: "Redirect" },
114
+ { id: "unchecked", label: "Not checked" },
115
+ { id: "ok", label: "OK" },
116
+ { id: "ignored", label: "Ignored" },
117
+ ];
118
+ type Frequency = "daily" | "weekly" | "monthly";
119
+ const FREQUENCY_OPTIONS = [
120
+ { value: "daily", label: "Daily" },
121
+ { value: "weekly", label: "Weekly" },
122
+ { value: "monthly", label: "Monthly" },
123
+ ];
124
+
125
+ const RULE_TYPES = [
126
+ { value: "domain", label: "Domain (and its subdomains)" },
127
+ { value: "url", label: "Exact URL" },
128
+ { value: "wildcard", label: "Wildcard (* matches anything)" },
129
+ { value: "regex", label: "Regular expression" },
130
+ ];
131
+
132
+ const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
133
+ const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short" });
134
+ const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString()} ${n === 1 ? one : many}`;
135
+
136
+ async function post<T>(path: string, body: unknown): Promise<T> {
137
+ const response = await apiFetch(`${API}/${path}`, {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json" },
140
+ body: JSON.stringify(body),
141
+ });
142
+ return parseApiResponse<T>(response, "The request failed");
143
+ }
144
+
145
+ /** Run a batched edit route until every affected entry is done. */
146
+ async function runEdit(path: "replace" | "unlink", body: Record<string, unknown>, onProgress: (done: number) => void): Promise<EditResult> {
147
+ const total: EditResult = { entries: 0, links: 0, published: 0, staged: 0, scheduled: 0, skipped: 0, conflicts: 0, failed: [], remaining: 0, next: null };
148
+ let after: string | null = null;
149
+ for (let i = 0; i < 500; i++) {
150
+ const step: EditResult = await post<EditResult>(path, { ...body, after });
151
+ total.entries += step.entries;
152
+ total.links += step.links;
153
+ total.published += step.published;
154
+ total.staged += step.staged;
155
+ total.scheduled += step.scheduled;
156
+ total.conflicts += step.conflicts;
157
+ total.skipped += step.skipped;
158
+ total.failed.push(...step.failed);
159
+ onProgress(total.entries);
160
+ if (!step.remaining || !step.next) break;
161
+ after = step.next;
162
+ }
163
+ return total;
164
+ }
165
+
166
+ function editSummary(verb: string, r: EditResult): string {
167
+ const parts = [`${verb} ${plural(r.links, "link")} in ${plural(r.entries, "entry", "entries")}.`];
168
+ if (r.staged) parts.push(`${plural(r.staged, "entry", "entries")} had unpublished changes or a schedule, so the fix is saved in the draft; publish to make it live.`);
169
+ if (r.scheduled) parts.push(`${plural(r.scheduled, "entry", "entries")} ${r.scheduled === 1 ? "is" : "are"} scheduled.`);
170
+ if (r.conflicts) parts.push(`${plural(r.conflicts, "entry", "entries")} changed while this ran and ${r.conflicts === 1 ? "was" : "were"} left alone; run it again to retry.`);
171
+ if (r.skipped) parts.push(`${plural(r.skipped, "embed")} can't be unlinked and ${r.skipped === 1 ? "was" : "were"} left as is.`);
172
+ if (r.failed.length > r.conflicts) parts.push(`${plural(r.failed.length, "entry", "entries")} failed: ${r.failed[0].error}`);
173
+ return parts.join(" ");
174
+ }
175
+
176
+ function useDebounced<T>(value: T, ms = 300): T {
177
+ const [debounced, setDebounced] = React.useState(value);
178
+ React.useEffect(() => {
179
+ const timer = setTimeout(() => setDebounced(value), ms);
180
+ return () => clearTimeout(timer);
181
+ }, [value, ms]);
182
+ return debounced;
183
+ }
184
+
185
+ function StatusBadge({ row }: { row: LinkRow }) {
186
+ const s = STATUS[row.status];
187
+ return (
188
+ <span title={row.note || undefined}>
189
+ <Badge variant={s.variant}>
190
+ {s.label}
191
+ {row.code && row.status !== "ok" ? ` ${row.code}` : ""}
192
+ </Badge>
193
+ </span>
194
+ );
195
+ }
196
+
197
+ function UsageList({ row }: { row: LinkRow }) {
198
+ return (
199
+ <ul className="mt-2 space-y-1 border-s-2 border-kumo-line ps-3 text-xs">
200
+ {row.usedIn.map((u) => (
201
+ <li key={`${u.collection}/${u.entryId}`} className="flex flex-wrap items-center gap-x-2">
202
+ <a className="font-medium text-kumo-link hover:underline" href={`${ADMIN}/content/${encodeURIComponent(u.collection)}/${encodeURIComponent(u.entryId)}`}>
203
+ {u.title}
204
+ </a>
205
+ <span className="text-kumo-subtle">
206
+ {u.collection}
207
+ {u.status && u.status !== "published" ? ` · ${u.status}` : ""}
208
+ {u.count > 1 ? ` · ${u.count}×` : ""}
209
+ {u.kinds.some((k) => k !== "text") ? ` · ${u.kinds.join(", ")}` : ""}
210
+ </span>
211
+ {u.anchors.length > 0 && <span className="truncate text-kumo-subtle">“{u.anchors.join("”, “")}”</span>}
212
+ </li>
213
+ ))}
214
+ {row.usedIn.length === 0 && <li className="text-kumo-subtle">Not used in any entry.</li>}
215
+ </ul>
216
+ );
217
+ }
218
+
219
+ function ReplaceDialog(props: { rows: LinkRow[] | null; onClose: () => void; onDone: (message: string) => void }) {
220
+ const [to, setTo] = React.useState("");
221
+ const [pending, setPending] = React.useState(false);
222
+ const [progress, setProgress] = React.useState<string>();
223
+ const [error, setError] = React.useState<string>();
224
+ React.useEffect(() => {
225
+ const only = props.rows?.length === 1 ? props.rows[0] : undefined;
226
+ setTo(only ? (only.finalUrl ?? only.url) : "");
227
+ setError(undefined);
228
+ setProgress(undefined);
229
+ }, [props.rows]);
230
+ const rows = props.rows ?? [];
231
+ const entries = new Set(rows.flatMap((r) => r.usedIn.map((u) => `${u.collection}/${u.entryId}`))).size;
232
+
233
+ const run = async () => {
234
+ setPending(true);
235
+ setError(undefined);
236
+ try {
237
+ const result = await runEdit("replace", { ids: rows.map((r) => r.id), to: to.trim() }, (n) => setProgress(`Updated ${plural(n, "entry", "entries")}…`));
238
+ props.onDone(editSummary("Replaced", result));
239
+ } catch (cause) {
240
+ setError(errorText(cause, "Replace failed"));
241
+ } finally {
242
+ setPending(false);
243
+ }
244
+ };
245
+
246
+ return (
247
+ <Dialog.Root open={props.rows !== null} onOpenChange={(open) => !open && !pending && props.onClose()}>
248
+ <Dialog className="p-6" size="lg">
249
+ <Dialog.Title className="text-lg font-semibold">{rows.length === 1 ? "Change link" : `Replace ${rows.length} links`}</Dialog.Title>
250
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">
251
+ Every entry that links to {rows.length === 1 ? <code className="break-all">{rows[0]?.url}</code> : "the selected URLs"} will link to the new URL
252
+ instead ({plural(entries, "entry", "entries")} on this page's count). Published entries are republished. Link text and other formatting stay the
253
+ same.
254
+ </Dialog.Description>
255
+ <form
256
+ className="mt-4 space-y-4"
257
+ onSubmit={(e) => {
258
+ e.preventDefault();
259
+ void run();
260
+ }}
261
+ >
262
+ <Input
263
+ label="New URL"
264
+ placeholder="https://example.com/new-page or /new-path/"
265
+ value={to}
266
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTo(e.target.value)}
267
+ required
268
+ />
269
+ {rows.length === 1 && rows[0]?.finalUrl && (
270
+ <p className="text-xs text-kumo-subtle">Prefilled with where the link redirects to now.</p>
271
+ )}
272
+ <p aria-live="polite" className="text-sm text-kumo-subtle">
273
+ {progress}
274
+ </p>
275
+ {error && <Banner variant="error" role="alert" description={error} />}
276
+ <div className="flex justify-end gap-2">
277
+ <Button type="button" variant="secondary" disabled={pending} onClick={props.onClose}>
278
+ Cancel
279
+ </Button>
280
+ <Button type="submit" variant="primary" disabled={pending || !to.trim()}>
281
+ {pending ? "Replacing…" : "Replace"}
282
+ </Button>
283
+ </div>
284
+ </form>
285
+ </Dialog>
286
+ </Dialog.Root>
287
+ );
288
+ }
289
+
290
+ function UnlinkDialog(props: { rows: LinkRow[] | null; onClose: () => void; onDone: (message: string) => void }) {
291
+ const [pending, setPending] = React.useState(false);
292
+ const [progress, setProgress] = React.useState<string>();
293
+ const [error, setError] = React.useState<string>();
294
+ React.useEffect(() => {
295
+ setError(undefined);
296
+ setProgress(undefined);
297
+ }, [props.rows]);
298
+ const rows = props.rows ?? [];
299
+ const run = async () => {
300
+ setPending(true);
301
+ setError(undefined);
302
+ try {
303
+ const result = await runEdit("unlink", { ids: rows.map((r) => r.id) }, (n) => setProgress(`Updated ${plural(n, "entry", "entries")}…`));
304
+ props.onDone(editSummary("Removed", result));
305
+ } catch (cause) {
306
+ setError(errorText(cause, "Unlink failed"));
307
+ } finally {
308
+ setPending(false);
309
+ }
310
+ };
311
+ return (
312
+ <Dialog.Root open={props.rows !== null} onOpenChange={(open) => !open && !pending && props.onClose()}>
313
+ <Dialog className="p-6" size="base">
314
+ <Dialog.Title className="text-lg font-semibold">{rows.length === 1 ? "Remove this link?" : `Remove ${rows.length} links?`}</Dialog.Title>
315
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">
316
+ The link is removed and its text kept. Linked images and buttons lose their link. Embeds and iframes are left alone. Published entries are
317
+ republished.
318
+ </Dialog.Description>
319
+ <p aria-live="polite" className="mt-3 text-sm text-kumo-subtle">
320
+ {progress}
321
+ </p>
322
+ {error && <Banner variant="error" role="alert" className="mt-3" description={error} />}
323
+ <div className="mt-6 flex justify-end gap-2">
324
+ <Button variant="secondary" disabled={pending} onClick={props.onClose}>
325
+ Cancel
326
+ </Button>
327
+ <Button variant="destructive" disabled={pending} onClick={() => void run()}>
328
+ {pending ? "Removing…" : "Remove links"}
329
+ </Button>
330
+ </div>
331
+ </Dialog>
332
+ </Dialog.Root>
333
+ );
334
+ }
335
+
336
+ interface LinksSettings {
337
+ checkBudget: number;
338
+ checkInternal: boolean;
339
+ userAgent: string;
340
+ frequency: Frequency;
341
+ }
342
+
343
+ /** Link checking settings (plugin settings edited here, not on the generic Settings page). */
344
+ function SettingsDialog(props: { open: boolean; onClose: () => void; onSaved: (message: string) => void }) {
345
+ const [draft, setDraft] = React.useState<{ checkBudget: string; checkInternal: boolean; userAgent: string; frequency: Frequency }>();
346
+ const [pending, setPending] = React.useState(false);
347
+ const [error, setError] = React.useState<string>();
348
+ React.useEffect(() => {
349
+ if (!props.open) return;
350
+ setDraft(undefined);
351
+ setError(undefined);
352
+ apiFetch(`${API}/settings`)
353
+ .then((response) => parseApiResponse<LinksSettings>(response, "Could not load the settings"))
354
+ .then((s) => setDraft({ checkBudget: String(s.checkBudget), checkInternal: s.checkInternal, userAgent: s.userAgent, frequency: s.frequency }))
355
+ .catch((cause) => setError(errorText(cause, "Could not load the settings")));
356
+ }, [props.open]);
357
+ const save = async () => {
358
+ if (!draft) return;
359
+ setPending(true);
360
+ setError(undefined);
361
+ try {
362
+ await post("settings/save", {
363
+ checkBudget: Number(draft.checkBudget),
364
+ checkInternal: draft.checkInternal,
365
+ userAgent: draft.userAgent,
366
+ frequency: draft.frequency,
367
+ });
368
+ props.onSaved("Link checking settings saved.");
369
+ } catch (cause) {
370
+ setError(errorText(cause, "Could not save the settings"));
371
+ } finally {
372
+ setPending(false);
373
+ }
374
+ };
375
+ return (
376
+ <Dialog.Root open={props.open} onOpenChange={(open) => !open && !pending && props.onClose()}>
377
+ <Dialog className="p-6" size="lg">
378
+ <Dialog.Title className="text-lg font-semibold">Link checking settings</Dialog.Title>
379
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">
380
+ Nothing runs in the background until Scheduled link checking is on under Plugins → Coywolf Pack. Then a scheduled run scans your content
381
+ and checks the links that are due (broken links daily, the rest weekly), continuing hourly until it's done.
382
+ </Dialog.Description>
383
+ {!draft && !error ? (
384
+ <div className="py-8 text-center">
385
+ <Loader />
386
+ </div>
387
+ ) : draft ? (
388
+ <form
389
+ className="mt-4 space-y-4"
390
+ onSubmit={(e) => {
391
+ e.preventDefault();
392
+ void save();
393
+ }}
394
+ >
395
+ <Select
396
+ label="How often"
397
+ description="How often a scheduled run starts. Weekly by default."
398
+ value={draft.frequency}
399
+ onValueChange={(v: string | null) => setDraft({ ...draft, frequency: (v ?? "weekly") as Frequency })}
400
+ items={FREQUENCY_OPTIONS}
401
+ />
402
+ <Input
403
+ type="number"
404
+ min={10}
405
+ max={900}
406
+ label="Subrequests per check run"
407
+ description="A link takes 1–2 HTTP requests (plus 1 per redirect) and 1 database write. Workers allow 50 subrequests per invocation on the Free plan and 1,000 on Paid, shared with other scheduled jobs. Default 40."
408
+ value={draft.checkBudget}
409
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDraft({ ...draft, checkBudget: e.target.value })}
410
+ />
411
+ <Switch className="shrink-0"
412
+ label="Check links to this site"
413
+ checked={draft.checkInternal}
414
+ onCheckedChange={(checked: boolean) => setDraft({ ...draft, checkInternal: checked })}
415
+ />
416
+ <p className="-mt-2 text-sm text-kumo-subtle">Internal links are always listed; this also requests them to see if they work.</p>
417
+ <Input
418
+ label="User-Agent override (optional)"
419
+ description="Leave empty to present a current desktop Chrome, which avoids most false “Blocked” results."
420
+ value={draft.userAgent}
421
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDraft({ ...draft, userAgent: e.target.value })}
422
+ />
423
+ {error && <Banner variant="error" role="alert" description={error} />}
424
+ <div className="flex justify-end gap-2">
425
+ <Button type="button" variant="secondary" disabled={pending} onClick={props.onClose}>
426
+ Cancel
427
+ </Button>
428
+ <Button type="submit" variant="primary" disabled={pending}>
429
+ {pending ? "Saving…" : "Save"}
430
+ </Button>
431
+ </div>
432
+ </form>
433
+ ) : null}
434
+ {error && !draft && <Banner variant="error" role="alert" className="mt-4" description={error} />}
435
+ </Dialog>
436
+ </Dialog.Root>
437
+ );
438
+ }
439
+
440
+ function IgnoreRulesDialog(props: { open: boolean; rules: IgnoreRule[]; onClose: () => void; onChanged: (message: string) => void }) {
441
+ const [type, setType] = React.useState<IgnoreRule["type"]>("domain");
442
+ const [value, setValue] = React.useState("");
443
+ const [pending, setPending] = React.useState(false);
444
+ const [error, setError] = React.useState<string>();
445
+ const save = async (body: { add?: Array<Omit<IgnoreRule, "id">>; remove?: string[] }, message: string) => {
446
+ setPending(true);
447
+ setError(undefined);
448
+ try {
449
+ await post("ignore", body);
450
+ setValue("");
451
+ props.onChanged(message);
452
+ } catch (cause) {
453
+ setError(errorText(cause, "Could not save the rule"));
454
+ } finally {
455
+ setPending(false);
456
+ }
457
+ };
458
+ return (
459
+ <Dialog.Root open={props.open} onOpenChange={(open) => !open && !pending && props.onClose()}>
460
+ <Dialog className="p-6" size="lg">
461
+ <Dialog.Title className="text-lg font-semibold">Ignore rules</Dialog.Title>
462
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">
463
+ Ignored links aren't checked or counted. They stay listed under Ignored.
464
+ </Dialog.Description>
465
+ <form
466
+ className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end"
467
+ onSubmit={(e) => {
468
+ e.preventDefault();
469
+ void save({ add: [{ type, value }] }, "Ignore rule added.");
470
+ }}
471
+ >
472
+ <div className="sm:w-64">
473
+ <Select
474
+ label="Type"
475
+ value={type}
476
+ onValueChange={(v: string | null) => setType((v ?? "domain") as IgnoreRule["type"])}
477
+ items={RULE_TYPES}
478
+ />
479
+ </div>
480
+ <div className="flex-1">
481
+ <Input
482
+ label="Value"
483
+ placeholder={type === "domain" ? "linkedin.com" : type === "regex" ? "^https://example\\.com/(tag|category)/" : "https://example.com/*"}
484
+ value={value}
485
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
486
+ />
487
+ </div>
488
+ <Button type="submit" variant="primary" disabled={pending || !value.trim()}>
489
+ Add rule
490
+ </Button>
491
+ </form>
492
+ {error && <Banner variant="error" role="alert" className="mt-3" description={error} />}
493
+ <ul className="mt-4 max-h-80 divide-y divide-kumo-line cw-overflow-auto rounded-lg border">
494
+ {props.rules.map((rule) => (
495
+ <li key={rule.id} className="flex items-center gap-3 px-3 py-2 text-sm">
496
+ <Badge variant="secondary">{rule.type}</Badge>
497
+ <code className="min-w-0 flex-1 truncate text-xs" title={rule.value}>
498
+ {rule.value}
499
+ </code>
500
+ <Button
501
+ variant="ghost"
502
+ size="sm"
503
+ disabled={pending}
504
+ aria-label={`Remove ignore rule ${rule.value}`}
505
+ onClick={() => void save({ remove: [rule.id] }, "Ignore rule removed.")}
506
+ >
507
+ Remove
508
+ </Button>
509
+ </li>
510
+ ))}
511
+ {props.rules.length === 0 && <li className="px-3 py-6 text-center text-sm text-kumo-subtle">No ignore rules yet.</li>}
512
+ </ul>
513
+ <div className="mt-6 flex justify-end">
514
+ <Button variant="secondary" disabled={pending} onClick={props.onClose}>
515
+ Done
516
+ </Button>
517
+ </div>
518
+ </Dialog>
519
+ </Dialog.Root>
520
+ );
521
+ }
522
+
523
+ export function LinksPage() {
524
+ const [data, setData] = React.useState<ListResponse>();
525
+ const [error, setError] = React.useState<string>();
526
+ const [notice, setNotice] = React.useState<string>();
527
+ const [status, setStatus] = React.useState<StatusFilter>("all");
528
+ const [scope, setScope] = React.useState<"all" | "internal" | "external">("all");
529
+ const [host, setHost] = React.useState("");
530
+ const [query, setQuery] = React.useState("");
531
+ /** Cursors of the pages before the current one, and the current one. */
532
+ const [cursors, setCursors] = React.useState<Array<string | null>>([null]);
533
+ const cursor = cursors[cursors.length - 1];
534
+ const [selected, setSelected] = React.useState<Set<string>>(new Set());
535
+ const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
536
+ const [replacing, setReplacing] = React.useState<LinkRow[] | null>(null);
537
+ const [unlinking, setUnlinking] = React.useState<LinkRow[] | null>(null);
538
+ const [rulesOpen, setRulesOpen] = React.useState(false);
539
+ const [settingsOpen, setSettingsOpen] = React.useState(false);
540
+ const [busy, setBusy] = React.useState<string>();
541
+ const scanning = React.useRef(false);
542
+
543
+ const deferredQuery = useDebounced(query);
544
+ const deferredHost = useDebounced(host);
545
+
546
+ const load = React.useCallback(async () => {
547
+ setError(undefined);
548
+ try {
549
+ const result = await post<ListResponse>("list", { status, scope, host: deferredHost || undefined, q: deferredQuery || undefined, cursor });
550
+ setData(result);
551
+ setSelected((prev) => new Set([...prev].filter((id) => result.items.some((r) => r.id === id))));
552
+ } catch (cause) {
553
+ setError(errorText(cause, "Could not load links"));
554
+ }
555
+ }, [status, scope, deferredHost, deferredQuery, cursor]);
556
+ React.useEffect(() => {
557
+ void load();
558
+ }, [load]);
559
+ React.useEffect(() => setCursors([null]), [status, scope, deferredHost, deferredQuery]);
560
+
561
+ /** Keep a running scan moving while the page is open (otherwise it continues the next time the page is open, or in a scheduled run). */
562
+ const scan = React.useCallback(
563
+ async (restart: boolean) => {
564
+ if (scanning.current) return;
565
+ scanning.current = true;
566
+ setError(undefined);
567
+ try {
568
+ let state = await post<ScanState & { busy?: boolean }>("scan", { restart });
569
+ while (state.status === "running" && !state.error) {
570
+ setData((d) => (d ? { ...d, scan: state } : d));
571
+ // Another step (the scheduled job) holds the scan: wait instead of spinning.
572
+ if (state.busy) await new Promise((resolve) => setTimeout(resolve, 5000));
573
+ state = await post<ScanState & { busy?: boolean }>("scan", {});
574
+ }
575
+ if (state.error) {
576
+ setData((d) => (d ? { ...d, scan: state } : d));
577
+ return;
578
+ }
579
+ setNotice(`Scan finished: ${plural(state.processed, "entry", "entries")} indexed.`);
580
+ await load();
581
+ } catch (cause) {
582
+ setError(errorText(cause, "Scan failed"));
583
+ } finally {
584
+ scanning.current = false;
585
+ setData((d) => (d ? { ...d } : d));
586
+ }
587
+ },
588
+ [load],
589
+ );
590
+ React.useEffect(() => {
591
+ if (data?.scan.status === "running" && !scanning.current) void scan(false);
592
+ }, [data?.scan.status, scan]);
593
+
594
+ const recheck = async (ids?: string[]) => {
595
+ setBusy(ids ? "Checking selected links…" : "Checking links that are due…");
596
+ setError(undefined);
597
+ try {
598
+ const run = await post<{ checked: number; exhausted: boolean }>("recheck", ids ? { ids } : {});
599
+ setNotice(
600
+ `Checked ${plural(run.checked, "link")}.${run.exhausted ? " The rest will be checked by the next scheduled run, or press Check now again." : ""}`,
601
+ );
602
+ await load();
603
+ } catch (cause) {
604
+ setError(errorText(cause, "Check failed"));
605
+ } finally {
606
+ setBusy(undefined);
607
+ }
608
+ };
609
+
610
+ const ignore = async (rows: LinkRow[], by: "url" | "domain") => {
611
+ const add = [...new Set(rows.map((r) => (by === "domain" ? r.host : (r.resolved ?? r.url))))].filter(Boolean).map((value) => ({ type: by, value }));
612
+ try {
613
+ const result = await post<{ changed: number }>("ignore", { add });
614
+ setNotice(`Ignoring ${plural(result.changed, "link")}.`);
615
+ await load();
616
+ } catch (cause) {
617
+ setError(errorText(cause, "Could not ignore"));
618
+ }
619
+ };
620
+
621
+ /** Every link that matches the current filters (all pages), as CSV. */
622
+ const exportCsv = async () => {
623
+ setBusy("Exporting links…");
624
+ setError(undefined);
625
+ try {
626
+ const rows: LinkRow[] = [];
627
+ let next: string | null = null;
628
+ for (let page = 0; page < 1000; page++) {
629
+ const result: ListResponse = await post<ListResponse>("list", { status, scope, host: deferredHost || undefined, q: deferredQuery || undefined, cursor: next });
630
+ rows.push(...result.items);
631
+ next = result.nextCursor;
632
+ if (!next) break;
633
+ }
634
+ const filtered = status !== "all" || scope !== "all" || deferredHost || deferredQuery;
635
+ saveFile(linksToCsv(rows), `links-${status === "all" ? "" : `${status}-`}${siteSlug() || "site"}-${today()}.csv`, "text/csv");
636
+ setNotice(`Exported ${plural(rows.length, "link")}${filtered ? " matching the current filters" : ""}.`);
637
+ } catch (cause) {
638
+ setError(errorText(cause, "Export failed"));
639
+ } finally {
640
+ setBusy(undefined);
641
+ }
642
+ };
643
+
644
+ const items = data?.items ?? [];
645
+ const selectedRows = items.filter((r) => selected.has(r.id));
646
+ const allSelected = items.length > 0 && selectedRows.length === items.length;
647
+ const pageNumber = cursors.length;
648
+ const scanState = data?.scan;
649
+ const neverScanned = scanState && !scanState.finishedAt && scanState.status !== "running";
650
+
651
+ const toggle = (set: Set<string>, id: string) => {
652
+ const next = new Set(set);
653
+ if (next.has(id)) next.delete(id);
654
+ else next.add(id);
655
+ return next;
656
+ };
657
+
658
+ return (
659
+ <div className="space-y-6">
660
+ <header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
661
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
662
+ <h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Link Manager</h1>
663
+ <div className="flex shrink-0 flex-wrap justify-end gap-2">
664
+ <Button variant="secondary" icon={<GearSix />} onClick={() => setSettingsOpen(true)}>
665
+ Settings
666
+ </Button>
667
+ <Button variant="secondary" icon={<Prohibit />} onClick={() => setRulesOpen(true)}>
668
+ Ignore rules
669
+ </Button>
670
+ <Button
671
+ variant="secondary"
672
+ icon={<DownloadSimple />}
673
+ disabled={!data?.total || Boolean(busy)}
674
+ title="Download the links matching the current filters as a CSV file"
675
+ onClick={() => void exportCsv()}
676
+ >
677
+ Export CSV
678
+ </Button>
679
+ <Button variant="secondary" icon={<MagnifyingGlass />} disabled={scanState?.status === "running"} onClick={() => void scan(true)}>
680
+ Scan content
681
+ </Button>
682
+ <Button
683
+ variant="primary"
684
+ icon={<ArrowClockwise />}
685
+ disabled={!data?.checking || Boolean(busy)}
686
+ title={data?.checking ? undefined : "Turn on Scheduled link checking under Plugins → Coywolf Pack"}
687
+ onClick={() => void recheck()}
688
+ >
689
+ Check now
690
+ </Button>
691
+ </div>
692
+ <p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
693
+ Every link in your content, where it's used, and whether it still works. Blocked means the destination refused a server (a bot wall such
694
+ as LinkedIn's 999 or Cloudflare's challenge): the link is probably fine.
695
+ {!data?.checking && " Turn on Scheduled link checking under Plugins → Coywolf Pack to check links (weekly by default; change it in Settings)."}
696
+ </p>
697
+ </div>
698
+ <div className="flex flex-wrap gap-1" role="group" aria-label="Filter by status">
699
+ {FILTERS.map((f) => (
700
+ <Button key={f.id} size="sm" variant={status === f.id ? "primary" : "ghost"} aria-pressed={status === f.id} onClick={() => setStatus(f.id)}>
701
+ {f.label}
702
+ {data ? ` (${(data.counts[f.id] ?? 0).toLocaleString()})` : ""}
703
+ </Button>
704
+ ))}
705
+ </div>
706
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
707
+ <div className="sm:w-72">
708
+ <Input
709
+ label="Search"
710
+ placeholder="Start of a URL or domain"
711
+ value={query}
712
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
713
+ />
714
+ </div>
715
+ <div className="sm:w-64">
716
+ <Input
717
+ label="Domain"
718
+ placeholder="example.com"
719
+ value={host}
720
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setHost(e.target.value)}
721
+ />
722
+ </div>
723
+ <div className="sm:w-44">
724
+ <Select
725
+ label="Links"
726
+ value={scope}
727
+ onValueChange={(v: string | null) => setScope((v ?? "all") as typeof scope)}
728
+ items={[
729
+ { value: "all", label: "Internal and external" },
730
+ { value: "internal", label: "Internal" },
731
+ { value: "external", label: "External" },
732
+ ]}
733
+ />
734
+ </div>
735
+ </div>
736
+ </header>
737
+
738
+ <PartSwitches module="links" />
739
+
740
+ <div aria-live="polite" className="space-y-3">
741
+ {scanState?.status === "running" && (
742
+ <Banner variant="default" title="Scanning content…" description={`${plural(scanState.processed, "entry", "entries")} indexed so far.`} />
743
+ )}
744
+ {neverScanned && (
745
+ <Banner variant="default" title="Your content hasn't been scanned yet" description="Scan content to build the link list. New and edited entries are added as they're saved." />
746
+ )}
747
+ {data && !data.siteUrlKnown && (
748
+ <Banner
749
+ variant="error"
750
+ title="Set your site URL"
751
+ description="Link Manager needs the site URL (Settings → General, or site in astro.config) to tell internal links from external ones. Nothing is indexed until it's set."
752
+ />
753
+ )}
754
+ {scanState?.error && <Banner variant="error" title="The last scan stopped" description={scanState.error} />}
755
+ {busy && <Banner variant="default" title={busy} />}
756
+ {notice && <Banner variant="default" role="status" title={notice} />}
757
+ {error && <Banner variant="error" role="alert" title="Something went wrong" description={error} />}
758
+ </div>
759
+
760
+ {selectedRows.length > 0 && (
761
+ <div className="flex flex-wrap items-center gap-2 rounded-lg border bg-kumo-tint/50 px-4 py-2 text-sm" role="toolbar" aria-label="Bulk actions">
762
+ <span className="me-2 font-medium">{selectedRows.length} selected</span>
763
+ <Button size="sm" variant="secondary" icon={<PencilSimple />} onClick={() => setReplacing(selectedRows)}>
764
+ Replace…
765
+ </Button>
766
+ <Button size="sm" variant="secondary" icon={<LinkBreak />} onClick={() => setUnlinking(selectedRows)}>
767
+ Unlink…
768
+ </Button>
769
+ <Button size="sm" variant="secondary" icon={<EyeSlash />} onClick={() => void ignore(selectedRows, "url")}>
770
+ Ignore
771
+ </Button>
772
+ <Button size="sm" variant="secondary" icon={<ArrowClockwise />} disabled={!data?.checking || Boolean(busy)} onClick={() => void recheck(selectedRows.map((r) => r.id))}>
773
+ Recheck
774
+ </Button>
775
+ <Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
776
+ Clear
777
+ </Button>
778
+ </div>
779
+ )}
780
+
781
+ {!data && !error ? (
782
+ <div className="py-12 text-center text-kumo-subtle">
783
+ <Loader />
784
+ </div>
785
+ ) : data && data.total === 0 ? (
786
+ <div className="py-10 text-center text-kumo-subtle">
787
+ <LinkSimple size={40} className="mx-auto mb-3 opacity-30" aria-hidden="true" />
788
+ <p className="text-base font-medium">No links match</p>
789
+ <p className="mt-1 text-sm">{data.counts.all + data.counts.ignored === 0 ? "Scan your content to find links." : "Try another filter."}</p>
790
+ </div>
791
+ ) : data ? (
792
+ <div className="rounded-lg border">
793
+ <div className="flex items-center gap-3 border-b bg-kumo-tint/50 px-4 py-2 text-sm font-medium text-kumo-subtle">
794
+ <div className="w-5">
795
+ <Checkbox
796
+ aria-label="Select all links on this page"
797
+ checked={allSelected}
798
+ indeterminate={selectedRows.length > 0 && !allSelected}
799
+ onCheckedChange={(checked: boolean) => setSelected(checked ? new Set(items.map((r) => r.id)) : new Set())}
800
+ />
801
+ </div>
802
+ <div className="min-w-0 flex-1">URL</div>
803
+ <div className="w-32">Status</div>
804
+ <div className="w-24">Used in</div>
805
+ <div className="w-36 cw-lg-block">Checked</div>
806
+ <div className="w-10" />
807
+ </div>
808
+ {items.map((row) => {
809
+ const open = expanded.has(row.id);
810
+ return (
811
+ <div key={row.id} className={`border-b px-4 py-2 text-sm last:border-0 ${row.ignored ? "opacity-60" : ""}`}>
812
+ <div className="flex items-center gap-3">
813
+ <div className="w-5">
814
+ <Checkbox
815
+ aria-label={`Select ${row.url}`}
816
+ checked={selected.has(row.id)}
817
+ onCheckedChange={() => setSelected((s) => toggle(s, row.id))}
818
+ />
819
+ </div>
820
+ <div className="min-w-0 flex-1">
821
+ <div className="flex items-center gap-1.5">
822
+ <a
823
+ className="truncate font-mono text-xs hover:underline"
824
+ href={row.resolved ?? row.url}
825
+ target="_blank"
826
+ rel="noopener noreferrer"
827
+ title={row.url}
828
+ >
829
+ {row.url}
830
+ </a>
831
+ <ArrowSquareOut className="shrink-0 text-kumo-subtle" size={12} aria-hidden="true" />
832
+ {row.internal && <Badge variant="outline">internal</Badge>}
833
+ </div>
834
+ {row.finalUrl && (
835
+ <div className="mt-0.5 truncate text-xs text-kumo-subtle" title={row.chain.map((h) => `${h.code} ${h.url}`).join("\n")}>
836
+ → {row.finalUrl}
837
+ </div>
838
+ )}
839
+ {row.note && row.status !== "ok" && row.status !== "redirect" && <div className="mt-0.5 truncate text-xs text-kumo-subtle">{row.note}</div>}
840
+ </div>
841
+ <div className="w-32">
842
+ <StatusBadge row={row} />
843
+ </div>
844
+ <div className="w-24">
845
+ <button
846
+ type="button"
847
+ className="inline-flex items-center gap-1 rounded text-kumo-link hover:underline focus-visible:outline focus-visible:outline-2"
848
+ aria-expanded={open}
849
+ onClick={() => setExpanded((s) => toggle(s, row.id))}
850
+ >
851
+ {open ? <CaretDown size={12} aria-hidden="true" /> : <CaretRight size={12} aria-hidden="true" />}
852
+ {plural(row.refs, "entry", "entries")}
853
+ </button>
854
+ </div>
855
+ <div className="w-36 text-xs text-kumo-subtle cw-lg-block">{row.checkedAt ? dateFormat.format(new Date(row.checkedAt)) : "Never"}</div>
856
+ <div className="flex w-10 justify-end">
857
+ <DropdownMenu>
858
+ <DropdownMenu.Trigger
859
+ render={<Button type="button" variant="ghost" shape="square" icon={<DotsThree aria-hidden="true" />} aria-label={`Actions for ${row.url}`} />}
860
+ />
861
+ <DropdownMenu.Content className="p-1">
862
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" icon={<PencilSimple className="me-1.5 size-3.5" aria-hidden="true" />} onClick={() => setReplacing([row])}>
863
+ Change URL…
864
+ </DropdownMenu.Item>
865
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" icon={<LinkBreak className="me-1.5 size-3.5" aria-hidden="true" />} onClick={() => setUnlinking([row])}>
866
+ Unlink…
867
+ </DropdownMenu.Item>
868
+ {data.checking && (
869
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" icon={<ArrowClockwise className="me-1.5 size-3.5" aria-hidden="true" />} onClick={() => void recheck([row.id])}>
870
+ Recheck
871
+ </DropdownMenu.Item>
872
+ )}
873
+ <DropdownMenu.Separator className="my-0.5" />
874
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" icon={<EyeSlash className="me-1.5 size-3.5" aria-hidden="true" />} onClick={() => void ignore([row], "url")}>
875
+ Ignore this URL
876
+ </DropdownMenu.Item>
877
+ {row.host && (
878
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" icon={<Prohibit className="me-1.5 size-3.5" aria-hidden="true" />} onClick={() => void ignore([row], "domain")}>
879
+ Ignore {row.host}
880
+ </DropdownMenu.Item>
881
+ )}
882
+ </DropdownMenu.Content>
883
+ </DropdownMenu>
884
+ </div>
885
+ </div>
886
+ {open && <UsageList row={row} />}
887
+ </div>
888
+ );
889
+ })}
890
+ </div>
891
+ ) : null}
892
+
893
+ {data && (cursors.length > 1 || data.nextCursor) && (
894
+ <nav className="flex items-center justify-between text-sm" aria-label="Pagination">
895
+ <span className="text-kumo-subtle">
896
+ Page {pageNumber} of {Math.max(1, Math.ceil(data.total / data.pageSize))} · {plural(data.total, "link")}
897
+ </span>
898
+ <div className="flex gap-2">
899
+ <Button size="sm" variant="secondary" disabled={cursors.length <= 1} onClick={() => setCursors((c) => c.slice(0, -1))}>
900
+ Previous
901
+ </Button>
902
+ <Button
903
+ size="sm"
904
+ variant="secondary"
905
+ disabled={!data.nextCursor}
906
+ onClick={() => setCursors((c) => (data.nextCursor ? [...c, data.nextCursor] : c))}
907
+ >
908
+ Next
909
+ </Button>
910
+ </div>
911
+ </nav>
912
+ )}
913
+
914
+ <ReplaceDialog
915
+ rows={replacing}
916
+ onClose={() => setReplacing(null)}
917
+ onDone={(message) => {
918
+ setReplacing(null);
919
+ setSelected(new Set());
920
+ setNotice(message);
921
+ void load();
922
+ }}
923
+ />
924
+ <UnlinkDialog
925
+ rows={unlinking}
926
+ onClose={() => setUnlinking(null)}
927
+ onDone={(message) => {
928
+ setUnlinking(null);
929
+ setSelected(new Set());
930
+ setNotice(message);
931
+ void load();
932
+ }}
933
+ />
934
+ <SettingsDialog
935
+ open={settingsOpen}
936
+ onClose={() => setSettingsOpen(false)}
937
+ onSaved={(message) => {
938
+ setSettingsOpen(false);
939
+ setNotice(message);
940
+ }}
941
+ />
942
+ <IgnoreRulesDialog
943
+ open={rulesOpen}
944
+ rules={data?.ignores ?? []}
945
+ onClose={() => setRulesOpen(false)}
946
+ onChanged={(message) => {
947
+ setNotice(message);
948
+ void load();
949
+ }}
950
+ />
951
+ </div>
952
+ );
953
+ }
954
+
955
+ export function LinksWidget() {
956
+ const [data, setData] = React.useState<{ counts: Record<LinkStatus, number>; scan: ScanState }>();
957
+ const [error, setError] = React.useState<string>();
958
+ React.useEffect(() => {
959
+ apiFetch(`${API}/summary`)
960
+ .then((r) => parseApiResponse<{ counts: Record<LinkStatus, number>; scan: ScanState }>(r, "Could not load links"))
961
+ .then(setData)
962
+ .catch((cause) => setError(errorText(cause, "Could not load links")));
963
+ }, []);
964
+ if (error) return <p className="text-sm text-kumo-subtle">{error}</p>;
965
+ if (!data) return <Loader />;
966
+ const problems = data.counts.broken + data.counts.error;
967
+ const total = Object.values(data.counts).reduce((a, b) => a + b, 0);
968
+ return (
969
+ <div className="space-y-1 text-sm">
970
+ <p className={problems ? "font-medium text-kumo-danger" : "font-medium"}>{problems ? `${plural(problems, "broken link")}` : "No broken links"}</p>
971
+ <p className="text-kumo-subtle">
972
+ {plural(total, "link")} · {data.counts.blocked.toLocaleString()} blocked · {data.counts.redirect.toLocaleString()} redirected
973
+ {data.counts.unchecked ? ` · ${data.counts.unchecked.toLocaleString()} not checked` : ""}
974
+ </p>
975
+ <a className="text-kumo-link hover:underline" href={`${ADMIN}/plugins/coywolf-pack/links`}>
976
+ Open Link Manager
977
+ </a>
978
+ </div>
979
+ );
980
+ }