@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.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance page (always on, right below Coywolf Pack in the sidebar): how
|
|
3
|
+
* long Cloudflare's edge keeps pages (Workers Cache), the media host's Cache
|
|
4
|
+
* Rule, and a button that clears every cached page plus the media host's
|
|
5
|
+
* images. Cloudflare's zone "Purge Everything" doesn't reach the pages (the
|
|
6
|
+
* cache belongs to the Worker, not the zone). Below them, Cloudflare usage
|
|
7
|
+
* (./usage.tsx).
|
|
8
|
+
*/
|
|
9
|
+
import { Banner, Button, Checkbox, Input, Switch } from "@cloudflare/kumo";
|
|
10
|
+
import { ArrowsClockwise, Fire, Lightning } from "@phosphor-icons/react";
|
|
11
|
+
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
12
|
+
import * as React from "react";
|
|
13
|
+
|
|
14
|
+
import { errorText } from "./settings-ui.js";
|
|
15
|
+
import { UsageCard } from "./usage.js";
|
|
16
|
+
|
|
17
|
+
const API = "/_emdash/api/plugins/coywolf-pack/cache";
|
|
18
|
+
|
|
19
|
+
interface Lifetimes {
|
|
20
|
+
maxAgeDays: number;
|
|
21
|
+
refreshDays: number;
|
|
22
|
+
images: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface MediaCache {
|
|
26
|
+
host: string | null;
|
|
27
|
+
zone?: string;
|
|
28
|
+
reason?: string;
|
|
29
|
+
rule: { edgeTtl?: number; browserTtl?: number; enabled: boolean } | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Notice = { variant: "default" | "error"; text: string } | null;
|
|
33
|
+
|
|
34
|
+
interface WarmStatus {
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
/** The daily refresh (on by default). */
|
|
37
|
+
daily: boolean;
|
|
38
|
+
state: {
|
|
39
|
+
phase: "collect" | "warm" | "done" | "failed";
|
|
40
|
+
startedAt: string;
|
|
41
|
+
finishedAt?: string;
|
|
42
|
+
total: number;
|
|
43
|
+
warmed: number;
|
|
44
|
+
failed: number;
|
|
45
|
+
remaining: number;
|
|
46
|
+
/** Pages to warm again once their video posters are copied. */
|
|
47
|
+
revisiting?: number;
|
|
48
|
+
error?: string;
|
|
49
|
+
reason: string;
|
|
50
|
+
/** Content changed: a new run starts after then. */
|
|
51
|
+
rewarmAfter?: string;
|
|
52
|
+
/** When the daily refresh starts the next run (ms). */
|
|
53
|
+
nextDailyAt?: number;
|
|
54
|
+
} | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const REASON: Record<string, string> = { deploy: "after a deploy", settings: "after a settings change", cleared: "after the cache was cleared", manual: "on request", edit: "after content edits", daily: "for the daily refresh" };
|
|
58
|
+
|
|
59
|
+
function warmText(state: NonNullable<WarmStatus["state"]>): string {
|
|
60
|
+
const when = REASON[state.reason] ?? "";
|
|
61
|
+
if (state.phase === "collect") return `Starting ${when}: reading the sitemap…`;
|
|
62
|
+
if (state.phase === "warm" && state.revisiting && state.remaining === 0) {
|
|
63
|
+
return `Re-warming ${state.revisiting} ${state.revisiting === 1 ? "page" : "pages"} after ${state.revisiting === 1 ? "its video posters were" : "their video posters were"} copied.`;
|
|
64
|
+
}
|
|
65
|
+
if (state.phase === "warm") return `Warming ${when}: ${state.warmed} of ${state.total} pages done.`;
|
|
66
|
+
if (state.rewarmAfter && (state.phase === "done" || state.phase === "failed")) {
|
|
67
|
+
return `Content changed: warming again at about ${new Date(Date.parse(state.rewarmAfter)).toLocaleTimeString()}.`;
|
|
68
|
+
}
|
|
69
|
+
if (state.phase === "failed") return state.error ?? "The last run failed.";
|
|
70
|
+
const at = state.finishedAt ? new Date(state.finishedAt).toLocaleString() : "";
|
|
71
|
+
return `Last run ${when}: ${state.warmed} of ${state.total} pages warmed${state.failed ? ` (${state.failed} didn't load)` : ""}${at ? `, finished ${at}` : ""}.`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** "Next daily refresh: about …" while idle (no run going, none scheduled after edits). */
|
|
75
|
+
function dailyText(state: NonNullable<WarmStatus["state"]>): string | null {
|
|
76
|
+
if (state.phase !== "done" || state.rewarmAfter || !state.nextDailyAt) return null;
|
|
77
|
+
return `Next daily refresh: about ${new Date(state.nextDailyAt).toLocaleString()}.`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function post<T>(path: string, body?: unknown): Promise<T> {
|
|
81
|
+
const response = await apiFetch(`${API}/${path}`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
...(body === undefined ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
|
|
84
|
+
});
|
|
85
|
+
return parseApiResponse<T>(response, "Something went wrong");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const days = (seconds?: number) => (seconds ? Math.round(seconds / 86400) : 0);
|
|
89
|
+
|
|
90
|
+
export function PerformancePage() {
|
|
91
|
+
const [lifetimes, setLifetimes] = React.useState<Lifetimes | null>(null);
|
|
92
|
+
const [draft, setDraft] = React.useState({ maxAgeDays: "", refreshDays: "" });
|
|
93
|
+
const [media, setMedia] = React.useState<MediaCache | null>(null);
|
|
94
|
+
const [busy, setBusy] = React.useState<"save" | "media" | "clear" | "warm" | null>(null);
|
|
95
|
+
const [notice, setNotice] = React.useState<Notice>(null);
|
|
96
|
+
const [warm, setWarm] = React.useState<WarmStatus | null>(null);
|
|
97
|
+
|
|
98
|
+
const loadWarm = React.useCallback(async () => {
|
|
99
|
+
try {
|
|
100
|
+
setWarm(await parseApiResponse<WarmStatus>(await apiFetch(`${API}/warm/status`), "Couldn't load cache warming"));
|
|
101
|
+
} catch {
|
|
102
|
+
// Shown as missing; the rest of the page still works.
|
|
103
|
+
}
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
// While a run is going, refresh its progress.
|
|
107
|
+
const running = warm?.state && (warm.state.phase === "collect" || warm.state.phase === "warm");
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
void loadWarm();
|
|
110
|
+
}, [loadWarm]);
|
|
111
|
+
React.useEffect(() => {
|
|
112
|
+
if (!running) return;
|
|
113
|
+
const timer = setInterval(() => void loadWarm(), 5000);
|
|
114
|
+
return () => clearInterval(timer);
|
|
115
|
+
}, [running, loadWarm]);
|
|
116
|
+
|
|
117
|
+
const load = React.useCallback(async () => {
|
|
118
|
+
try {
|
|
119
|
+
const loaded = await parseApiResponse<Lifetimes>(await apiFetch(`${API}/settings`), "Couldn't load the page cache settings");
|
|
120
|
+
setLifetimes(loaded);
|
|
121
|
+
setDraft({ maxAgeDays: String(loaded.maxAgeDays), refreshDays: String(loaded.refreshDays) });
|
|
122
|
+
if (loaded.images) setMedia(await parseApiResponse<MediaCache>(await apiFetch(`${API}/media`), "Couldn't check the media cache"));
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
setNotice({ variant: "error", text: errorText(cause, "Couldn't load the page cache settings") });
|
|
125
|
+
}
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
React.useEffect(() => {
|
|
129
|
+
void load();
|
|
130
|
+
}, [load]);
|
|
131
|
+
|
|
132
|
+
async function run(kind: "save" | "media" | "clear" | "warm", action: () => Promise<string>) {
|
|
133
|
+
setBusy(kind);
|
|
134
|
+
setNotice(null);
|
|
135
|
+
try {
|
|
136
|
+
setNotice({ variant: "default", text: await action() });
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
setNotice({ variant: "error", text: errorText(cause, "Something went wrong") });
|
|
139
|
+
} finally {
|
|
140
|
+
setBusy(null);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const save = () =>
|
|
145
|
+
run("save", async () => {
|
|
146
|
+
const next = await post<{ maxAgeDays: number; refreshDays: number }>("settings/save", {
|
|
147
|
+
maxAgeDays: Number(draft.maxAgeDays),
|
|
148
|
+
refreshDays: Number(draft.refreshDays),
|
|
149
|
+
});
|
|
150
|
+
setLifetimes((l) => (l ? { ...l, ...next } : l));
|
|
151
|
+
return "Saved. Cached pages were cleared, so new visits use the new lifetime.";
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const applyMedia = () =>
|
|
155
|
+
run("media", async () => {
|
|
156
|
+
const result = await post<MediaCache>("media/apply");
|
|
157
|
+
setMedia(result);
|
|
158
|
+
return `${result.host} is now cached at Cloudflare's edge and in browsers for a year.`;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const clear = () =>
|
|
162
|
+
run("clear", async () => {
|
|
163
|
+
const result = await post<{ pages: boolean; images: { purged: boolean; host: string | null; message?: string } }>("purge");
|
|
164
|
+
const done = [result.pages && "pages", result.images.purged && `images on ${result.images.host}`].filter(Boolean).join(" and ");
|
|
165
|
+
const warning = !result.images.purged && result.images.host ? ` ${result.images.message ?? ""}` : "";
|
|
166
|
+
if (warning) throw new Error(`Cleared ${done || "nothing"}.${warning}`);
|
|
167
|
+
return `Cleared ${done}. Everything is cached again the next time someone visits it.`;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const toggleWarm = (enabled: boolean) =>
|
|
171
|
+
run("warm", async () => {
|
|
172
|
+
await post("warm/settings/save", { enabled });
|
|
173
|
+
await loadWarm();
|
|
174
|
+
return enabled ? "Cache warming is on. It runs after each deploy and whenever the whole cache is cleared." : "Cache warming is off.";
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const toggleDaily = (daily: boolean) =>
|
|
178
|
+
run("warm", async () => {
|
|
179
|
+
await post("warm/settings/save", { daily });
|
|
180
|
+
await loadWarm();
|
|
181
|
+
return daily ? "Daily refresh is on." : "Daily refresh is off.";
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const warmNow = () =>
|
|
185
|
+
run("warm", async () => {
|
|
186
|
+
await post("warm/start");
|
|
187
|
+
await loadWarm();
|
|
188
|
+
return "Warming started. Progress shows below.";
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const dirty = lifetimes && (String(lifetimes.maxAgeDays) !== draft.maxAgeDays || String(lifetimes.refreshDays) !== draft.refreshDays);
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="space-y-6">
|
|
195
|
+
<header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
|
|
196
|
+
<h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Performance</h1>
|
|
197
|
+
<p className="text-sm leading-5 text-pretty text-kumo-subtle">
|
|
198
|
+
Cloudflare keeps copies of your public pages at its edge (Workers Cache), so visitors get them without waiting for the
|
|
199
|
+
site to build them. EmDash clears the pages that change when you publish or edit content, menus or settings; Coywolf
|
|
200
|
+
Pack clears everything after each deploy and whenever its settings change. These settings take effect on sites that
|
|
201
|
+
turn on Workers Cache (Astro's cacheCloudflare(); see docs/performance.md in the Coywolf Pack repository).
|
|
202
|
+
</p>
|
|
203
|
+
</header>
|
|
204
|
+
|
|
205
|
+
<section className="rounded-lg border border-kumo-line" aria-label="Performance settings">
|
|
206
|
+
<div className="divide-y divide-kumo-line">
|
|
207
|
+
{notice && (
|
|
208
|
+
<div className="p-4">
|
|
209
|
+
<Banner variant={notice.variant === "error" ? "error" : "default"} title={notice.text} />
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
<div className="grid gap-4 p-4">
|
|
214
|
+
<h2 className="text-base font-semibold">Page cache</h2>
|
|
215
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
216
|
+
<Input
|
|
217
|
+
type="number"
|
|
218
|
+
min={1}
|
|
219
|
+
max={365}
|
|
220
|
+
label="Keep pages for (days)"
|
|
221
|
+
description="After this, the edge asks the site for a fresh copy."
|
|
222
|
+
value={draft.maxAgeDays}
|
|
223
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDraft((d) => ({ ...d, maxAgeDays: e.target.value }))}
|
|
224
|
+
/>
|
|
225
|
+
<Input
|
|
226
|
+
type="number"
|
|
227
|
+
min={0}
|
|
228
|
+
max={365}
|
|
229
|
+
label="Refresh in the background for (days)"
|
|
230
|
+
description="While a fresh copy is made, visitors still get the cached one instantly."
|
|
231
|
+
value={draft.refreshDays}
|
|
232
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDraft((d) => ({ ...d, refreshDays: e.target.value }))}
|
|
233
|
+
/>
|
|
234
|
+
</div>
|
|
235
|
+
<div>
|
|
236
|
+
<Button variant="primary" disabled={!dirty || busy !== null} onClick={() => void save()}>
|
|
237
|
+
{busy === "save" ? "Saving…" : "Save"}
|
|
238
|
+
</Button>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div className="grid gap-3 p-4">
|
|
243
|
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
244
|
+
<div className="min-w-0 max-w-2xl">
|
|
245
|
+
<h2 className="text-base font-semibold">Cache warming</h2>
|
|
246
|
+
<p className="text-sm leading-5 text-pretty text-kumo-subtle">
|
|
247
|
+
After a deploy or a full clear, every page is cold until someone visits it. Warming visits every page in your
|
|
248
|
+
sitemap (home page first, then newest posts) a few at a time, in the background of the requests your site already
|
|
249
|
+
gets, so the next visitors and search engine crawlers get cached pages. It warms the region your traffic comes
|
|
250
|
+
from; it doesn't run while nobody is visiting. Warming also runs a minute after content edits (saving or
|
|
251
|
+
publishing an entry, menus, categories, site settings), which clear the home page, archives and related pages.
|
|
252
|
+
</p>
|
|
253
|
+
</div>
|
|
254
|
+
<Switch className="shrink-0"
|
|
255
|
+
aria-label={`Cache warming: ${warm?.enabled ? "on" : "off"}`}
|
|
256
|
+
checked={Boolean(warm?.enabled)}
|
|
257
|
+
disabled={!warm || busy !== null}
|
|
258
|
+
transitioning={busy === "warm"}
|
|
259
|
+
onCheckedChange={(on) => void toggleWarm(on)}
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
{warm?.enabled && (
|
|
263
|
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
264
|
+
<p className="text-sm leading-5 text-kumo-subtle" aria-live="polite">
|
|
265
|
+
{warm.state ? warmText(warm.state) : warm.daily ? "No run yet. It starts with the next page visit." : "No run yet. It starts after the next deploy or full clear."}
|
|
266
|
+
{warm.daily && warm.state && dailyText(warm.state) && (
|
|
267
|
+
<>
|
|
268
|
+
<br />
|
|
269
|
+
{dailyText(warm.state)}
|
|
270
|
+
</>
|
|
271
|
+
)}
|
|
272
|
+
</p>
|
|
273
|
+
<Button variant="secondary" icon={<Fire />} disabled={busy !== null || Boolean(running)} onClick={() => void warmNow()}>
|
|
274
|
+
{running ? "Warming…" : "Warm now"}
|
|
275
|
+
</Button>
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
{warm?.enabled && (
|
|
279
|
+
<div className="max-w-2xl">
|
|
280
|
+
<Checkbox label="Refresh daily" checked={warm.daily} disabled={busy !== null} onCheckedChange={(checked: boolean) => void toggleDaily(checked)} />
|
|
281
|
+
<p className="text-sm leading-5 text-pretty text-kumo-subtle">
|
|
282
|
+
Once a day, check every page and rebuild the ones Cloudflare dropped from its cache, so search bots and visitors find them cached. Pages still cached cost almost nothing.
|
|
283
|
+
</p>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
{lifetimes?.images && media && (
|
|
289
|
+
<div className="flex flex-wrap items-center justify-between gap-4 p-4">
|
|
290
|
+
<div className="min-w-0 max-w-2xl">
|
|
291
|
+
<h2 className="text-base font-semibold">Media cache</h2>
|
|
292
|
+
<p className="text-sm leading-5 text-pretty text-kumo-subtle">
|
|
293
|
+
{!media.host
|
|
294
|
+
? media.reason
|
|
295
|
+
: media.rule
|
|
296
|
+
? `${media.host} is cached at Cloudflare's edge for ${days(media.rule.edgeTtl)} days and in browsers for ${days(media.rule.browserTtl)} days${media.rule.enabled ? "" : " (the rule is turned off)"}.`
|
|
297
|
+
: media.reason
|
|
298
|
+
? `Couldn't check ${media.host}: ${media.reason} The token needs Zone → Cache Rules → Edit.`
|
|
299
|
+
: `${media.host} isn't cached at Cloudflare's edge yet, so every image is fetched from storage. Media file names never change, so a year is safe.`}
|
|
300
|
+
</p>
|
|
301
|
+
</div>
|
|
302
|
+
{media.host && !(media.rule && days(media.rule.edgeTtl) >= 365 && media.rule.enabled) && (
|
|
303
|
+
<Button variant="secondary" icon={<Lightning />} disabled={busy !== null || Boolean(media.reason)} onClick={() => void applyMedia()}>
|
|
304
|
+
{busy === "media" ? "Setting up…" : "Cache media for a year"}
|
|
305
|
+
</Button>
|
|
306
|
+
)}
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
|
|
310
|
+
<div className="flex flex-wrap items-center justify-between gap-4 p-4">
|
|
311
|
+
<div className="min-w-0 max-w-2xl">
|
|
312
|
+
<h2 className="text-base font-semibold">Clear the cache</h2>
|
|
313
|
+
<p className="text-sm leading-5 text-pretty text-kumo-subtle">
|
|
314
|
+
Removes every cached page and the images on your media host, for example after replacing an image or changing
|
|
315
|
+
theme files outside a deploy. Cloudflare's “Purge Everything” for the zone doesn't clear these pages; use this
|
|
316
|
+
button instead. Clearing images uses the Cloudflare API token from Clean Image URLs (it needs Zone → Cache Purge →
|
|
317
|
+
Purge).
|
|
318
|
+
</p>
|
|
319
|
+
</div>
|
|
320
|
+
<Button variant="secondary" icon={<ArrowsClockwise />} disabled={busy !== null} onClick={() => void clear()}>
|
|
321
|
+
{busy === "clear" ? "Clearing…" : "Clear pages and images"}
|
|
322
|
+
</Button>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</section>
|
|
326
|
+
|
|
327
|
+
<UsageCard />
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live preview of a site block in an isolated frame, so its CSS can't restyle
|
|
3
|
+
* the admin page (and the admin's can't restyle it). No scripts run in the
|
|
4
|
+
* frame; <details> still opens and closes natively.
|
|
5
|
+
*/
|
|
6
|
+
import { Button } from "@cloudflare/kumo";
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
|
|
9
|
+
export function PreviewFrame(props: { title: string; css: string; html: string; width?: "wide" | "phone" }) {
|
|
10
|
+
const frame = React.useRef<HTMLIFrameElement>(null);
|
|
11
|
+
const [height, setHeight] = React.useState(160);
|
|
12
|
+
const doc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>html{color-scheme:light dark}body{margin:0;padding:16px;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;line-height:1.5;background:Canvas;color:CanvasText}a{color:LinkText}</style><style>${props.css}</style></head><body>${props.html}</body></html>`;
|
|
13
|
+
const measure = React.useCallback(() => {
|
|
14
|
+
// The body, not the root: the root is never shorter than the frame, so it can't shrink.
|
|
15
|
+
const body = frame.current?.contentDocument?.body;
|
|
16
|
+
if (body) setHeight(Math.min(1200, Math.max(60, Math.ceil(body.getBoundingClientRect().height))));
|
|
17
|
+
}, []);
|
|
18
|
+
// Re-measure when <details> opens or closes inside the frame.
|
|
19
|
+
const onLoad = () => {
|
|
20
|
+
measure();
|
|
21
|
+
frame.current?.contentDocument?.addEventListener("toggle", measure, true);
|
|
22
|
+
};
|
|
23
|
+
return (
|
|
24
|
+
<iframe
|
|
25
|
+
ref={frame}
|
|
26
|
+
title={props.title}
|
|
27
|
+
// No scripts; same origin only so the frame's height can be measured.
|
|
28
|
+
sandbox="allow-same-origin"
|
|
29
|
+
srcDoc={doc}
|
|
30
|
+
onLoad={onLoad}
|
|
31
|
+
className="block rounded-lg border border-kumo-line bg-white"
|
|
32
|
+
style={{ width: props.width === "phone" ? 375 : "100%", maxWidth: "100%", height }}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** "Preview" heading with Wide/Phone toggles, then the frame and a note. */
|
|
38
|
+
export function PreviewSection(props: { id: string; title: string; css: string; html: string; note?: React.ReactNode; empty?: React.ReactNode }) {
|
|
39
|
+
const [width, setWidth] = React.useState<"wide" | "phone">("wide");
|
|
40
|
+
return (
|
|
41
|
+
<section aria-labelledby={props.id} className="min-w-0">
|
|
42
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
43
|
+
<h3 id={props.id} className="text-sm font-medium">
|
|
44
|
+
Preview
|
|
45
|
+
</h3>
|
|
46
|
+
<div className="flex gap-1" role="group" aria-label="Preview width">
|
|
47
|
+
<Button type="button" size="sm" variant={width === "wide" ? "primary" : "secondary"} aria-pressed={width === "wide"} onClick={() => setWidth("wide")}>
|
|
48
|
+
Wide
|
|
49
|
+
</Button>
|
|
50
|
+
<Button type="button" size="sm" variant={width === "phone" ? "primary" : "secondary"} aria-pressed={width === "phone"} onClick={() => setWidth("phone")}>
|
|
51
|
+
Phone
|
|
52
|
+
</Button>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
{props.html ? (
|
|
56
|
+
<PreviewFrame title={props.title} css={props.css} html={props.html} width={width} />
|
|
57
|
+
) : (
|
|
58
|
+
<p className="rounded-lg border border-dashed border-kumo-line p-4 text-sm text-kumo-subtle">{props.empty}</p>
|
|
59
|
+
)}
|
|
60
|
+
{props.note && <p className="mt-2 text-xs text-kumo-subtle">{props.note}</p>}
|
|
61
|
+
</section>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Removed content" panel on the Redirects page (feature
|
|
3
|
+
* "redirects.trashPrompt"): published entries that were deleted or
|
|
4
|
+
* unpublished, with what to do about their old URLs. Renders nothing while
|
|
5
|
+
* the feature is off.
|
|
6
|
+
*/
|
|
7
|
+
import { Badge, Banner, Button, Dialog, Input, Select } from "@cloudflare/kumo";
|
|
8
|
+
import { ArrowBendUpRight, Prohibit, X } from "@phosphor-icons/react";
|
|
9
|
+
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
10
|
+
import * as React from "react";
|
|
11
|
+
|
|
12
|
+
import type { Pending } from "./trash-prompt-core.js";
|
|
13
|
+
|
|
14
|
+
export type { Pending };
|
|
15
|
+
|
|
16
|
+
export const REMOVED_API = "/_emdash/api/plugins/coywolf-pack/redirects/removed";
|
|
17
|
+
const API = REMOVED_API;
|
|
18
|
+
|
|
19
|
+
export const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
|
|
20
|
+
const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium" });
|
|
21
|
+
const domId = (item: Pending) => `cw-removed-${item.id.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One removed entry: title, old URL, Deleted/Unpublished badge, and the
|
|
25
|
+
* Redirect to… and Return 410 Gone buttons (plus `extra` buttons and `meta`
|
|
26
|
+
* details). With `status`, the buttons give way to that line.
|
|
27
|
+
*/
|
|
28
|
+
export function RemovedItemRow(props: {
|
|
29
|
+
item: Pending;
|
|
30
|
+
busy: boolean;
|
|
31
|
+
onRedirect: () => void;
|
|
32
|
+
onGone: () => void;
|
|
33
|
+
meta?: React.ReactNode;
|
|
34
|
+
extra?: React.ReactNode;
|
|
35
|
+
status?: string;
|
|
36
|
+
redirectRef?: React.Ref<HTMLButtonElement>;
|
|
37
|
+
}) {
|
|
38
|
+
const { item } = props;
|
|
39
|
+
const urlId = `${domId(item)}-url`;
|
|
40
|
+
return (
|
|
41
|
+
<li className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center">
|
|
42
|
+
<div className="min-w-0 flex-1">
|
|
43
|
+
<div className="truncate text-sm font-medium" title={item.title}>
|
|
44
|
+
{item.title}
|
|
45
|
+
</div>
|
|
46
|
+
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-kumo-subtle">
|
|
47
|
+
<code id={urlId} className="truncate" title={item.url}>
|
|
48
|
+
{item.url}
|
|
49
|
+
</code>
|
|
50
|
+
<Badge variant="outline">{item.reason === "deleted" ? "Deleted" : "Unpublished"}</Badge>
|
|
51
|
+
{props.meta}
|
|
52
|
+
</div>
|
|
53
|
+
{props.status && <p className="mt-1 text-sm">{props.status}</p>}
|
|
54
|
+
</div>
|
|
55
|
+
{!props.status && (
|
|
56
|
+
<div className="flex shrink-0 flex-wrap gap-2">
|
|
57
|
+
<Button
|
|
58
|
+
ref={props.redirectRef}
|
|
59
|
+
variant="secondary"
|
|
60
|
+
size="sm"
|
|
61
|
+
icon={<ArrowBendUpRight aria-hidden="true" />}
|
|
62
|
+
disabled={props.busy}
|
|
63
|
+
onClick={props.onRedirect}
|
|
64
|
+
aria-describedby={urlId}
|
|
65
|
+
>
|
|
66
|
+
Redirect to…
|
|
67
|
+
</Button>
|
|
68
|
+
<Button
|
|
69
|
+
variant="secondary"
|
|
70
|
+
size="sm"
|
|
71
|
+
icon={<Prohibit aria-hidden="true" />}
|
|
72
|
+
disabled={props.busy}
|
|
73
|
+
onClick={props.onGone}
|
|
74
|
+
aria-describedby={urlId}
|
|
75
|
+
>
|
|
76
|
+
Return 410 Gone
|
|
77
|
+
</Button>
|
|
78
|
+
{props.extra}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
</li>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function RedirectDialog(props: { item: Pending | null; onClose: () => void; onDone: (message: string) => void }) {
|
|
86
|
+
const [target, setTarget] = React.useState("");
|
|
87
|
+
const [type, setType] = React.useState("301");
|
|
88
|
+
const [pending, setPending] = React.useState(false);
|
|
89
|
+
const [error, setError] = React.useState<string>();
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
setTarget("");
|
|
92
|
+
setType("301");
|
|
93
|
+
setError(undefined);
|
|
94
|
+
}, [props.item]);
|
|
95
|
+
|
|
96
|
+
const save = async () => {
|
|
97
|
+
if (!props.item) return;
|
|
98
|
+
setPending(true);
|
|
99
|
+
setError(undefined);
|
|
100
|
+
try {
|
|
101
|
+
await resolve(props.item.id, "redirect", target.trim(), Number(type));
|
|
102
|
+
props.onDone(`${props.item.url} now redirects to ${target.trim()}.`);
|
|
103
|
+
} catch (cause) {
|
|
104
|
+
setError(errorText(cause, "Could not create the redirect"));
|
|
105
|
+
} finally {
|
|
106
|
+
setPending(false);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Dialog.Root open={props.item !== null} onOpenChange={(open) => !open && !pending && props.onClose()}>
|
|
112
|
+
<Dialog className="p-6" size="lg">
|
|
113
|
+
<Dialog.Title className="text-lg font-semibold">Redirect removed content</Dialog.Title>
|
|
114
|
+
<Dialog.Description className="mt-1 text-sm text-kumo-subtle">
|
|
115
|
+
Send visitors and search engines from <code>{props.item?.url}</code> to another page.
|
|
116
|
+
</Dialog.Description>
|
|
117
|
+
<form
|
|
118
|
+
className="mt-4 space-y-4"
|
|
119
|
+
onSubmit={(e) => {
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
void save();
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<Input
|
|
125
|
+
label="Destination"
|
|
126
|
+
placeholder="/related-article or https://example.com/page"
|
|
127
|
+
value={target}
|
|
128
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTarget(e.target.value)}
|
|
129
|
+
required
|
|
130
|
+
/>
|
|
131
|
+
<Select
|
|
132
|
+
label="Type"
|
|
133
|
+
value={type}
|
|
134
|
+
onValueChange={(value: string | null) => setType(value ?? "301")}
|
|
135
|
+
items={[
|
|
136
|
+
{ value: "301", label: "301 Permanent" },
|
|
137
|
+
{ value: "302", label: "302 Temporary" },
|
|
138
|
+
{ value: "308", label: "308 Permanent (keep method)" },
|
|
139
|
+
{ value: "307", label: "307 Temporary (keep method)" },
|
|
140
|
+
]}
|
|
141
|
+
/>
|
|
142
|
+
{error && <Banner variant="error" role="alert" description={error} />}
|
|
143
|
+
<div className="flex justify-end gap-2">
|
|
144
|
+
<Button type="button" variant="secondary" disabled={pending} onClick={props.onClose}>
|
|
145
|
+
Cancel
|
|
146
|
+
</Button>
|
|
147
|
+
<Button type="submit" variant="primary" disabled={pending || !target.trim()}>
|
|
148
|
+
{pending ? "Saving…" : "Create redirect"}
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
</form>
|
|
152
|
+
</Dialog>
|
|
153
|
+
</Dialog.Root>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function resolve(id: string, action: "redirect" | "gone" | "dismiss", target?: string, type?: number) {
|
|
158
|
+
const response = await apiFetch(`${API}/resolve`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "Content-Type": "application/json" },
|
|
161
|
+
body: JSON.stringify({ id, action, target, type }),
|
|
162
|
+
});
|
|
163
|
+
return parseApiResponse(response, "The request failed");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Shows pending decisions; calls onRuleCreated after a redirect or 410 rule is added so the page reloads its rules. */
|
|
167
|
+
export function RemovedContentPanel(props: { onRuleCreated: () => void }) {
|
|
168
|
+
const [items, setItems] = React.useState<Pending[] | null>(null);
|
|
169
|
+
const [error, setError] = React.useState<string>();
|
|
170
|
+
const [notice, setNotice] = React.useState<string>();
|
|
171
|
+
const [redirecting, setRedirecting] = React.useState<Pending | null>(null);
|
|
172
|
+
const [busy, setBusy] = React.useState<string | null>(null);
|
|
173
|
+
|
|
174
|
+
const load = React.useCallback(async () => {
|
|
175
|
+
try {
|
|
176
|
+
const response = await apiFetch(API);
|
|
177
|
+
if (response.status === 404) {
|
|
178
|
+
setItems(null); // Feature off.
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
setItems((await parseApiResponse<{ items: Pending[] }>(response, "Could not load removed content")).items);
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
setError(errorText(cause, "Could not load removed content"));
|
|
184
|
+
}
|
|
185
|
+
}, []);
|
|
186
|
+
React.useEffect(() => {
|
|
187
|
+
void load();
|
|
188
|
+
}, [load]);
|
|
189
|
+
|
|
190
|
+
const act = async (item: Pending, action: "gone" | "dismiss") => {
|
|
191
|
+
setBusy(item.id);
|
|
192
|
+
setError(undefined);
|
|
193
|
+
try {
|
|
194
|
+
await resolve(item.id, action);
|
|
195
|
+
setNotice(action === "gone" ? `${item.url} now returns 410 Gone.` : `Dismissed ${item.url}.`);
|
|
196
|
+
if (action === "gone") props.onRuleCreated();
|
|
197
|
+
await load();
|
|
198
|
+
} catch (cause) {
|
|
199
|
+
setError(errorText(cause, "The request failed"));
|
|
200
|
+
} finally {
|
|
201
|
+
setBusy(null);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (!items || (items.length === 0 && !notice && !error)) return null;
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<section aria-labelledby="cw-removed-heading" className="rounded-lg border border-kumo-line">
|
|
209
|
+
<div className="border-b border-kumo-line p-4">
|
|
210
|
+
<h2 id="cw-removed-heading" className="text-base font-semibold">
|
|
211
|
+
Removed content
|
|
212
|
+
</h2>
|
|
213
|
+
<p className="mt-1 text-sm text-kumo-subtle">
|
|
214
|
+
These published entries were deleted or unpublished. Decide what their old URLs should do: redirect them to a
|
|
215
|
+
related page, or tell search engines they're gone for good.
|
|
216
|
+
</p>
|
|
217
|
+
</div>
|
|
218
|
+
<div aria-live="polite">
|
|
219
|
+
{(notice || error) && (
|
|
220
|
+
<div className="space-y-4 p-4">
|
|
221
|
+
{notice && <Banner variant="default" role="status" title={notice} />}
|
|
222
|
+
{error && <Banner variant="error" role="alert" description={error} />}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
{items.length === 0 ? (
|
|
227
|
+
<p className="px-4 py-4 text-sm text-kumo-subtle">Nothing left to decide.</p>
|
|
228
|
+
) : (
|
|
229
|
+
<ul className="divide-y divide-kumo-line">
|
|
230
|
+
{items.map((item) => (
|
|
231
|
+
<RemovedItemRow
|
|
232
|
+
key={item.id}
|
|
233
|
+
item={item}
|
|
234
|
+
busy={busy !== null}
|
|
235
|
+
onRedirect={() => setRedirecting(item)}
|
|
236
|
+
onGone={() => void act(item, "gone")}
|
|
237
|
+
meta={<span>{dateFormat.format(new Date(item.at))}</span>}
|
|
238
|
+
extra={
|
|
239
|
+
<Button
|
|
240
|
+
variant="ghost"
|
|
241
|
+
size="sm"
|
|
242
|
+
icon={<X aria-hidden="true" />}
|
|
243
|
+
disabled={busy !== null}
|
|
244
|
+
onClick={() => void act(item, "dismiss")}
|
|
245
|
+
aria-describedby={`${domId(item)}-url`}
|
|
246
|
+
>
|
|
247
|
+
Dismiss
|
|
248
|
+
</Button>
|
|
249
|
+
}
|
|
250
|
+
/>
|
|
251
|
+
))}
|
|
252
|
+
</ul>
|
|
253
|
+
)}
|
|
254
|
+
<RedirectDialog
|
|
255
|
+
item={redirecting}
|
|
256
|
+
onClose={() => setRedirecting(null)}
|
|
257
|
+
onDone={(message) => {
|
|
258
|
+
setRedirecting(null);
|
|
259
|
+
setNotice(message);
|
|
260
|
+
props.onRuleCreated();
|
|
261
|
+
void load();
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
</section>
|
|
265
|
+
);
|
|
266
|
+
}
|