@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,825 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clean Image URLs page: the media host (status, a live check, the setting),
|
|
3
|
+
* a step-by-step guide for setting it up by hand on Cloudflare, and Set up
|
|
4
|
+
* media host, which does the same through the Cloudflare API (review, then
|
|
5
|
+
* apply). The feature itself is turned on or off on the Coywolf Pack page.
|
|
6
|
+
*/
|
|
7
|
+
import { Banner, Button, Checkbox, Input, Loader } from "@cloudflare/kumo";
|
|
8
|
+
import { ArrowsClockwise, CheckCircle, Info, MagnifyingGlass, Wrench, XCircle } from "@phosphor-icons/react";
|
|
9
|
+
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
10
|
+
import * as React from "react";
|
|
11
|
+
|
|
12
|
+
import { type LibrarySummary, monthlyWithoutMax, oneTimeCost, paybackMonths, storageCost, trafficEstimate } from "../images/variants-estimate.js";
|
|
13
|
+
import { CredentialGuide } from "./guides.js";
|
|
14
|
+
import { SaveBar, isDirty } from "./save-bar.js";
|
|
15
|
+
import { SecretField, SettingsSection, errorText } from "./settings-ui.js";
|
|
16
|
+
|
|
17
|
+
const API = "/_emdash/api/plugins/coywolf-pack/images";
|
|
18
|
+
|
|
19
|
+
interface Settings {
|
|
20
|
+
host: string;
|
|
21
|
+
optionHost: string;
|
|
22
|
+
activeHost: string;
|
|
23
|
+
accountId: string;
|
|
24
|
+
bucket: string;
|
|
25
|
+
tokenSet: boolean;
|
|
26
|
+
envToken: boolean;
|
|
27
|
+
envAccountId: boolean;
|
|
28
|
+
siteHost: string;
|
|
29
|
+
quotaNote: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CheckItem {
|
|
33
|
+
id: string;
|
|
34
|
+
ok: boolean;
|
|
35
|
+
label: string;
|
|
36
|
+
detail: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CheckResult {
|
|
40
|
+
host: string;
|
|
41
|
+
file: string;
|
|
42
|
+
ok: boolean;
|
|
43
|
+
items: CheckItem[];
|
|
44
|
+
quotaNote: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Plan {
|
|
48
|
+
zone: string;
|
|
49
|
+
plan: { host: string; done: boolean; steps: Array<{ id: string; action: string; label: string }> };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Applied {
|
|
53
|
+
zone: string;
|
|
54
|
+
results: Array<{ id: string; ok: boolean; message: string }>;
|
|
55
|
+
check: CheckResult | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Draft {
|
|
59
|
+
host: string;
|
|
60
|
+
accountId: string;
|
|
61
|
+
bucket: string;
|
|
62
|
+
token: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const toDraft = (s: Settings): Draft => ({ host: s.host, accountId: s.accountId, bucket: s.bucket, token: "" });
|
|
66
|
+
|
|
67
|
+
async function post<T>(path: string, body: unknown, fallback: string): Promise<T> {
|
|
68
|
+
const response = await apiFetch(`${API}/${path}`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
});
|
|
73
|
+
return parseApiResponse<T>(response, fallback);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function StatusIcon({ ok }: { ok: boolean }) {
|
|
77
|
+
return ok ? (
|
|
78
|
+
<CheckCircle size={18} weight="fill" style={{ color: "var(--color-kumo-success, #16a34a)", flexShrink: 0 }} role="img" aria-label="Passed" />
|
|
79
|
+
) : (
|
|
80
|
+
<XCircle size={18} weight="fill" style={{ color: "var(--color-kumo-danger, #dc2626)", flexShrink: 0 }} role="img" aria-label="Failed" />
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Load an image in this browser; its natural size, or null when it fails. */
|
|
85
|
+
function loadImage(url: string): Promise<{ width: number; height: number } | null> {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const img = new Image();
|
|
88
|
+
const timer = window.setTimeout(() => resolve(null), 20_000);
|
|
89
|
+
img.onload = () => {
|
|
90
|
+
window.clearTimeout(timer);
|
|
91
|
+
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
|
92
|
+
};
|
|
93
|
+
img.onerror = () => {
|
|
94
|
+
window.clearTimeout(timer);
|
|
95
|
+
resolve(null);
|
|
96
|
+
};
|
|
97
|
+
img.src = url;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Replace the server's fetch results with what this browser actually gets from the media host. */
|
|
102
|
+
async function browserCheck(result: CheckResult): Promise<CheckResult> {
|
|
103
|
+
if (!result.file) return result;
|
|
104
|
+
const stamp = Date.now().toString(36);
|
|
105
|
+
const host = result.host.replace(/\/+$/, "");
|
|
106
|
+
const [original, resized] = await Promise.all([loadImage(`${host}/${result.file}?check=${stamp}`), loadImage(`${host}/s/64x64/${result.file}`)]);
|
|
107
|
+
const items: CheckItem[] = [
|
|
108
|
+
{ id: "reachable", ok: Boolean(original || resized), label: "Host reachable", detail: original || resized ? "Your browser loaded images from the media host." : "Your browser couldn't load anything from the media host. Check the R2 custom domain and its DNS record." },
|
|
109
|
+
{ id: "original", ok: Boolean(original), label: "Original served", detail: original ? `${result.file} (${original.width}×${original.height}).` : `${result.file} didn't load. Is the custom domain connected to the media bucket?` },
|
|
110
|
+
{
|
|
111
|
+
id: "resize",
|
|
112
|
+
ok: Boolean(resized && resized.width === 64 && resized.height === 64),
|
|
113
|
+
label: "Resize works",
|
|
114
|
+
detail: !resized
|
|
115
|
+
? "/s/64x64/ didn't load: the URL rewrite rules are missing, or Image Transformations are off for the zone."
|
|
116
|
+
: resized.width === 64 && resized.height === 64
|
|
117
|
+
? "/s/64x64/ came back at 64×64."
|
|
118
|
+
: `/s/64x64/ came back at ${resized.width}×${resized.height}: the rewrite rules don't match this host.`,
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
return { ...result, items, ok: items.every((i) => i.ok) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function CheckList({ result }: { result: CheckResult }) {
|
|
125
|
+
return (
|
|
126
|
+
<div className="space-y-2" aria-live="polite">
|
|
127
|
+
<p className="text-sm">
|
|
128
|
+
Checked <span className="font-medium">{result.host}</span> with <code className="rounded bg-kumo-tint px-1 text-xs">{result.file}</code>:{" "}
|
|
129
|
+
{result.ok ? "it works." : "something needs attention."}
|
|
130
|
+
</p>
|
|
131
|
+
<ul className="space-y-1.5">
|
|
132
|
+
{result.items.map((item) => (
|
|
133
|
+
<li key={item.id} className="flex items-start gap-2 text-sm">
|
|
134
|
+
{item.id === "cached" && !item.ok ? (
|
|
135
|
+
<Info size={18} style={{ flexShrink: 0 }} className="text-kumo-subtle" aria-label="Not yet" />
|
|
136
|
+
) : (
|
|
137
|
+
<StatusIcon ok={item.ok} />
|
|
138
|
+
)}
|
|
139
|
+
<span>
|
|
140
|
+
<span className="font-medium">{item.label}.</span> <span className="text-kumo-subtle">{item.detail}</span>
|
|
141
|
+
</span>
|
|
142
|
+
</li>
|
|
143
|
+
))}
|
|
144
|
+
</ul>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface VariantsStatus {
|
|
150
|
+
available: boolean;
|
|
151
|
+
reason?: string;
|
|
152
|
+
/** Sizes for existing images (the backfill) are on. */
|
|
153
|
+
bulk?: boolean;
|
|
154
|
+
total?: number;
|
|
155
|
+
stored?: number;
|
|
156
|
+
skipped?: number;
|
|
157
|
+
state?: { phase: "running" | "cleanup" | "done"; done: number; skipped: number; failed: number; finishedAt?: string; leaseUntil?: string; pausedUntil?: string; pauseReason?: string } | null;
|
|
158
|
+
/** Images the running backfill handles an hour, from its start. */
|
|
159
|
+
perHour?: number | null;
|
|
160
|
+
crops: string[];
|
|
161
|
+
fallbackNote: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface Estimate extends LibrarySummary {
|
|
165
|
+
/** Published pages; null when they couldn't be counted. */
|
|
166
|
+
pages: number | null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const count = (n: number | undefined) => (n ?? 0).toLocaleString("en-US");
|
|
170
|
+
const money = (n: number) => (n > 0 && n < 0.01 ? "less than $0.01" : `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
|
|
171
|
+
const range = (r: { min: number; max: number }) => (r.max < 0.01 || money(r.min) === money(r.max) ? money(r.max) : `${money(r.min)}–${money(r.max)}`);
|
|
172
|
+
/** "about $1.20", or "less than $0.01" as it is (never "about less than"). */
|
|
173
|
+
const about = (s: string) => (s.startsWith("less") ? s : `about ${s}`);
|
|
174
|
+
const gb = (bytes: number) => (bytes < 1e9 ? `${Math.max(1, Math.round(bytes / 1e6)).toLocaleString("en-US")} MB` : `${(bytes / 1e9).toFixed(1)} GB`);
|
|
175
|
+
const payback = (months: number | null) =>
|
|
176
|
+
months === null ? "doesn't pay for itself at this traffic" : months <= 1 ? "pays for itself in the first month" : `pays for itself in about ${months} months`;
|
|
177
|
+
|
|
178
|
+
/** What stored sizes cost, what they save, and a traffic calculator (all computed here from one fetch). */
|
|
179
|
+
function CostEstimate({ estimate }: { estimate: Estimate }) {
|
|
180
|
+
const [views, setViews] = React.useState("10000");
|
|
181
|
+
const [crawlers, setCrawlers] = React.useState(true);
|
|
182
|
+
const oneTime = oneTimeCost(estimate.transforms);
|
|
183
|
+
const storage = storageCost(estimate.bytes);
|
|
184
|
+
const upTo = monthlyWithoutMax(estimate.sizes);
|
|
185
|
+
const viewsNumber = Math.max(0, Number(views) || 0);
|
|
186
|
+
const traffic = estimate.pages
|
|
187
|
+
? trafficEstimate({ views: viewsNumber, pages: estimate.pages, images: estimate.images, sizes: estimate.sizes, bytes: estimate.bytes, crawlers })
|
|
188
|
+
: null;
|
|
189
|
+
if (!estimate.images) return <p className="text-sm text-kumo-subtle">The media library has no images that can get stored sizes yet.</p>;
|
|
190
|
+
return (
|
|
191
|
+
<div className="space-y-3 rounded-md border border-kumo-line p-3">
|
|
192
|
+
<p className="text-sm font-medium">
|
|
193
|
+
What it costs: {count(estimate.images)} images, {count(estimate.done)} with their sizes
|
|
194
|
+
</p>
|
|
195
|
+
<ul className="list-disc space-y-1.5 cw-ps-6 text-sm">
|
|
196
|
+
<li>
|
|
197
|
+
{estimate.transforms ? (
|
|
198
|
+
<>
|
|
199
|
+
<span className="font-medium">One time: {about(range(oneTime))}.</span> Making the missing sizes is {count(estimate.transforms)} Cloudflare image
|
|
200
|
+
transformations ({count(estimate.filesLeft)} files) at $0.50 per 1,000, made once. The first 5,000 each month are free, but the site may
|
|
201
|
+
already have used them this month, hence the range.
|
|
202
|
+
</>
|
|
203
|
+
) : (
|
|
204
|
+
<>
|
|
205
|
+
<span className="font-medium">One time: nothing left to pay.</span> Every image has its sizes.
|
|
206
|
+
</>
|
|
207
|
+
)}
|
|
208
|
+
</li>
|
|
209
|
+
<li>
|
|
210
|
+
<span className="font-medium">Then {about(money(storage))} a month</span> to keep {count(estimate.files)} files (about {gb(estimate.bytes)}) in the
|
|
211
|
+
media bucket, at R2's $0.015 per GB a month (less while the bucket is under R2's free 10 GB). Serving them costs no transformations.
|
|
212
|
+
</li>
|
|
213
|
+
<li>
|
|
214
|
+
<span className="font-medium">Without stored sizes: up to {money(upTo)} a month.</span> Cloudflare charges for each different size of each image
|
|
215
|
+
that's asked for, every month (AVIF and WebP of one size count once). That's if all {count(estimate.sizes)} sizes are asked for in a month, after
|
|
216
|
+
the free 5,000.{" "}
|
|
217
|
+
{upTo > storage
|
|
218
|
+
? `At that most, making them ${payback(paybackMonths(oneTime.max, upTo - storage))}.`
|
|
219
|
+
: "That fits in the free 5,000, so here stored sizes are about faster pages, not savings."}
|
|
220
|
+
</li>
|
|
221
|
+
<li>Crops (thumbnails, avatars) are made as pages first show them, only for the images that use them: usually a few cents.</li>
|
|
222
|
+
</ul>
|
|
223
|
+
|
|
224
|
+
{traffic ? (
|
|
225
|
+
<div className="space-y-2">
|
|
226
|
+
<div className="grid gap-3 sm:grid-cols-2 sm:items-end">
|
|
227
|
+
<Input
|
|
228
|
+
type="number"
|
|
229
|
+
min={0}
|
|
230
|
+
label="Page views per month"
|
|
231
|
+
value={views}
|
|
232
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setViews(e.target.value)}
|
|
233
|
+
/>
|
|
234
|
+
<Checkbox label="Search engines crawl the site" checked={crawlers} onCheckedChange={(checked: boolean) => setCrawlers(checked)} />
|
|
235
|
+
</div>
|
|
236
|
+
<table className="w-full text-sm" aria-live="polite">
|
|
237
|
+
<tbody>
|
|
238
|
+
<tr className="border-b border-kumo-line">
|
|
239
|
+
<th scope="row" className="py-1.5 pr-3 text-left font-normal text-kumo-subtle">Without stored sizes</th>
|
|
240
|
+
<td className="py-1.5 font-medium">
|
|
241
|
+
{money(traffic.withoutCost)} a month <span className="font-normal text-kumo-subtle">({count(traffic.withoutTransforms)} transformations)</span>
|
|
242
|
+
</td>
|
|
243
|
+
</tr>
|
|
244
|
+
<tr className="border-b border-kumo-line">
|
|
245
|
+
<th scope="row" className="py-1.5 pr-3 text-left font-normal text-kumo-subtle">With stored sizes</th>
|
|
246
|
+
<td className="py-1.5 font-medium">
|
|
247
|
+
{money(traffic.withCost)} a month <span className="font-normal text-kumo-subtle">(storage, plus {count(traffic.withTransforms)} Open Graph images)</span>
|
|
248
|
+
</td>
|
|
249
|
+
</tr>
|
|
250
|
+
<tr className="border-b border-kumo-line">
|
|
251
|
+
<th scope="row" className="py-1.5 pr-3 text-left font-normal text-kumo-subtle">One-time fee</th>
|
|
252
|
+
<td className="py-1.5 font-medium">{estimate.transforms ? about(range(oneTime)) : "none left"}</td>
|
|
253
|
+
</tr>
|
|
254
|
+
<tr>
|
|
255
|
+
<th scope="row" className="py-1.5 pr-3 text-left font-normal text-kumo-subtle">Payback</th>
|
|
256
|
+
<td className="py-1.5 font-medium">
|
|
257
|
+
{!estimate.transforms
|
|
258
|
+
? "—"
|
|
259
|
+
: traffic.withoutCost === 0
|
|
260
|
+
? "no savings: at this traffic, /s/ stays within the free 5,000 a month"
|
|
261
|
+
: payback(paybackMonths(oneTime.max, traffic.savings))}
|
|
262
|
+
</td>
|
|
263
|
+
</tr>
|
|
264
|
+
</tbody>
|
|
265
|
+
</table>
|
|
266
|
+
</div>
|
|
267
|
+
) : (
|
|
268
|
+
<p className="text-sm text-kumo-subtle">
|
|
269
|
+
{estimate.pages === 0
|
|
270
|
+
? "The traffic calculator needs published pages; the site has none yet."
|
|
271
|
+
: "The traffic calculator needs the number of published pages, which couldn't be counted."}
|
|
272
|
+
</p>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
<details className="text-xs text-kumo-subtle">
|
|
276
|
+
<summary className="cursor-pointer" style={{ display: "list-item" }}>
|
|
277
|
+
How this is estimated
|
|
278
|
+
</summary>
|
|
279
|
+
<div className="mt-2 space-y-1.5">
|
|
280
|
+
<p>
|
|
281
|
+
Counts come from the media library: each JPEG, PNG or WebP image up to 20 MB gets the widths 400, 640, 800, 1200 and 1600 that are smaller than
|
|
282
|
+
the original, each as WebP and AVIF (two transformations; one above 1,200 pixels, where Cloudflare stores WebP for both). File sizes are rough
|
|
283
|
+
averages per width.
|
|
284
|
+
</p>
|
|
285
|
+
<p>
|
|
286
|
+
Traffic: {estimate.pages ? count(estimate.pages) : "the"} published pages, with the images spread evenly over them. With V page views spread evenly
|
|
287
|
+
over P pages, about P × (1 − e^(−V/P)) different pages are seen in a month; real traffic favors some pages, so it's usually fewer. With search
|
|
288
|
+
engines crawling, at least 80% of pages are assumed to be fetched each month. Each image shown is asked for in about 3 sizes (phones, tablets,
|
|
289
|
+
desktops), fewer when it has fewer. Each page's Open Graph image stays a /s/ transformation either way. Monthly costs subtract the free 5,000.
|
|
290
|
+
</p>
|
|
291
|
+
</div>
|
|
292
|
+
</details>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Where a backfill run is, in words: working (and how fast), or paused (and until when). */
|
|
298
|
+
function progressText(state: NonNullable<VariantsStatus["state"]>, perHour: number | null | undefined): string {
|
|
299
|
+
const made = `${count(state.done)} made so far`;
|
|
300
|
+
if (state.pausedUntil && Date.parse(state.pausedUntil) > Date.now()) {
|
|
301
|
+
const at = new Date(state.pausedUntil).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
|
302
|
+
return `paused until about ${at} (${state.pauseReason ?? "Cloudflare Images refused work"}); it picks up on its own · ${made}`;
|
|
303
|
+
}
|
|
304
|
+
if (state.phase === "cleanup") return `finishing up · ${made}`;
|
|
305
|
+
return `working${perHour ? `, about ${count(perHour)} images an hour` : ""} · ${made}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Stored image sizes: how many images have them, the cost estimate, and the opt-in for existing images. */
|
|
309
|
+
function StoredSizes() {
|
|
310
|
+
const [status, setStatus] = React.useState<VariantsStatus | null>(null);
|
|
311
|
+
const [estimate, setEstimate] = React.useState<Estimate | null>(null);
|
|
312
|
+
const [running, setRunning] = React.useState(false);
|
|
313
|
+
const [confirming, setConfirming] = React.useState(false);
|
|
314
|
+
const [switching, setSwitching] = React.useState(false);
|
|
315
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
316
|
+
const stop = React.useRef(false);
|
|
317
|
+
|
|
318
|
+
const loadEstimate = async () => {
|
|
319
|
+
try {
|
|
320
|
+
setEstimate(await parseApiResponse<Estimate>(await apiFetch(`${API}/variants/estimate`), "Couldn't load the estimate"));
|
|
321
|
+
} catch {
|
|
322
|
+
setEstimate(null);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
React.useEffect(() => {
|
|
327
|
+
void (async () => {
|
|
328
|
+
try {
|
|
329
|
+
const next = await parseApiResponse<VariantsStatus>(await apiFetch(`${API}/variants/settings`), "Couldn't load the stored sizes");
|
|
330
|
+
setStatus(next);
|
|
331
|
+
if (next.available) await loadEstimate();
|
|
332
|
+
// A run under way (started here earlier, by the hourly job or an import): show its progress.
|
|
333
|
+
if (next.bulk && next.state && next.state.phase !== "done" && !next.state.pausedUntil) void watch();
|
|
334
|
+
} catch (cause) {
|
|
335
|
+
setError(errorText(cause, "Couldn't load the stored sizes"));
|
|
336
|
+
}
|
|
337
|
+
})();
|
|
338
|
+
return () => {
|
|
339
|
+
stop.current = true;
|
|
340
|
+
};
|
|
341
|
+
}, []);
|
|
342
|
+
|
|
343
|
+
/** While a run is under way, read its progress every few seconds (the server works on its own; this only watches). */
|
|
344
|
+
const watch = async () => {
|
|
345
|
+
setRunning(true);
|
|
346
|
+
stop.current = false;
|
|
347
|
+
try {
|
|
348
|
+
for (let i = 0; i < 2000 && !stop.current; i++) {
|
|
349
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
350
|
+
if (stop.current) break;
|
|
351
|
+
const next = await parseApiResponse<VariantsStatus>(await apiFetch(`${API}/variants/settings`), "Couldn't load the stored sizes");
|
|
352
|
+
if (stop.current) break;
|
|
353
|
+
setStatus(next);
|
|
354
|
+
if (!next.bulk || !next.state || next.state.phase === "done" || next.state.pausedUntil) break;
|
|
355
|
+
}
|
|
356
|
+
} catch (cause) {
|
|
357
|
+
setError(errorText(cause, "Couldn't load the stored sizes"));
|
|
358
|
+
} finally {
|
|
359
|
+
setRunning(false);
|
|
360
|
+
void loadEstimate();
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/** "Make missing sizes now": start a new run (its chain works in the background), then watch it. */
|
|
365
|
+
const run = async () => {
|
|
366
|
+
setError(null);
|
|
367
|
+
try {
|
|
368
|
+
setStatus(await post<VariantsStatus>("variants/run", { start: true }, "Couldn't make the sizes"));
|
|
369
|
+
} catch (cause) {
|
|
370
|
+
setError(errorText(cause, "Couldn't make the sizes"));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
void watch();
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const setBulk = async (on: boolean) => {
|
|
377
|
+
setSwitching(true);
|
|
378
|
+
setError(null);
|
|
379
|
+
if (!on) stop.current = true;
|
|
380
|
+
let turnedOn = false;
|
|
381
|
+
try {
|
|
382
|
+
setStatus(await post<VariantsStatus>("variants/bulk", { on }, "Couldn't change the setting"));
|
|
383
|
+
setConfirming(false);
|
|
384
|
+
turnedOn = on;
|
|
385
|
+
} catch (cause) {
|
|
386
|
+
setError(errorText(cause, "Couldn't change the setting"));
|
|
387
|
+
} finally {
|
|
388
|
+
setSwitching(false);
|
|
389
|
+
}
|
|
390
|
+
// The first run starts on the server, in the background: Turn off stays usable while it works.
|
|
391
|
+
if (turnedOn) void watch();
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const state = status?.state;
|
|
395
|
+
const oneTime = estimate ? oneTimeCost(estimate.transforms) : null;
|
|
396
|
+
return (
|
|
397
|
+
<SettingsSection
|
|
398
|
+
id="images-variants"
|
|
399
|
+
title="Stored image sizes"
|
|
400
|
+
description="Each image is stored once in a few widths (and the theme's crops) as AVIF and WebP on the media host, so pages load small, ready-made files instead of resizing on each visit."
|
|
401
|
+
actions={
|
|
402
|
+
status?.available && status.bulk ? (
|
|
403
|
+
<Button type="button" variant="secondary" icon={<ArrowsClockwise />} disabled={running || switching} onClick={() => void run()}>
|
|
404
|
+
{running ? "Making sizes…" : "Make missing sizes now"}
|
|
405
|
+
</Button>
|
|
406
|
+
) : undefined
|
|
407
|
+
}
|
|
408
|
+
>
|
|
409
|
+
{error && <Banner variant="error" role="alert" description={error} />}
|
|
410
|
+
{!status && !error && <Loader />}
|
|
411
|
+
{status && !status.available && <p className="text-sm text-kumo-subtle">{status.reason}</p>}
|
|
412
|
+
{status?.available && (
|
|
413
|
+
<p className="text-sm" aria-live="polite">
|
|
414
|
+
<span className="font-medium">
|
|
415
|
+
{count(status.stored)} of {count(status.total)} images have stored sizes
|
|
416
|
+
</span>
|
|
417
|
+
{status.skipped ? ` · ${count(status.skipped)} skipped (no known width or over 20 MB)` : ""}
|
|
418
|
+
{status.bulk && state?.failed ? ` · ${count(state.failed)} failed in the last run (tried again later)` : ""}
|
|
419
|
+
{status.bulk && state && state.phase !== "done" ? ` · ${progressText(state, status.perHour)}` : ""}
|
|
420
|
+
</p>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{status?.available && (
|
|
424
|
+
<div className="space-y-2 rounded-md border border-kumo-line p-3">
|
|
425
|
+
{status.bulk ? (
|
|
426
|
+
<>
|
|
427
|
+
<p className="text-sm">
|
|
428
|
+
<span className="font-medium">Sizes for existing images: on.</span> Missing sizes are made in the background, batch after batch, until
|
|
429
|
+
every image has them (also right after a WordPress media import; an hourly job picks up anything left). When a run is done, cached pages
|
|
430
|
+
are cleared once so they use the new sizes. Turning it off stops that; sizes already made stay in use.
|
|
431
|
+
</p>
|
|
432
|
+
<Button type="button" variant="secondary" disabled={switching} onClick={() => void setBulk(false)}>
|
|
433
|
+
{switching ? "Turning off…" : "Turn off"}
|
|
434
|
+
</Button>
|
|
435
|
+
</>
|
|
436
|
+
) : confirming ? (
|
|
437
|
+
<>
|
|
438
|
+
<p className="text-sm font-medium">
|
|
439
|
+
{estimate && oneTime
|
|
440
|
+
? estimate.transforms
|
|
441
|
+
? `Make sizes for ${count(estimate.images - estimate.done)} existing images? It's a one-time fee of ${about(range(oneTime))} (${count(estimate.transforms)} transformations).`
|
|
442
|
+
: "Every image already has its sizes; turning this on keeps it that way for imports."
|
|
443
|
+
: "Make sizes for existing images? It's a one-time fee for Cloudflare transformations (the estimate couldn't be loaded)."}
|
|
444
|
+
</p>
|
|
445
|
+
<p className="text-xs text-kumo-subtle">
|
|
446
|
+
On Cloudflare's Free plan, transformations beyond 5,000 a month aren't charged; they stop until the next month, and the job picks up where it
|
|
447
|
+
left off.
|
|
448
|
+
</p>
|
|
449
|
+
<div className="flex flex-wrap gap-2">
|
|
450
|
+
<Button type="button" variant="primary" disabled={switching} onClick={() => void setBulk(true)}>
|
|
451
|
+
{switching ? "Turning on…" : "Turn on and start"}
|
|
452
|
+
</Button>
|
|
453
|
+
<Button type="button" variant="secondary" disabled={switching} onClick={() => setConfirming(false)}>
|
|
454
|
+
Cancel
|
|
455
|
+
</Button>
|
|
456
|
+
</div>
|
|
457
|
+
</>
|
|
458
|
+
) : (
|
|
459
|
+
<>
|
|
460
|
+
<p className="text-sm">
|
|
461
|
+
<span className="font-medium">Sizes for existing images: off.</span> New uploads always get their sizes. Images that were already in the
|
|
462
|
+
library (including WordPress imports) keep using the media host's /s/ resizing until you turn this on.
|
|
463
|
+
</p>
|
|
464
|
+
<Button type="button" variant="secondary" onClick={() => setConfirming(true)}>
|
|
465
|
+
Make sizes for existing images…
|
|
466
|
+
</Button>
|
|
467
|
+
</>
|
|
468
|
+
)}
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
|
|
472
|
+
{status?.available && estimate && <CostEstimate estimate={estimate} />}
|
|
473
|
+
|
|
474
|
+
{status && (
|
|
475
|
+
<p className="text-xs text-kumo-subtle">
|
|
476
|
+
Widths: 400, 640, 800, 1200 and 1600 pixels (only those smaller than the original).{" "}
|
|
477
|
+
{status.crops.length ? `Crops, made when first shown: ${status.crops.join(", ")}.` : "Crops: none (set images.crops in astro.config.mjs)."}
|
|
478
|
+
</p>
|
|
479
|
+
)}
|
|
480
|
+
{status && <p className="text-xs text-kumo-subtle">{status.fallbackNote}</p>}
|
|
481
|
+
</SettingsSection>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function ImagesPage() {
|
|
486
|
+
const [saved, setSaved] = React.useState<Settings | null>(null);
|
|
487
|
+
const [draft, setDraft] = React.useState<Draft>({ host: "", accountId: "", bucket: "", token: "" });
|
|
488
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
489
|
+
const [notice, setNotice] = React.useState("");
|
|
490
|
+
const [pending, setPending] = React.useState<"save" | "clear" | "check" | "plan" | "apply" | undefined>();
|
|
491
|
+
const [check, setCheck] = React.useState<CheckResult | null>(null);
|
|
492
|
+
const [plan, setPlan] = React.useState<Plan | null>(null);
|
|
493
|
+
const [applied, setApplied] = React.useState<Applied | null>(null);
|
|
494
|
+
// A working media host hides the host field and the setup form until asked for.
|
|
495
|
+
const [hostOpen, setHostOpen] = React.useState(false);
|
|
496
|
+
const [setupOpen, setSetupOpen] = React.useState(false);
|
|
497
|
+
// The check on page load runs on its own, outside `pending`: it must not lock the form (it can take a while
|
|
498
|
+
// when the host is down), only the Check button, so two checks never race.
|
|
499
|
+
const [autoChecking, setAutoChecking] = React.useState(false);
|
|
500
|
+
const autoChecked = React.useRef(false);
|
|
501
|
+
|
|
502
|
+
const apply = (s: Settings) => {
|
|
503
|
+
setSaved(s);
|
|
504
|
+
setDraft(toDraft(s));
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
React.useEffect(() => {
|
|
508
|
+
void (async () => {
|
|
509
|
+
try {
|
|
510
|
+
apply(await parseApiResponse<Settings>(await apiFetch(`${API}/settings`), "Couldn't load the settings"));
|
|
511
|
+
} catch (cause) {
|
|
512
|
+
setError(errorText(cause, "Couldn't load the settings"));
|
|
513
|
+
}
|
|
514
|
+
})();
|
|
515
|
+
}, []);
|
|
516
|
+
|
|
517
|
+
const set = (patch: Partial<Draft>) => {
|
|
518
|
+
setDraft((d) => ({ ...d, ...patch }));
|
|
519
|
+
// A changed host, account or bucket makes the reviewed plan stale.
|
|
520
|
+
if ("host" in patch || "accountId" in patch || "bucket" in patch) setPlan(null);
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const save = async (clearToken = false) => {
|
|
524
|
+
setPending(clearToken ? "clear" : "save");
|
|
525
|
+
setError(null);
|
|
526
|
+
setNotice("");
|
|
527
|
+
try {
|
|
528
|
+
const { token, ...rest } = draft;
|
|
529
|
+
apply(await post<Settings>("settings/save", { ...rest, ...(clearToken ? { clearToken: true } : token.trim() ? { token: token.trim() } : {}) }, "Couldn't save"));
|
|
530
|
+
setNotice(clearToken ? "API token removed." : "Saved. The site picks up a new media host within a minute.");
|
|
531
|
+
} catch (cause) {
|
|
532
|
+
setError(errorText(cause, "Couldn't save"));
|
|
533
|
+
} finally {
|
|
534
|
+
setPending(undefined);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const hostToUse = draft.host.trim() || saved?.activeHost || "";
|
|
539
|
+
const suggestedHost = saved?.siteHost ? `media.${saved.siteHost}` : "media.example.com";
|
|
540
|
+
const setupHost = draft.host.trim() || saved?.activeHost || suggestedHost;
|
|
541
|
+
|
|
542
|
+
const runCheck = async (quiet = false) => {
|
|
543
|
+
if (quiet) setAutoChecking(true);
|
|
544
|
+
else {
|
|
545
|
+
setPending("check");
|
|
546
|
+
setError(null);
|
|
547
|
+
}
|
|
548
|
+
setCheck(null);
|
|
549
|
+
try {
|
|
550
|
+
// The server picks a recent image from the media library. The loading itself is checked here, in the
|
|
551
|
+
// browser: requests from the site's own Worker to the media host skip the zone's rewrite rules and can
|
|
552
|
+
// be challenged by Bot Fight Mode, so only a visitor's view is meaningful.
|
|
553
|
+
const result = await post<CheckResult>("check", { host: hostToUse }, "Couldn't check the media host");
|
|
554
|
+
setCheck(await browserCheck(result));
|
|
555
|
+
} catch (cause) {
|
|
556
|
+
// The check on page load stays quiet: the setup form simply shows, as when the host doesn't work.
|
|
557
|
+
if (!quiet) setError(errorText(cause, "Couldn't check the media host"));
|
|
558
|
+
} finally {
|
|
559
|
+
if (quiet) setAutoChecking(false);
|
|
560
|
+
else setPending(undefined);
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const setupBody = () => ({
|
|
565
|
+
host: setupHost,
|
|
566
|
+
...(draft.accountId.trim() ? { accountId: draft.accountId.trim() } : {}),
|
|
567
|
+
...(draft.bucket.trim() ? { bucket: draft.bucket.trim() } : {}),
|
|
568
|
+
...(draft.token.trim() ? { token: draft.token.trim() } : {}),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const review = async () => {
|
|
572
|
+
setPending("plan");
|
|
573
|
+
setError(null);
|
|
574
|
+
setPlan(null);
|
|
575
|
+
setApplied(null);
|
|
576
|
+
try {
|
|
577
|
+
setPlan(await post<Plan>("setup/plan", setupBody(), "Couldn't read the Cloudflare setup"));
|
|
578
|
+
} catch (cause) {
|
|
579
|
+
setError(errorText(cause, "Couldn't read the Cloudflare setup"));
|
|
580
|
+
} finally {
|
|
581
|
+
setPending(undefined);
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const applySetup = async () => {
|
|
586
|
+
setPending("apply");
|
|
587
|
+
setError(null);
|
|
588
|
+
try {
|
|
589
|
+
const result = await post<Applied>("setup/apply", setupBody(), "Couldn't set up the media host");
|
|
590
|
+
setApplied(result);
|
|
591
|
+
setPlan(null);
|
|
592
|
+
if (result.check) setCheck(result.check);
|
|
593
|
+
// Fill in the host so saving starts using it.
|
|
594
|
+
if (result.results.every((r) => r.ok) && !draft.host.trim() && !saved?.activeHost) set({ host: `https://${setupHost.replace(/^https?:\/\//, "")}` });
|
|
595
|
+
} catch (cause) {
|
|
596
|
+
setError(errorText(cause, "Couldn't set up the media host"));
|
|
597
|
+
} finally {
|
|
598
|
+
setPending(undefined);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Check the media host once on load, so a working setup can be shown as done.
|
|
603
|
+
React.useEffect(() => {
|
|
604
|
+
if (!saved?.activeHost || autoChecked.current) return;
|
|
605
|
+
autoChecked.current = true;
|
|
606
|
+
void runCheck(true);
|
|
607
|
+
}, [saved?.activeHost]);
|
|
608
|
+
|
|
609
|
+
const bare = (host: string) => host.replace(/^https?:\/\//, "").replace(/\/+$/, "").toLowerCase();
|
|
610
|
+
const hostWorks = Boolean(saved?.activeHost && check?.ok && bare(check.host) === bare(saved.activeHost));
|
|
611
|
+
// Only the check on load collapses the setup form to "Checking…"; a Check the user asks for leaves the page as it is.
|
|
612
|
+
const firstCheck = autoChecking && !setupOpen;
|
|
613
|
+
const showHostInput = !saved?.activeHost || hostOpen || draft.host !== (saved?.host ?? "");
|
|
614
|
+
const setupDone = (hostWorks || firstCheck) && !setupOpen && !plan && !applied;
|
|
615
|
+
|
|
616
|
+
const dirty = saved ? isDirty(draft, toDraft(saved)) : false;
|
|
617
|
+
const busy = Boolean(pending);
|
|
618
|
+
const checking = pending === "check" || autoChecking;
|
|
619
|
+
const changes = plan?.plan.steps.filter((s) => s.action !== "none").length ?? 0;
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<div className="space-y-6">
|
|
623
|
+
<header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
|
|
624
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
|
|
625
|
+
<h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Clean Image URLs</h1>
|
|
626
|
+
<div />
|
|
627
|
+
<p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
|
|
628
|
+
Resized images at short, cacheable addresses. With a media host, images are served and resized by Cloudflare at
|
|
629
|
+
addresses like media.example.com/s/600x315/<file> and never touch the site's Worker. Without one, they're
|
|
630
|
+
served at /media/<id>-600x315.webp.
|
|
631
|
+
</p>
|
|
632
|
+
</div>
|
|
633
|
+
</header>
|
|
634
|
+
|
|
635
|
+
{error && <Banner variant="error" role="alert" description={error} />}
|
|
636
|
+
<p className="sr-only" role="status" aria-live="polite">
|
|
637
|
+
{notice}
|
|
638
|
+
</p>
|
|
639
|
+
{notice && !busy && <p className="text-sm text-kumo-subtle">{notice}</p>}
|
|
640
|
+
|
|
641
|
+
{!saved && !error && (
|
|
642
|
+
<div className="flex justify-center py-12">
|
|
643
|
+
<Loader />
|
|
644
|
+
</div>
|
|
645
|
+
)}
|
|
646
|
+
|
|
647
|
+
{saved && (
|
|
648
|
+
<form
|
|
649
|
+
id="cw-images-form"
|
|
650
|
+
className="space-y-6"
|
|
651
|
+
onSubmit={(e) => {
|
|
652
|
+
e.preventDefault();
|
|
653
|
+
void save();
|
|
654
|
+
}}
|
|
655
|
+
>
|
|
656
|
+
<SettingsSection
|
|
657
|
+
id="images-status"
|
|
658
|
+
title="Media host"
|
|
659
|
+
description={
|
|
660
|
+
saved.activeHost ? (
|
|
661
|
+
<>
|
|
662
|
+
Images use <span className="font-medium">{saved.activeHost}</span>
|
|
663
|
+
{saved.host ? " (saved here)" : " (from astro.config.mjs)"}. Old /media/ addresses redirect there.
|
|
664
|
+
</>
|
|
665
|
+
) : (
|
|
666
|
+
"None yet: images use the site's /media/ addresses, resized by the Worker."
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
actions={
|
|
670
|
+
<>
|
|
671
|
+
{!showHostInput && (
|
|
672
|
+
<Button type="button" variant="secondary" disabled={busy} onClick={() => setHostOpen(true)}>
|
|
673
|
+
Change host
|
|
674
|
+
</Button>
|
|
675
|
+
)}
|
|
676
|
+
<Button type="button" variant="secondary" icon={<MagnifyingGlass />} disabled={busy || checking || !hostToUse} onClick={() => void runCheck()}>
|
|
677
|
+
{checking ? "Checking…" : "Check"}
|
|
678
|
+
</Button>
|
|
679
|
+
</>
|
|
680
|
+
}
|
|
681
|
+
>
|
|
682
|
+
{showHostInput && (
|
|
683
|
+
<Input
|
|
684
|
+
label="Media host"
|
|
685
|
+
placeholder={`https://${suggestedHost}`}
|
|
686
|
+
description={
|
|
687
|
+
saved.optionHost
|
|
688
|
+
? `Leave empty to use the one in astro.config.mjs (${saved.optionHost}). Check it before saving: images switch to it right away.`
|
|
689
|
+
: "An https address like https://media.example.com. Check it before saving: images switch to it right away."
|
|
690
|
+
}
|
|
691
|
+
value={draft.host}
|
|
692
|
+
disabled={busy}
|
|
693
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ host: e.target.value })}
|
|
694
|
+
/>
|
|
695
|
+
)}
|
|
696
|
+
{check && <CheckList result={check} />}
|
|
697
|
+
<p className="text-xs text-kumo-subtle">{saved.quotaNote}</p>
|
|
698
|
+
{!hostWorks && <CredentialGuide id="media-host" />}
|
|
699
|
+
</SettingsSection>
|
|
700
|
+
|
|
701
|
+
{setupDone ? (
|
|
702
|
+
<SettingsSection
|
|
703
|
+
id="images-setup"
|
|
704
|
+
title="Set up media host"
|
|
705
|
+
description={
|
|
706
|
+
firstCheck ? (
|
|
707
|
+
"Checking the media host…"
|
|
708
|
+
) : (
|
|
709
|
+
<>
|
|
710
|
+
Done: <span className="font-medium">{bare(saved.activeHost)}</span> serves the media bucket and resizes images (checked just
|
|
711
|
+
now). Run the setup again only to repair it or to set up a different host.
|
|
712
|
+
</>
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
actions={
|
|
716
|
+
<Button type="button" variant="secondary" icon={<Wrench />} disabled={busy} onClick={() => setSetupOpen(true)}>
|
|
717
|
+
Run setup again
|
|
718
|
+
</Button>
|
|
719
|
+
}
|
|
720
|
+
/>
|
|
721
|
+
) : (
|
|
722
|
+
<SettingsSection
|
|
723
|
+
id="images-setup"
|
|
724
|
+
title="Set up media host"
|
|
725
|
+
description={
|
|
726
|
+
<>
|
|
727
|
+
Optional: does the Cloudflare setup for you, for <span className="font-medium">{setupHost.replace(/^https?:\/\//, "")}</span>. It turns
|
|
728
|
+
on Image Transformations for the zone, connects the host to the media bucket, and adds the two URL rewrite rules,
|
|
729
|
+
skipping anything already done. You'll see the plan before anything changes.
|
|
730
|
+
</>
|
|
731
|
+
}
|
|
732
|
+
actions={
|
|
733
|
+
<Button type="button" variant="secondary" icon={<Wrench />} disabled={busy} onClick={() => void review()}>
|
|
734
|
+
{pending === "plan" ? "Reading…" : "Review setup"}
|
|
735
|
+
</Button>
|
|
736
|
+
}
|
|
737
|
+
>
|
|
738
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
739
|
+
<Input
|
|
740
|
+
label="Cloudflare account ID"
|
|
741
|
+
description={saved.envAccountId ? "CF_ACCOUNT_ID is set; it's used when this is empty." : undefined}
|
|
742
|
+
value={draft.accountId}
|
|
743
|
+
disabled={busy}
|
|
744
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ accountId: e.target.value })}
|
|
745
|
+
/>
|
|
746
|
+
<Input
|
|
747
|
+
label="R2 bucket name"
|
|
748
|
+
description="The bucket bound as MEDIA in wrangler.jsonc (its bucket_name)."
|
|
749
|
+
value={draft.bucket}
|
|
750
|
+
disabled={busy}
|
|
751
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ bucket: e.target.value })}
|
|
752
|
+
/>
|
|
753
|
+
<SecretField
|
|
754
|
+
label="Cloudflare API token"
|
|
755
|
+
saved={saved.tokenSet}
|
|
756
|
+
value={draft.token}
|
|
757
|
+
onChange={(value) => set({ token: value })}
|
|
758
|
+
description={
|
|
759
|
+
saved.envToken
|
|
760
|
+
? "A Worker secret (IMAGES_API_TOKEN or CLOUDFLARE_API_TOKEN) is set; it's used when no token is saved here."
|
|
761
|
+
: "Used only for this setup. Saving it is optional (stored encrypted)."
|
|
762
|
+
}
|
|
763
|
+
onClear={() => void save(true)}
|
|
764
|
+
clearing={pending === "clear"}
|
|
765
|
+
disabled={busy}
|
|
766
|
+
/>
|
|
767
|
+
</div>
|
|
768
|
+
<CredentialGuide id="images-token" />
|
|
769
|
+
|
|
770
|
+
{plan && (
|
|
771
|
+
<div className="space-y-3 rounded-md border border-kumo-line p-3" aria-live="polite">
|
|
772
|
+
<p className="text-sm font-medium">
|
|
773
|
+
{plan.plan.done ? `Everything is already set up on ${plan.zone}.` : `Plan for ${plan.zone} (${changes} change${changes === 1 ? "" : "s"}):`}
|
|
774
|
+
</p>
|
|
775
|
+
<ul className="space-y-1.5">
|
|
776
|
+
{plan.plan.steps.map((step) => (
|
|
777
|
+
<li key={step.id} className="flex items-start gap-2 text-sm">
|
|
778
|
+
{step.action === "none" ? <StatusIcon ok /> : <Wrench size={18} style={{ flexShrink: 0 }} aria-label="Will change" />}
|
|
779
|
+
<span className={step.action === "none" ? "text-kumo-subtle" : undefined}>{step.label}</span>
|
|
780
|
+
</li>
|
|
781
|
+
))}
|
|
782
|
+
</ul>
|
|
783
|
+
{!plan.plan.done && (
|
|
784
|
+
<div className="flex flex-wrap gap-2">
|
|
785
|
+
<Button type="button" variant="primary" disabled={busy} onClick={() => void applySetup()}>
|
|
786
|
+
{pending === "apply" ? "Setting up…" : "Apply"}
|
|
787
|
+
</Button>
|
|
788
|
+
<Button type="button" variant="secondary" disabled={busy} onClick={() => setPlan(null)}>
|
|
789
|
+
Cancel
|
|
790
|
+
</Button>
|
|
791
|
+
</div>
|
|
792
|
+
)}
|
|
793
|
+
</div>
|
|
794
|
+
)}
|
|
795
|
+
|
|
796
|
+
{applied && (
|
|
797
|
+
<div className="space-y-2" aria-live="polite">
|
|
798
|
+
<ul className="space-y-1.5">
|
|
799
|
+
{applied.results.map((r) => (
|
|
800
|
+
<li key={r.id} className="flex items-start gap-2 text-sm">
|
|
801
|
+
<StatusIcon ok={r.ok} />
|
|
802
|
+
<span>{r.message}</span>
|
|
803
|
+
</li>
|
|
804
|
+
))}
|
|
805
|
+
</ul>
|
|
806
|
+
{applied.results.every((r) => r.ok) && (
|
|
807
|
+
<p className="text-sm text-kumo-subtle">
|
|
808
|
+
{applied.check?.ok
|
|
809
|
+
? "The media host works. Save to start using it."
|
|
810
|
+
: "Done. A new custom domain can take a few minutes to get its certificate: select Check again shortly, then save."}
|
|
811
|
+
</p>
|
|
812
|
+
)}
|
|
813
|
+
</div>
|
|
814
|
+
)}
|
|
815
|
+
</SettingsSection>
|
|
816
|
+
)}
|
|
817
|
+
</form>
|
|
818
|
+
)}
|
|
819
|
+
|
|
820
|
+
{saved && <StoredSizes />}
|
|
821
|
+
|
|
822
|
+
{saved && <SaveBar form="cw-images-form" dirty={dirty} saving={pending === "save"} canSave={!busy} onDiscard={() => apply(saved)} />}
|
|
823
|
+
</div>
|
|
824
|
+
);
|
|
825
|
+
}
|