@coywolf/emdash 0.0.0-stage → 0.35.14
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 +203 -2
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +79 -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.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/routes.ts +359 -0
- package/src/ai/store.ts +301 -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.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -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.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.ts +60 -0
- package/src/files/export.ts +44 -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.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.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/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.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.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.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.ts +362 -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.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,1202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Videos admin page: the Cloudflare Stream library with plays, likes and
|
|
3
|
+
* where each video is used; editing, captions, uploads (straight from the
|
|
4
|
+
* browser to Stream), index rebuild and the Stream webhook.
|
|
5
|
+
*/
|
|
6
|
+
import { Badge, Banner, Button, Checkbox, Dialog, DropdownMenu, Input, InputArea, Loader, Select, Tabs } from "@cloudflare/kumo";
|
|
7
|
+
import {
|
|
8
|
+
ArrowClockwise,
|
|
9
|
+
ClosedCaptioning,
|
|
10
|
+
Copy,
|
|
11
|
+
DotsThree,
|
|
12
|
+
PencilSimple,
|
|
13
|
+
Plug,
|
|
14
|
+
Trash,
|
|
15
|
+
UploadSimple,
|
|
16
|
+
VideoCamera,
|
|
17
|
+
} from "@phosphor-icons/react";
|
|
18
|
+
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
19
|
+
import * as React from "react";
|
|
20
|
+
|
|
21
|
+
import type { VideoDisplay } from "../videos/render.js";
|
|
22
|
+
import { CredentialGuide } from "./guides.js";
|
|
23
|
+
import { DisplaySettings, fromDisplayDraft, toDisplayDraft } from "./videos-display.js";
|
|
24
|
+
import { SaveBar, isDirty } from "./save-bar.js";
|
|
25
|
+
import { SecretField, SettingsSection, SetupCard } from "./settings-ui.js";
|
|
26
|
+
import { PartSwitches } from "./feature-switches.js";
|
|
27
|
+
|
|
28
|
+
const API = "/_emdash/api/plugins/coywolf-pack/videos";
|
|
29
|
+
|
|
30
|
+
interface Usage {
|
|
31
|
+
title: string | null;
|
|
32
|
+
url: string | null;
|
|
33
|
+
collection: string;
|
|
34
|
+
status: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Video {
|
|
38
|
+
uid: string;
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
duration: number;
|
|
42
|
+
created: string | null;
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
state: string;
|
|
46
|
+
ready: boolean;
|
|
47
|
+
size: number;
|
|
48
|
+
allowedOrigins: string[];
|
|
49
|
+
thumbnail: string;
|
|
50
|
+
plays: number;
|
|
51
|
+
likes: number;
|
|
52
|
+
usedIn: Usage[];
|
|
53
|
+
captions: Array<{ language: string; label?: string }>;
|
|
54
|
+
tags?: string[];
|
|
55
|
+
creator?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Embedded videos that are no longer in Stream (deleted in its dashboard). */
|
|
59
|
+
interface Orphan {
|
|
60
|
+
uid: string;
|
|
61
|
+
usedIn: Usage[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface StorageUsage {
|
|
65
|
+
videos: number;
|
|
66
|
+
minutes: number;
|
|
67
|
+
limit: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Filter = "all" | "plays" | "likes" | "used" | "unused";
|
|
71
|
+
|
|
72
|
+
interface Status {
|
|
73
|
+
configured: boolean;
|
|
74
|
+
host: string | null;
|
|
75
|
+
features: Record<string, boolean>;
|
|
76
|
+
webhook: { subscribed: boolean; url: string };
|
|
77
|
+
sitemapUrl: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface VideosSettings {
|
|
81
|
+
accountId: string;
|
|
82
|
+
tokenSet: boolean;
|
|
83
|
+
customerSubdomain: string;
|
|
84
|
+
accentColor: string;
|
|
85
|
+
backgroundColor: string;
|
|
86
|
+
lightEmbed: boolean;
|
|
87
|
+
display: VideoDisplay;
|
|
88
|
+
defaultTags: string;
|
|
89
|
+
envAccountId: boolean;
|
|
90
|
+
envToken: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface Meta {
|
|
94
|
+
uid: string;
|
|
95
|
+
name?: string;
|
|
96
|
+
description?: string;
|
|
97
|
+
posterTime?: number;
|
|
98
|
+
posterImage?: string;
|
|
99
|
+
allowedOrigins?: string[];
|
|
100
|
+
downloadUrl?: string;
|
|
101
|
+
downloadStatus?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface Caption {
|
|
105
|
+
language: string;
|
|
106
|
+
label?: string;
|
|
107
|
+
generated?: boolean;
|
|
108
|
+
status?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
|
|
112
|
+
const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium" });
|
|
113
|
+
const numberFormat = new Intl.NumberFormat("en-US");
|
|
114
|
+
|
|
115
|
+
function clock(seconds: number): string {
|
|
116
|
+
if (!seconds) return "";
|
|
117
|
+
const total = Math.round(seconds);
|
|
118
|
+
const h = Math.floor(total / 3600);
|
|
119
|
+
const m = Math.floor((total % 3600) / 60);
|
|
120
|
+
const s = String(total % 60).padStart(2, "0");
|
|
121
|
+
return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function post<T>(path: string, body: unknown = {}): Promise<T> {
|
|
125
|
+
const response = await apiFetch(`${API}/${path}`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "Content-Type": "application/json" },
|
|
128
|
+
body: JSON.stringify(body),
|
|
129
|
+
});
|
|
130
|
+
return parseApiResponse<T>(response, "The request failed");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Edit ─────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function EditDialog(props: { video: Video | null; onClose: () => void; onSaved: (message: string) => void }) {
|
|
136
|
+
const [meta, setMeta] = React.useState<Meta>();
|
|
137
|
+
const [download, setDownload] = React.useState<{ status?: string; url?: string; percent?: number } | null>(null);
|
|
138
|
+
const [name, setName] = React.useState("");
|
|
139
|
+
const [description, setDescription] = React.useState("");
|
|
140
|
+
const [posterTime, setPosterTime] = React.useState("");
|
|
141
|
+
const [posterImage, setPosterImage] = React.useState("");
|
|
142
|
+
const [origins, setOrigins] = React.useState("");
|
|
143
|
+
const [tags, setTags] = React.useState("");
|
|
144
|
+
const [creator, setCreator] = React.useState("");
|
|
145
|
+
const [downloads, setDownloads] = React.useState(false);
|
|
146
|
+
const [pending, setPending] = React.useState(false);
|
|
147
|
+
const [error, setError] = React.useState<string>();
|
|
148
|
+
|
|
149
|
+
React.useEffect(() => {
|
|
150
|
+
const video = props.video;
|
|
151
|
+
setMeta(undefined);
|
|
152
|
+
setError(undefined);
|
|
153
|
+
if (!video) return;
|
|
154
|
+
setName(video.name);
|
|
155
|
+
setDescription(video.description);
|
|
156
|
+
setOrigins(video.allowedOrigins.join(", "));
|
|
157
|
+
setTags((video.tags ?? []).join(", "));
|
|
158
|
+
setCreator(video.creator ?? "");
|
|
159
|
+
post<{ meta: Meta | null; download: typeof download }>("detail", { uid: video.uid })
|
|
160
|
+
.then((d) => {
|
|
161
|
+
const m = d.meta ?? { uid: video.uid };
|
|
162
|
+
setMeta(m);
|
|
163
|
+
setDownload(d.download);
|
|
164
|
+
setDescription(m.description ?? "");
|
|
165
|
+
setPosterTime(m.posterTime !== undefined ? String(m.posterTime) : "");
|
|
166
|
+
setPosterImage(m.posterImage ?? "");
|
|
167
|
+
setOrigins((m.allowedOrigins ?? video.allowedOrigins).join(", "));
|
|
168
|
+
setDownloads(Boolean(d.download));
|
|
169
|
+
})
|
|
170
|
+
.catch((cause) => setError(errorText(cause, "Could not load the video")));
|
|
171
|
+
}, [props.video]);
|
|
172
|
+
|
|
173
|
+
const save = async () => {
|
|
174
|
+
if (!props.video) return;
|
|
175
|
+
setPending(true);
|
|
176
|
+
setError(undefined);
|
|
177
|
+
try {
|
|
178
|
+
const time = posterTime.trim() === "" ? null : Number(posterTime);
|
|
179
|
+
if (time !== null && (!Number.isFinite(time) || time < 0)) throw new Error("The poster frame must be a number of seconds.");
|
|
180
|
+
await post("update", {
|
|
181
|
+
uid: props.video.uid,
|
|
182
|
+
name,
|
|
183
|
+
description,
|
|
184
|
+
posterTime: time,
|
|
185
|
+
posterImage: posterImage.trim() || null,
|
|
186
|
+
allowedOrigins: origins
|
|
187
|
+
.split(/[\s,]+/)
|
|
188
|
+
.map((o) => o.trim())
|
|
189
|
+
.filter(Boolean),
|
|
190
|
+
tags,
|
|
191
|
+
creator: creator.trim(),
|
|
192
|
+
...(downloads !== Boolean(download) ? { downloads } : {}),
|
|
193
|
+
});
|
|
194
|
+
props.onSaved(`Saved ${name || props.video.uid}.`);
|
|
195
|
+
} catch (cause) {
|
|
196
|
+
setError(errorText(cause, "Could not save the video"));
|
|
197
|
+
} finally {
|
|
198
|
+
setPending(false);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<Dialog.Root open={props.video !== null} onOpenChange={(open) => !open && !pending && props.onClose()}>
|
|
204
|
+
<Dialog className="p-6" size="lg">
|
|
205
|
+
<Dialog.Title className="text-lg font-semibold">Edit video</Dialog.Title>
|
|
206
|
+
<Dialog.Description className="mt-1 text-sm text-kumo-subtle">
|
|
207
|
+
The name is saved to Cloudflare Stream. The description and poster are used by blocks that don't set their own,
|
|
208
|
+
and by schema and the video sitemap.
|
|
209
|
+
</Dialog.Description>
|
|
210
|
+
{!meta && !error ? (
|
|
211
|
+
<div className="py-8 text-center">
|
|
212
|
+
<Loader />
|
|
213
|
+
</div>
|
|
214
|
+
) : (
|
|
215
|
+
<form
|
|
216
|
+
className="mt-4 space-y-4"
|
|
217
|
+
onSubmit={(e) => {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
void save();
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
<Input label="Name" value={name} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)} required />
|
|
223
|
+
<InputArea
|
|
224
|
+
label="Description"
|
|
225
|
+
rows={4}
|
|
226
|
+
value={description}
|
|
227
|
+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
|
|
228
|
+
/>
|
|
229
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
230
|
+
<Input
|
|
231
|
+
label="Poster frame (seconds)"
|
|
232
|
+
inputMode="decimal"
|
|
233
|
+
placeholder="0"
|
|
234
|
+
value={posterTime}
|
|
235
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPosterTime(e.target.value)}
|
|
236
|
+
/>
|
|
237
|
+
<Input
|
|
238
|
+
label="Poster image URL (optional)"
|
|
239
|
+
placeholder="https://…"
|
|
240
|
+
value={posterImage}
|
|
241
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPosterImage(e.target.value)}
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
245
|
+
<Input
|
|
246
|
+
label="Tags"
|
|
247
|
+
placeholder="tutorial, product"
|
|
248
|
+
description="Comma-separated. Search the library with #tag."
|
|
249
|
+
value={tags}
|
|
250
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTags(e.target.value)}
|
|
251
|
+
/>
|
|
252
|
+
<Input
|
|
253
|
+
label="Creator (optional)"
|
|
254
|
+
description="Stream's creator ID, such as a user or team, for your own records."
|
|
255
|
+
value={creator}
|
|
256
|
+
maxLength={64}
|
|
257
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCreator(e.target.value)}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
<Input
|
|
261
|
+
label="Allowed origins (empty allows any site)"
|
|
262
|
+
placeholder="example.com, *.example.com"
|
|
263
|
+
value={origins}
|
|
264
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setOrigins(e.target.value)}
|
|
265
|
+
/>
|
|
266
|
+
<Checkbox
|
|
267
|
+
label="Downloadable MP4 (used as the schema contentUrl)"
|
|
268
|
+
checked={downloads}
|
|
269
|
+
onCheckedChange={(checked: boolean) => setDownloads(checked)}
|
|
270
|
+
/>
|
|
271
|
+
{download && (
|
|
272
|
+
<p className="text-sm text-kumo-subtle" aria-live="polite">
|
|
273
|
+
MP4: {download.status === "ready" ? "ready" : `${download.status ?? "pending"}${download.percent ? ` (${Math.round(download.percent)}%)` : ""}`}
|
|
274
|
+
</p>
|
|
275
|
+
)}
|
|
276
|
+
{error && <Banner variant="error" role="alert" description={error} />}
|
|
277
|
+
<div className="flex justify-end gap-2">
|
|
278
|
+
<Button type="button" variant="secondary" disabled={pending} onClick={props.onClose}>
|
|
279
|
+
Cancel
|
|
280
|
+
</Button>
|
|
281
|
+
<Button type="submit" variant="primary" disabled={pending}>
|
|
282
|
+
{pending ? "Saving…" : "Save"}
|
|
283
|
+
</Button>
|
|
284
|
+
</div>
|
|
285
|
+
</form>
|
|
286
|
+
)}
|
|
287
|
+
{error && !meta && <Banner variant="error" role="alert" className="mt-4" description={error} />}
|
|
288
|
+
</Dialog>
|
|
289
|
+
</Dialog.Root>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Captions ─────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
function CaptionsDialog(props: { video: Video | null; onClose: () => void }) {
|
|
296
|
+
const [captions, setCaptions] = React.useState<Caption[]>();
|
|
297
|
+
const [lang, setLang] = React.useState("en");
|
|
298
|
+
const [file, setFile] = React.useState<File | null>(null);
|
|
299
|
+
const [busy, setBusy] = React.useState<string>();
|
|
300
|
+
const [error, setError] = React.useState<string>();
|
|
301
|
+
const uid = props.video?.uid;
|
|
302
|
+
|
|
303
|
+
React.useEffect(() => {
|
|
304
|
+
setCaptions(undefined);
|
|
305
|
+
setError(undefined);
|
|
306
|
+
if (!uid) return;
|
|
307
|
+
post<{ captions: Caption[] }>("captions/list", { uid })
|
|
308
|
+
.then((d) => setCaptions(d.captions))
|
|
309
|
+
.catch((cause) => setError(errorText(cause, "Could not load captions")));
|
|
310
|
+
}, [uid]);
|
|
311
|
+
|
|
312
|
+
const run = async (label: string, path: string, body: Record<string, unknown>) => {
|
|
313
|
+
if (!uid) return;
|
|
314
|
+
setBusy(label);
|
|
315
|
+
setError(undefined);
|
|
316
|
+
try {
|
|
317
|
+
const d = await post<{ captions?: Caption[] }>(path, { uid, ...body });
|
|
318
|
+
if (d.captions) setCaptions(d.captions);
|
|
319
|
+
} catch (cause) {
|
|
320
|
+
setError(errorText(cause, `${label} failed`));
|
|
321
|
+
} finally {
|
|
322
|
+
setBusy(undefined);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const upload = async () => {
|
|
327
|
+
if (!file) return;
|
|
328
|
+
if (file.size > 1_500_000) {
|
|
329
|
+
setError("Caption files must be under 1.5 MB.");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
await run("Upload", "captions/upload", { lang: lang.trim(), vtt: await file.text() });
|
|
333
|
+
setFile(null);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<Dialog.Root open={props.video !== null} onOpenChange={(open) => !open && !busy && props.onClose()}>
|
|
338
|
+
<Dialog className="p-6" size="lg">
|
|
339
|
+
<Dialog.Title className="text-lg font-semibold">Captions{props.video ? `: ${props.video.name}` : ""}</Dialog.Title>
|
|
340
|
+
<Dialog.Description className="mt-1 text-sm text-kumo-subtle">
|
|
341
|
+
Upload a WebVTT file or have Stream generate captions from the audio. Ready tracks are copied to the site for
|
|
342
|
+
the caption files and transcript in schema.
|
|
343
|
+
</Dialog.Description>
|
|
344
|
+
<div className="mt-4 space-y-4">
|
|
345
|
+
{!captions && !error ? (
|
|
346
|
+
<div className="py-6 text-center">
|
|
347
|
+
<Loader />
|
|
348
|
+
</div>
|
|
349
|
+
) : captions && captions.length === 0 ? (
|
|
350
|
+
<p className="text-sm text-kumo-subtle">No captions yet.</p>
|
|
351
|
+
) : (
|
|
352
|
+
<ul className="divide-y rounded-lg border">
|
|
353
|
+
{(captions ?? []).map((c) => (
|
|
354
|
+
<li key={c.language} className="flex items-center gap-3 px-3 py-2 text-sm">
|
|
355
|
+
<span className="font-mono">{c.language}</span>
|
|
356
|
+
<span className="flex-1 truncate">{c.label}</span>
|
|
357
|
+
{c.generated && <Badge variant="outline">generated</Badge>}
|
|
358
|
+
<Badge variant={c.status === "ready" ? "secondary" : "outline"}>{c.status ?? "ready"}</Badge>
|
|
359
|
+
<Button
|
|
360
|
+
variant="ghost"
|
|
361
|
+
shape="square"
|
|
362
|
+
icon={<Trash aria-hidden="true" />}
|
|
363
|
+
aria-label={`Delete ${c.language} captions`}
|
|
364
|
+
disabled={Boolean(busy)}
|
|
365
|
+
onClick={() => {
|
|
366
|
+
if (window.confirm(`Delete the ${c.language} captions?`)) void run("Delete", "captions/delete", { lang: c.language });
|
|
367
|
+
}}
|
|
368
|
+
/>
|
|
369
|
+
</li>
|
|
370
|
+
))}
|
|
371
|
+
</ul>
|
|
372
|
+
)}
|
|
373
|
+
<div className="grid gap-3 cw-sm-cols-8-fill sm:items-end">
|
|
374
|
+
<Input label="Language" value={lang} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLang(e.target.value)} />
|
|
375
|
+
<div>
|
|
376
|
+
<label className="mb-1.5 block text-sm font-medium" htmlFor="cw-vtt-file">
|
|
377
|
+
WebVTT file
|
|
378
|
+
</label>
|
|
379
|
+
<input
|
|
380
|
+
id="cw-vtt-file"
|
|
381
|
+
type="file"
|
|
382
|
+
accept=".vtt,text/vtt"
|
|
383
|
+
className="block w-full text-sm"
|
|
384
|
+
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
<div className="flex flex-wrap justify-end gap-2">
|
|
389
|
+
<Button variant="secondary" disabled={Boolean(busy)} onClick={() => void run("Refresh", "captions/refresh", {}).then(() => run("Reload", "captions/list", {}))}>
|
|
390
|
+
Copy to site
|
|
391
|
+
</Button>
|
|
392
|
+
<Button variant="secondary" disabled={Boolean(busy) || !lang.trim()} onClick={() => void run("Generate", "captions/generate", { lang: lang.trim() })}>
|
|
393
|
+
Generate {lang.trim() || "…"}
|
|
394
|
+
</Button>
|
|
395
|
+
<Button variant="primary" icon={<UploadSimple />} disabled={Boolean(busy) || !file || !lang.trim()} onClick={() => void upload()}>
|
|
396
|
+
Upload
|
|
397
|
+
</Button>
|
|
398
|
+
</div>
|
|
399
|
+
<p className="text-sm text-kumo-subtle" aria-live="polite">
|
|
400
|
+
{busy ? `${busy}…` : ""}
|
|
401
|
+
</p>
|
|
402
|
+
{error && <Banner variant="error" role="alert" description={error} />}
|
|
403
|
+
</div>
|
|
404
|
+
</Dialog>
|
|
405
|
+
</Dialog.Root>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Upload ───────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Sends the file through the site in pieces (videos/upload-chunk), which forwards each to
|
|
413
|
+
* Stream. EmDash's admin Content-Security-Policy (connect-src 'self') blocks the browser
|
|
414
|
+
* from uploading to Stream directly. A piece that fails on the network is retried.
|
|
415
|
+
*/
|
|
416
|
+
async function uploadThroughSite(uid: string, chunkBytes: number, file: File, onProgress: (pct: number) => void): Promise<void> {
|
|
417
|
+
let offset = 0;
|
|
418
|
+
while (offset < file.size) {
|
|
419
|
+
const piece = file.slice(offset, offset + chunkBytes);
|
|
420
|
+
let response: Response | null = null;
|
|
421
|
+
for (let attempt = 1; ; attempt++) {
|
|
422
|
+
try {
|
|
423
|
+
response = await apiFetch(`${API}/upload-chunk`, {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: { "Content-Type": "application/octet-stream", "x-cw-upload-uid": uid, "x-cw-upload-offset": String(offset) },
|
|
426
|
+
body: piece,
|
|
427
|
+
});
|
|
428
|
+
break;
|
|
429
|
+
} catch (cause) {
|
|
430
|
+
if (attempt >= 3) throw new Error("The upload was interrupted. Check your connection and try again.", { cause });
|
|
431
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const result = await parseApiResponse<{ offset: number; done: boolean }>(response, "Stream refused part of the upload");
|
|
435
|
+
offset = result.offset;
|
|
436
|
+
onProgress((offset / file.size) * 100);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function UploadDialog(props: { open: boolean; onClose: () => void; onUploaded: (name: string) => void }) {
|
|
441
|
+
const [file, setFile] = React.useState<File | null>(null);
|
|
442
|
+
const [name, setName] = React.useState("");
|
|
443
|
+
const [tags, setTags] = React.useState("");
|
|
444
|
+
const [progress, setProgress] = React.useState<number | null>(null);
|
|
445
|
+
const [error, setError] = React.useState<string>();
|
|
446
|
+
|
|
447
|
+
const start = async () => {
|
|
448
|
+
if (!file) return;
|
|
449
|
+
setError(undefined);
|
|
450
|
+
setProgress(0);
|
|
451
|
+
try {
|
|
452
|
+
// Stream turns any file into a "video" (a still image becomes a one-frame clip).
|
|
453
|
+
if (!file.type.startsWith("video/")) throw new Error("Choose a video file (MP4, MOV, WebM…), not an image or other file.");
|
|
454
|
+
const target = await post<{ uid: string; chunkBytes: number }>("upload", {
|
|
455
|
+
name: name.trim() || file.name,
|
|
456
|
+
size: file.size,
|
|
457
|
+
type: file.type,
|
|
458
|
+
...(tags.trim() ? { tags } : {}),
|
|
459
|
+
});
|
|
460
|
+
await uploadThroughSite(target.uid, target.chunkBytes, file, setProgress);
|
|
461
|
+
const label = name.trim() || file.name;
|
|
462
|
+
setFile(null);
|
|
463
|
+
setName("");
|
|
464
|
+
setProgress(null);
|
|
465
|
+
props.onUploaded(label);
|
|
466
|
+
} catch (cause) {
|
|
467
|
+
setError(errorText(cause, "Upload failed"));
|
|
468
|
+
setProgress(null);
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
const uploading = progress !== null;
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
<Dialog.Root open={props.open} onOpenChange={(open) => !open && !uploading && props.onClose()}>
|
|
475
|
+
<Dialog className="p-6" size="lg">
|
|
476
|
+
<Dialog.Title className="text-lg font-semibold">Upload a video</Dialog.Title>
|
|
477
|
+
<Dialog.Description className="mt-1 text-sm text-kumo-subtle">
|
|
478
|
+
The file goes to Cloudflare Stream through this site, a few megabytes at a time. Keep this dialog open until the upload finishes;
|
|
479
|
+
Stream then processes the video for a few minutes.
|
|
480
|
+
</Dialog.Description>
|
|
481
|
+
<div className="mt-4 space-y-4">
|
|
482
|
+
<div>
|
|
483
|
+
<label className="mb-1.5 block text-sm font-medium" htmlFor="cw-video-file">
|
|
484
|
+
Video file
|
|
485
|
+
</label>
|
|
486
|
+
<input
|
|
487
|
+
id="cw-video-file"
|
|
488
|
+
type="file"
|
|
489
|
+
accept="video/*"
|
|
490
|
+
className="block w-full text-sm"
|
|
491
|
+
disabled={uploading}
|
|
492
|
+
onChange={(e) => {
|
|
493
|
+
const f = e.target.files?.[0] ?? null;
|
|
494
|
+
setFile(f);
|
|
495
|
+
if (f && !name) setName(f.name.replace(/\.[^.]+$/, ""));
|
|
496
|
+
}}
|
|
497
|
+
/>
|
|
498
|
+
</div>
|
|
499
|
+
<Input label="Name" value={name} disabled={uploading} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)} />
|
|
500
|
+
<Input
|
|
501
|
+
label="Tags (optional)"
|
|
502
|
+
placeholder="tutorial, product"
|
|
503
|
+
description="Comma-separated. Empty uses the default tags from Settings → Uploads."
|
|
504
|
+
value={tags}
|
|
505
|
+
disabled={uploading}
|
|
506
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTags(e.target.value)}
|
|
507
|
+
/>
|
|
508
|
+
{uploading && (
|
|
509
|
+
<div>
|
|
510
|
+
<progress className="w-full" max={100} value={progress ?? 0} aria-label="Upload progress" />
|
|
511
|
+
</div>
|
|
512
|
+
)}
|
|
513
|
+
<p className="text-sm text-kumo-subtle" aria-live="polite">
|
|
514
|
+
{uploading ? `Uploading… ${Math.round(progress ?? 0)}%` : ""}
|
|
515
|
+
</p>
|
|
516
|
+
{error && <Banner variant="error" role="alert" description={error} />}
|
|
517
|
+
<div className="flex justify-end gap-2">
|
|
518
|
+
<Button variant="secondary" disabled={uploading} onClick={props.onClose}>
|
|
519
|
+
Cancel
|
|
520
|
+
</Button>
|
|
521
|
+
<Button variant="primary" icon={<UploadSimple />} disabled={uploading || !file} onClick={() => void start()}>
|
|
522
|
+
Upload
|
|
523
|
+
</Button>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
</Dialog>
|
|
527
|
+
</Dialog.Root>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── Delete ───────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
function DeleteDialog(props: { video: Video | null; onClose: () => void; onDeleted: (message: string) => void }) {
|
|
534
|
+
const [pending, setPending] = React.useState(false);
|
|
535
|
+
const [error, setError] = React.useState<string>();
|
|
536
|
+
const video = props.video;
|
|
537
|
+
React.useEffect(() => setError(undefined), [video]);
|
|
538
|
+
const used = video?.usedIn ?? [];
|
|
539
|
+
const remove = async () => {
|
|
540
|
+
if (!video) return;
|
|
541
|
+
setPending(true);
|
|
542
|
+
setError(undefined);
|
|
543
|
+
try {
|
|
544
|
+
await post("delete", { uid: video.uid, force: used.length > 0 });
|
|
545
|
+
props.onDeleted(`Deleted ${video.name}.`);
|
|
546
|
+
} catch (cause) {
|
|
547
|
+
setError(errorText(cause, "Could not delete the video"));
|
|
548
|
+
} finally {
|
|
549
|
+
setPending(false);
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
return (
|
|
553
|
+
<Dialog.Root open={video !== null} onOpenChange={(open) => !open && !pending && props.onClose()}>
|
|
554
|
+
<Dialog className="p-6" size="lg">
|
|
555
|
+
<Dialog.Title className="text-lg font-semibold">Delete {video?.name ?? "video"}?</Dialog.Title>
|
|
556
|
+
<Dialog.Description className="mt-1 text-sm text-kumo-subtle">
|
|
557
|
+
This permanently deletes the video from Cloudflare Stream, with its captions and download, and removes its plays, likes and details from
|
|
558
|
+
this site. It can't be undone.
|
|
559
|
+
</Dialog.Description>
|
|
560
|
+
{used.length > 0 && (
|
|
561
|
+
<Banner
|
|
562
|
+
className="mt-4"
|
|
563
|
+
variant="error"
|
|
564
|
+
title={`Used in ${used.length} ${used.length === 1 ? "entry" : "entries"}`}
|
|
565
|
+
description={
|
|
566
|
+
<>
|
|
567
|
+
These will show nothing where the video was until you remove its block:
|
|
568
|
+
<ul className="mt-1 list-disc ps-5">
|
|
569
|
+
{used.slice(0, 10).map((u, i) => (
|
|
570
|
+
<li key={i}>
|
|
571
|
+
{u.title ?? "(untitled)"} ({u.collection}, {u.status})
|
|
572
|
+
</li>
|
|
573
|
+
))}
|
|
574
|
+
{used.length > 10 && <li>and {used.length - 10} more</li>}
|
|
575
|
+
</ul>
|
|
576
|
+
</>
|
|
577
|
+
}
|
|
578
|
+
/>
|
|
579
|
+
)}
|
|
580
|
+
{error && <Banner variant="error" role="alert" className="mt-4" description={error} />}
|
|
581
|
+
<div className="mt-6 flex justify-end gap-2">
|
|
582
|
+
<Button variant="secondary" disabled={pending} onClick={props.onClose}>
|
|
583
|
+
Cancel
|
|
584
|
+
</Button>
|
|
585
|
+
<Button variant="destructive" icon={<Trash />} disabled={pending} onClick={() => void remove()}>
|
|
586
|
+
{pending ? "Deleting…" : used.length ? "Delete anyway" : "Delete"}
|
|
587
|
+
</Button>
|
|
588
|
+
</div>
|
|
589
|
+
</Dialog>
|
|
590
|
+
</Dialog.Root>
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── Settings ─────────────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
async function loadSettings(): Promise<VideosSettings> {
|
|
597
|
+
return parseApiResponse<VideosSettings>(await apiFetch(`${API}/settings`), "Could not load the Videos settings");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Account ID and token: the setup card before Stream is connected. */
|
|
601
|
+
function ConnectForm(props: { onConnected: () => void }) {
|
|
602
|
+
const [settings, setSettings] = React.useState<VideosSettings>();
|
|
603
|
+
const [accountId, setAccountId] = React.useState("");
|
|
604
|
+
const [token, setToken] = React.useState("");
|
|
605
|
+
const [pending, setPending] = React.useState(false);
|
|
606
|
+
const [error, setError] = React.useState<string>();
|
|
607
|
+
React.useEffect(() => {
|
|
608
|
+
loadSettings()
|
|
609
|
+
.then((s) => {
|
|
610
|
+
setSettings(s);
|
|
611
|
+
setAccountId(s.accountId);
|
|
612
|
+
})
|
|
613
|
+
.catch((cause) => setError(errorText(cause, "Could not load the Videos settings")));
|
|
614
|
+
}, []);
|
|
615
|
+
const save = async () => {
|
|
616
|
+
setPending(true);
|
|
617
|
+
setError(undefined);
|
|
618
|
+
try {
|
|
619
|
+
await post("settings/save", { accountId: accountId.trim(), ...(token.trim() ? { token: token.trim() } : {}) });
|
|
620
|
+
setToken("");
|
|
621
|
+
props.onConnected();
|
|
622
|
+
} catch (cause) {
|
|
623
|
+
setError(errorText(cause, "Could not save"));
|
|
624
|
+
} finally {
|
|
625
|
+
setPending(false);
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
return (
|
|
629
|
+
<form
|
|
630
|
+
className="space-y-3"
|
|
631
|
+
onSubmit={(e) => {
|
|
632
|
+
e.preventDefault();
|
|
633
|
+
void save();
|
|
634
|
+
}}
|
|
635
|
+
>
|
|
636
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
637
|
+
<Input
|
|
638
|
+
label="Cloudflare account ID"
|
|
639
|
+
description={settings?.envAccountId ? "Empty uses the CF_ACCOUNT_ID Worker variable." : "Dashboard → any domain → Account ID (32 characters)."}
|
|
640
|
+
value={accountId}
|
|
641
|
+
disabled={pending}
|
|
642
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAccountId(e.target.value)}
|
|
643
|
+
/>
|
|
644
|
+
<SecretField
|
|
645
|
+
label="Stream API token"
|
|
646
|
+
saved={settings?.tokenSet ?? false}
|
|
647
|
+
value={token}
|
|
648
|
+
onChange={setToken}
|
|
649
|
+
description={settings?.envToken ? "Empty uses the CF_STREAM_TOKEN Worker secret." : "An API token with Account → Stream → Edit. Stored encrypted."}
|
|
650
|
+
disabled={pending}
|
|
651
|
+
/>
|
|
652
|
+
</div>
|
|
653
|
+
<CredentialGuide id="stream" />
|
|
654
|
+
{error && <Banner variant="error" role="alert" description={error} />}
|
|
655
|
+
<Button type="submit" variant="primary" disabled={pending || (!accountId.trim() && !token.trim())}>
|
|
656
|
+
{pending ? "Connecting…" : "Connect"}
|
|
657
|
+
</Button>
|
|
658
|
+
</form>
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function SettingsPanel(props: { onSaved: (message: string) => void }) {
|
|
663
|
+
const [saved, setSaved] = React.useState<VideosSettings>();
|
|
664
|
+
const toDraft = (s: VideosSettings) => ({
|
|
665
|
+
accountId: s.accountId,
|
|
666
|
+
token: "",
|
|
667
|
+
customerSubdomain: s.customerSubdomain,
|
|
668
|
+
accentColor: s.accentColor,
|
|
669
|
+
backgroundColor: s.backgroundColor,
|
|
670
|
+
lightEmbed: s.lightEmbed,
|
|
671
|
+
display: toDisplayDraft(s.display),
|
|
672
|
+
defaultTags: s.defaultTags,
|
|
673
|
+
});
|
|
674
|
+
const [draft, setDraft] = React.useState<ReturnType<typeof toDraft>>();
|
|
675
|
+
const [pending, setPending] = React.useState<"save" | "clear">();
|
|
676
|
+
const [error, setError] = React.useState<string>();
|
|
677
|
+
const apply = (s: VideosSettings) => {
|
|
678
|
+
setSaved(s);
|
|
679
|
+
setDraft(toDraft(s));
|
|
680
|
+
};
|
|
681
|
+
React.useEffect(() => {
|
|
682
|
+
loadSettings()
|
|
683
|
+
.then(apply)
|
|
684
|
+
.catch((cause) => setError(errorText(cause, "Could not load the Videos settings")));
|
|
685
|
+
}, []);
|
|
686
|
+
const set = (patch: Partial<NonNullable<typeof draft>>) => setDraft((d) => (d ? { ...d, ...patch } : d));
|
|
687
|
+
const save = async (clearToken = false) => {
|
|
688
|
+
if (!draft) return;
|
|
689
|
+
setPending(clearToken ? "clear" : "save");
|
|
690
|
+
setError(undefined);
|
|
691
|
+
try {
|
|
692
|
+
const { token, display, ...rest } = draft;
|
|
693
|
+
apply(
|
|
694
|
+
await post<VideosSettings>("settings/save", {
|
|
695
|
+
...rest,
|
|
696
|
+
display: fromDisplayDraft(display),
|
|
697
|
+
...(clearToken ? { clearToken: true } : token.trim() ? { token: token.trim() } : {}),
|
|
698
|
+
}),
|
|
699
|
+
);
|
|
700
|
+
props.onSaved(clearToken ? "Stream API token removed." : "Videos settings saved.");
|
|
701
|
+
} catch (cause) {
|
|
702
|
+
setError(errorText(cause, "Could not save the settings"));
|
|
703
|
+
} finally {
|
|
704
|
+
setPending(undefined);
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
if (!saved || !draft)
|
|
708
|
+
return error ? (
|
|
709
|
+
<Banner variant="error" role="alert" title="Could not load the settings" description={error} />
|
|
710
|
+
) : (
|
|
711
|
+
<div className="py-12 text-center">
|
|
712
|
+
<Loader />
|
|
713
|
+
</div>
|
|
714
|
+
);
|
|
715
|
+
const text = (key: "accountId" | "customerSubdomain" | "accentColor" | "defaultTags") => ({
|
|
716
|
+
value: draft[key],
|
|
717
|
+
disabled: Boolean(pending),
|
|
718
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => set({ [key]: e.target.value }),
|
|
719
|
+
});
|
|
720
|
+
const dirty = isDirty(draft, toDraft(saved));
|
|
721
|
+
return (
|
|
722
|
+
<>
|
|
723
|
+
<form
|
|
724
|
+
id="cw-videos-settings-form"
|
|
725
|
+
className="space-y-6"
|
|
726
|
+
onSubmit={(e) => {
|
|
727
|
+
e.preventDefault();
|
|
728
|
+
void save();
|
|
729
|
+
}}
|
|
730
|
+
>
|
|
731
|
+
<SettingsSection
|
|
732
|
+
id="videos-connection"
|
|
733
|
+
title="Cloudflare Stream connection"
|
|
734
|
+
description="The account that holds your Stream library and an API token with Account → Stream → Edit. Empty fields fall back to the CF_ACCOUNT_ID and CF_STREAM_TOKEN Worker variables."
|
|
735
|
+
>
|
|
736
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
737
|
+
<Input label="Cloudflare account ID" description={saved.envAccountId ? "CF_ACCOUNT_ID is set; it's used when this is empty." : undefined} {...text("accountId")} />
|
|
738
|
+
<SecretField
|
|
739
|
+
label="Stream API token"
|
|
740
|
+
saved={saved.tokenSet}
|
|
741
|
+
value={draft.token}
|
|
742
|
+
onChange={(value) => set({ token: value })}
|
|
743
|
+
description={saved.envToken ? "CF_STREAM_TOKEN is set; it's used when no token is saved here." : "Stored encrypted."}
|
|
744
|
+
onClear={() => void save(true)}
|
|
745
|
+
clearing={pending === "clear"}
|
|
746
|
+
disabled={Boolean(pending)}
|
|
747
|
+
/>
|
|
748
|
+
</div>
|
|
749
|
+
<CredentialGuide id="stream" />
|
|
750
|
+
</SettingsSection>
|
|
751
|
+
<SettingsSection id="videos-player" title="Player" description="How the Coywolf Video block's player looks and where it loads from.">
|
|
752
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
753
|
+
<Input
|
|
754
|
+
label="Stream customer subdomain"
|
|
755
|
+
placeholder="customer-abc123.cloudflarestream.com"
|
|
756
|
+
description="Stream → any video → Embed. Learned from the library automatically when empty."
|
|
757
|
+
{...text("customerSubdomain")}
|
|
758
|
+
/>
|
|
759
|
+
<Input label="Accent color" placeholder="#f6821f" description="Stream's play button and progress bar. Empty uses Stream's default." {...text("accentColor")} />
|
|
760
|
+
</div>
|
|
761
|
+
<Checkbox
|
|
762
|
+
label="Load the player only when it's needed (faster pages)"
|
|
763
|
+
checked={draft.lightEmbed}
|
|
764
|
+
disabled={Boolean(pending)}
|
|
765
|
+
onCheckedChange={(checked: boolean) => set({ lightEmbed: checked })}
|
|
766
|
+
/>
|
|
767
|
+
<p className="text-sm leading-5 text-pretty text-kumo-subtle">
|
|
768
|
+
Pages show the video's poster, sized for the screen, and load Stream's player (about 350 KB) when someone presses play.
|
|
769
|
+
Autoplaying videos start on the visitor's first scroll or tap once they're on screen. On mobile, a video near the top of a
|
|
770
|
+
page otherwise loads with it and can hold back the page's first paint by several seconds. Turn this off to load the
|
|
771
|
+
player with the page.
|
|
772
|
+
</p>
|
|
773
|
+
</SettingsSection>
|
|
774
|
+
<DisplaySettings
|
|
775
|
+
draft={draft.display}
|
|
776
|
+
background={draft.backgroundColor}
|
|
777
|
+
disabled={Boolean(pending)}
|
|
778
|
+
onChange={(patch) => set({ display: { ...draft.display, ...patch } })}
|
|
779
|
+
onBackground={(value) => set({ backgroundColor: value })}
|
|
780
|
+
/>
|
|
781
|
+
<SettingsSection id="videos-uploads" title="Uploads">
|
|
782
|
+
<Input
|
|
783
|
+
label="Default tags"
|
|
784
|
+
placeholder="tutorial, product"
|
|
785
|
+
description="Added to every video you upload here (you can change them in the upload dialog). Comma-separated; tags are kept in Stream's video metadata, like Video Manager's."
|
|
786
|
+
{...text("defaultTags")}
|
|
787
|
+
/>
|
|
788
|
+
</SettingsSection>
|
|
789
|
+
{error && <Banner variant="error" role="alert" description={error} />}
|
|
790
|
+
</form>
|
|
791
|
+
<SaveBar
|
|
792
|
+
form="cw-videos-settings-form"
|
|
793
|
+
dirty={dirty}
|
|
794
|
+
saving={pending === "save"}
|
|
795
|
+
canSave={!pending}
|
|
796
|
+
onDiscard={() => apply(saved)}
|
|
797
|
+
/>
|
|
798
|
+
</>
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ── Page ─────────────────────────────────────────────────────────
|
|
803
|
+
|
|
804
|
+
export function VideosPage() {
|
|
805
|
+
const [status, setStatus] = React.useState<Status>();
|
|
806
|
+
const [videos, setVideos] = React.useState<Video[]>();
|
|
807
|
+
const [error, setError] = React.useState<string>();
|
|
808
|
+
const [notice, setNotice] = React.useState<string>();
|
|
809
|
+
const [query, setQuery] = React.useState("");
|
|
810
|
+
const [editing, setEditing] = React.useState<Video | null>(null);
|
|
811
|
+
const [captioning, setCaptioning] = React.useState<Video | null>(null);
|
|
812
|
+
const [deleting, setDeleting] = React.useState<Video | null>(null);
|
|
813
|
+
const [filter, setFilter] = React.useState<Filter>("all");
|
|
814
|
+
const [orphans, setOrphans] = React.useState<Orphan[]>([]);
|
|
815
|
+
const [storage, setStorage] = React.useState<StorageUsage | null>(null);
|
|
816
|
+
const [uploading, setUploading] = React.useState(false);
|
|
817
|
+
const [busy, setBusy] = React.useState<string>();
|
|
818
|
+
const [tab, setTab] = React.useState("library");
|
|
819
|
+
|
|
820
|
+
const load = React.useCallback(async (refresh = false) => {
|
|
821
|
+
setError(undefined);
|
|
822
|
+
try {
|
|
823
|
+
const s = await parseApiResponse<Status>(await apiFetch(`${API}/status`), "Could not load the Videos status");
|
|
824
|
+
setStatus(s);
|
|
825
|
+
if (!s.configured) {
|
|
826
|
+
setVideos([]);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const data = await post<{ items: Video[]; orphans?: Orphan[] }>("list", { refresh });
|
|
830
|
+
setVideos(data.items);
|
|
831
|
+
setOrphans(data.orphans ?? []);
|
|
832
|
+
// Optional: a token without storage access still lists the library.
|
|
833
|
+
post<StorageUsage>("storage")
|
|
834
|
+
.then(setStorage)
|
|
835
|
+
.catch(() => setStorage(null));
|
|
836
|
+
} catch (cause) {
|
|
837
|
+
setError(errorText(cause, "Could not load videos"));
|
|
838
|
+
}
|
|
839
|
+
}, []);
|
|
840
|
+
React.useEffect(() => {
|
|
841
|
+
void load();
|
|
842
|
+
}, [load]);
|
|
843
|
+
|
|
844
|
+
const action = async (label: string, fn: () => Promise<string>) => {
|
|
845
|
+
setBusy(label);
|
|
846
|
+
setError(undefined);
|
|
847
|
+
setNotice(undefined);
|
|
848
|
+
try {
|
|
849
|
+
setNotice(await fn());
|
|
850
|
+
} catch (cause) {
|
|
851
|
+
setError(errorText(cause, `${label} failed`));
|
|
852
|
+
} finally {
|
|
853
|
+
setBusy(undefined);
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
const test = () => action("Test connection", async () => (await post<{ message: string }>("test")).message);
|
|
858
|
+
|
|
859
|
+
const reindex = () =>
|
|
860
|
+
action("Rebuild index", async () => {
|
|
861
|
+
let state: unknown = null;
|
|
862
|
+
let found = 0;
|
|
863
|
+
let scanned = 0;
|
|
864
|
+
let removed = 0;
|
|
865
|
+
for (let i = 0; i < 500; i++) {
|
|
866
|
+
const r = await post<{ done: boolean; state: unknown; found: number; scanned: number; removed: number; progress: string }>("reindex", { state });
|
|
867
|
+
removed += r.removed;
|
|
868
|
+
found += r.found;
|
|
869
|
+
scanned += r.scanned;
|
|
870
|
+
setBusy(`Rebuilding the index (${r.progress})`);
|
|
871
|
+
if (r.done) break;
|
|
872
|
+
state = r.state;
|
|
873
|
+
}
|
|
874
|
+
await load();
|
|
875
|
+
return `Scanned ${numberFormat.format(scanned)} entries and found ${numberFormat.format(found)} embedded ${found === 1 ? "video" : "videos"}${removed ? `, and removed ${numberFormat.format(removed)} deleted ${removed === 1 ? "entry" : "entries"} from the index` : ""}.`;
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
const webhook = (subscribe: boolean) =>
|
|
879
|
+
action(subscribe ? "Subscribe" : "Unsubscribe", async () => {
|
|
880
|
+
await post(subscribe ? "webhook/subscribe" : "webhook/unsubscribe");
|
|
881
|
+
await load();
|
|
882
|
+
return subscribe ? "Stream will notify this site when videos finish processing." : "Stream webhook removed.";
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// "#tag" words filter by tag (all must match); the rest searches names, descriptions and IDs.
|
|
886
|
+
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
887
|
+
const tagWords = words.filter((w) => w.startsWith("#") && w.length > 1).map((w) => w.slice(1));
|
|
888
|
+
const q = words.filter((w) => !w.startsWith("#")).join(" ");
|
|
889
|
+
const matchesFilter = (v: Video) =>
|
|
890
|
+
filter === "plays" ? v.plays > 0 : filter === "likes" ? v.likes > 0 : filter === "used" ? v.usedIn.length > 0 : filter === "unused" ? v.usedIn.length === 0 : true;
|
|
891
|
+
const visible = (videos ?? []).filter((v) => {
|
|
892
|
+
const tags = (v.tags ?? []).map((t) => t.toLowerCase());
|
|
893
|
+
if (!tagWords.every((t) => tags.includes(t))) return false;
|
|
894
|
+
if (q && !(v.name.toLowerCase().includes(q) || v.uid.includes(q) || v.description.toLowerCase().includes(q))) return false;
|
|
895
|
+
return matchesFilter(v);
|
|
896
|
+
});
|
|
897
|
+
const f = status?.features ?? {};
|
|
898
|
+
|
|
899
|
+
return (
|
|
900
|
+
<div className="space-y-6">
|
|
901
|
+
<header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
|
|
902
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
|
|
903
|
+
<h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Videos</h1>
|
|
904
|
+
<div className="flex shrink-0 justify-end gap-2">
|
|
905
|
+
{tab === "library" && status?.configured && (
|
|
906
|
+
<>
|
|
907
|
+
<Button variant="secondary" icon={<ArrowClockwise />} disabled={Boolean(busy)} onClick={() => void load(true)}>
|
|
908
|
+
Refresh
|
|
909
|
+
</Button>
|
|
910
|
+
<Button variant="primary" icon={<UploadSimple />} onClick={() => setUploading(true)}>
|
|
911
|
+
Upload
|
|
912
|
+
</Button>
|
|
913
|
+
</>
|
|
914
|
+
)}
|
|
915
|
+
</div>
|
|
916
|
+
<p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
|
|
917
|
+
Your Cloudflare Stream library. Add videos to posts with the <strong>Coywolf Video</strong> block; plays,
|
|
918
|
+
likes and the posts each video appears in show here.
|
|
919
|
+
</p>
|
|
920
|
+
</div>
|
|
921
|
+
{tab === "library" && status?.configured && (
|
|
922
|
+
<div className="flex flex-wrap items-end gap-4">
|
|
923
|
+
<div className="w-full sm:w-72">
|
|
924
|
+
<Input
|
|
925
|
+
label="Search"
|
|
926
|
+
placeholder="Name, description, video ID or #tag"
|
|
927
|
+
value={query}
|
|
928
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
|
929
|
+
/>
|
|
930
|
+
</div>
|
|
931
|
+
<div className="w-full sm:w-48">
|
|
932
|
+
<Select
|
|
933
|
+
label="Show"
|
|
934
|
+
value={filter}
|
|
935
|
+
onValueChange={(value: string | null) => setFilter((value as Filter | null) ?? "all")}
|
|
936
|
+
items={[
|
|
937
|
+
{ value: "all", label: "All videos" },
|
|
938
|
+
...(f.engagement
|
|
939
|
+
? [
|
|
940
|
+
{ value: "plays", label: "With plays" },
|
|
941
|
+
{ value: "likes", label: "With likes" },
|
|
942
|
+
]
|
|
943
|
+
: []),
|
|
944
|
+
{ value: "used", label: "Used in content" },
|
|
945
|
+
{ value: "unused", label: "Not used" },
|
|
946
|
+
]}
|
|
947
|
+
/>
|
|
948
|
+
</div>
|
|
949
|
+
{storage && (
|
|
950
|
+
<p className="text-sm text-kumo-subtle sm:ms-auto">
|
|
951
|
+
{numberFormat.format(storage.videos)} {storage.videos === 1 ? "video" : "videos"} · {numberFormat.format(storage.minutes)}
|
|
952
|
+
{storage.limit ? ` of ${numberFormat.format(storage.limit)}` : ""} minutes stored
|
|
953
|
+
</p>
|
|
954
|
+
)}
|
|
955
|
+
</div>
|
|
956
|
+
)}
|
|
957
|
+
</header>
|
|
958
|
+
|
|
959
|
+
<PartSwitches module="videos" />
|
|
960
|
+
|
|
961
|
+
<div aria-live="polite">
|
|
962
|
+
{busy && <p className="text-sm text-kumo-subtle">{busy}…</p>}
|
|
963
|
+
{notice && <Banner variant="default" role="status" title={notice} />}
|
|
964
|
+
</div>
|
|
965
|
+
{error && <Banner variant="error" role="alert" title="Something went wrong" description={error} />}
|
|
966
|
+
{tab === "library" && orphans.length > 0 && (
|
|
967
|
+
<Banner
|
|
968
|
+
variant="error"
|
|
969
|
+
title={`${orphans.length} embedded ${orphans.length === 1 ? "video is" : "videos are"} no longer in Cloudflare Stream`}
|
|
970
|
+
description={
|
|
971
|
+
<>
|
|
972
|
+
Deleted in Stream's dashboard, so these entries show nothing where the video was. Remove the blocks (or upload the video again and
|
|
973
|
+
choose it in the block), then rebuild the embed index under Tools.
|
|
974
|
+
<ul className="mt-1 list-disc ps-5">
|
|
975
|
+
{orphans.slice(0, 10).map((o) => (
|
|
976
|
+
<li key={o.uid}>
|
|
977
|
+
<span className="font-mono">{o.uid}</span>: {o.usedIn.map((u) => u.title ?? "(untitled)").join(", ")}
|
|
978
|
+
</li>
|
|
979
|
+
))}
|
|
980
|
+
{orphans.length > 10 && <li>and {orphans.length - 10} more</li>}
|
|
981
|
+
</ul>
|
|
982
|
+
</>
|
|
983
|
+
}
|
|
984
|
+
/>
|
|
985
|
+
)}
|
|
986
|
+
|
|
987
|
+
{status && (
|
|
988
|
+
<Tabs
|
|
989
|
+
value={tab}
|
|
990
|
+
onValueChange={setTab}
|
|
991
|
+
tabs={[
|
|
992
|
+
{ value: "library", label: "Library" },
|
|
993
|
+
{ value: "settings", label: "Settings" },
|
|
994
|
+
]}
|
|
995
|
+
/>
|
|
996
|
+
)}
|
|
997
|
+
|
|
998
|
+
{tab === "settings" && (
|
|
999
|
+
<SettingsPanel
|
|
1000
|
+
onSaved={(message) => {
|
|
1001
|
+
setNotice(message);
|
|
1002
|
+
void load(true);
|
|
1003
|
+
}}
|
|
1004
|
+
/>
|
|
1005
|
+
)}
|
|
1006
|
+
|
|
1007
|
+
{tab === "library" && status && !status.configured && (
|
|
1008
|
+
<SetupCard
|
|
1009
|
+
title="Add a Stream API token to manage videos"
|
|
1010
|
+
description="Connect your Cloudflare Stream account to see the library, upload videos, manage captions and subscribe to the Stream webhook. Or set the CF_ACCOUNT_ID and CF_STREAM_TOKEN Worker variables and reload this page."
|
|
1011
|
+
>
|
|
1012
|
+
<ConnectForm
|
|
1013
|
+
onConnected={() => {
|
|
1014
|
+
setNotice("Connected to Cloudflare Stream.");
|
|
1015
|
+
void load(true);
|
|
1016
|
+
}}
|
|
1017
|
+
/>
|
|
1018
|
+
</SetupCard>
|
|
1019
|
+
)}
|
|
1020
|
+
|
|
1021
|
+
{tab !== "library" ? null : !videos && !error ? (
|
|
1022
|
+
<div className="py-12 text-center text-kumo-subtle">
|
|
1023
|
+
<Loader />
|
|
1024
|
+
</div>
|
|
1025
|
+
) : videos && status?.configured && videos.length === 0 ? (
|
|
1026
|
+
<div className="py-10 text-center text-kumo-subtle">
|
|
1027
|
+
<VideoCamera size={40} className="mx-auto mb-3 opacity-30" aria-hidden="true" />
|
|
1028
|
+
<p className="text-base font-medium">No videos yet</p>
|
|
1029
|
+
<p className="mt-1 text-sm">Upload one to get started.</p>
|
|
1030
|
+
</div>
|
|
1031
|
+
) : videos && videos.length > 0 ? (
|
|
1032
|
+
<div className="rounded-lg border">
|
|
1033
|
+
<div className="flex items-center gap-4 border-b bg-kumo-tint/50 px-4 py-2 text-sm font-medium text-kumo-subtle">
|
|
1034
|
+
<div className="w-24 shrink-0" />
|
|
1035
|
+
<div className="min-w-0 flex-1">Video</div>
|
|
1036
|
+
<div className="w-16 text-end">Length</div>
|
|
1037
|
+
<div className="w-32 cw-lg-block">Uploaded</div>
|
|
1038
|
+
{f.engagement && <div className="hidden w-16 text-end md:block">Plays</div>}
|
|
1039
|
+
{f.engagement && <div className="hidden w-14 text-end md:block">Likes</div>}
|
|
1040
|
+
<div className="w-16 text-end">Used in</div>
|
|
1041
|
+
<div className="w-10" />
|
|
1042
|
+
</div>
|
|
1043
|
+
{visible.map((v) => (
|
|
1044
|
+
<div key={v.uid} className="flex items-center gap-4 border-b px-4 py-2 text-sm last:border-0">
|
|
1045
|
+
<div className="w-24 shrink-0">
|
|
1046
|
+
<img src={v.thumbnail} alt="" loading="lazy" width={96} height={54} className="aspect-video w-24 rounded bg-kumo-tint object-cover" />
|
|
1047
|
+
</div>
|
|
1048
|
+
<div className="min-w-0 flex-1">
|
|
1049
|
+
<div className="truncate font-medium" title={v.name}>
|
|
1050
|
+
{v.name}
|
|
1051
|
+
</div>
|
|
1052
|
+
<div className="mt-0.5 flex items-center gap-1 truncate text-xs text-kumo-subtle">
|
|
1053
|
+
<span className="font-mono">{v.uid}</span>
|
|
1054
|
+
{!v.ready && <Badge variant="outline">{v.state}</Badge>}
|
|
1055
|
+
{v.captions.length > 0 && <Badge variant="outline">CC</Badge>}
|
|
1056
|
+
</div>
|
|
1057
|
+
{(v.tags ?? []).length > 0 && (
|
|
1058
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
1059
|
+
{(v.tags ?? []).map((t) => (
|
|
1060
|
+
<button
|
|
1061
|
+
key={t}
|
|
1062
|
+
type="button"
|
|
1063
|
+
className="rounded text-xs text-kumo-subtle underline-offset-2 hover:underline"
|
|
1064
|
+
aria-label={`Show videos tagged ${t}`}
|
|
1065
|
+
onClick={() => setQuery(`#${t}`)}
|
|
1066
|
+
>
|
|
1067
|
+
#{t}
|
|
1068
|
+
</button>
|
|
1069
|
+
))}
|
|
1070
|
+
</div>
|
|
1071
|
+
)}
|
|
1072
|
+
</div>
|
|
1073
|
+
<div className="w-16 text-end tabular-nums">{clock(v.duration)}</div>
|
|
1074
|
+
<div className="w-32 text-xs text-kumo-subtle cw-lg-block">{v.created ? dateFormat.format(new Date(v.created)) : ""}</div>
|
|
1075
|
+
{f.engagement && <div className="hidden w-16 text-end tabular-nums md:block">{numberFormat.format(v.plays)}</div>}
|
|
1076
|
+
{f.engagement && <div className="hidden w-14 text-end tabular-nums md:block">{numberFormat.format(v.likes)}</div>}
|
|
1077
|
+
<div
|
|
1078
|
+
className="w-16 text-end tabular-nums"
|
|
1079
|
+
title={v.usedIn.map((u) => `${u.title ?? "(untitled)"} (${u.collection}, ${u.status})`).join("\n") || "Not used"}
|
|
1080
|
+
>
|
|
1081
|
+
{v.usedIn.length}
|
|
1082
|
+
</div>
|
|
1083
|
+
<div className="flex w-10 justify-end">
|
|
1084
|
+
<DropdownMenu>
|
|
1085
|
+
<DropdownMenu.Trigger
|
|
1086
|
+
render={
|
|
1087
|
+
<Button type="button" variant="ghost" shape="square" icon={<DotsThree aria-hidden="true" />} aria-label={`Actions for ${v.name}`} />
|
|
1088
|
+
}
|
|
1089
|
+
/>
|
|
1090
|
+
<DropdownMenu.Content className="p-1">
|
|
1091
|
+
<DropdownMenu.Item
|
|
1092
|
+
className="py-1 data-highlighted:bg-kumo-fill"
|
|
1093
|
+
icon={<PencilSimple className="me-1.5 size-3.5" aria-hidden="true" />}
|
|
1094
|
+
onClick={() => setEditing(v)}
|
|
1095
|
+
>
|
|
1096
|
+
Edit
|
|
1097
|
+
</DropdownMenu.Item>
|
|
1098
|
+
{f.captions && (
|
|
1099
|
+
<DropdownMenu.Item
|
|
1100
|
+
className="py-1 data-highlighted:bg-kumo-fill"
|
|
1101
|
+
icon={<ClosedCaptioning className="me-1.5 size-3.5" aria-hidden="true" />}
|
|
1102
|
+
onClick={() => setCaptioning(v)}
|
|
1103
|
+
>
|
|
1104
|
+
Captions
|
|
1105
|
+
</DropdownMenu.Item>
|
|
1106
|
+
)}
|
|
1107
|
+
<DropdownMenu.Item
|
|
1108
|
+
className="py-1 data-highlighted:bg-kumo-fill"
|
|
1109
|
+
icon={<Copy className="me-1.5 size-3.5" aria-hidden="true" />}
|
|
1110
|
+
onClick={() => {
|
|
1111
|
+
void navigator.clipboard.writeText(v.uid);
|
|
1112
|
+
setNotice(`Copied the ID of ${v.name}.`);
|
|
1113
|
+
}}
|
|
1114
|
+
>
|
|
1115
|
+
Copy video ID
|
|
1116
|
+
</DropdownMenu.Item>
|
|
1117
|
+
<DropdownMenu.Item
|
|
1118
|
+
className="py-1 text-kumo-danger data-highlighted:bg-kumo-fill"
|
|
1119
|
+
icon={<Trash className="me-1.5 size-3.5" aria-hidden="true" />}
|
|
1120
|
+
onClick={() => setDeleting(v)}
|
|
1121
|
+
>
|
|
1122
|
+
Delete
|
|
1123
|
+
</DropdownMenu.Item>
|
|
1124
|
+
</DropdownMenu.Content>
|
|
1125
|
+
</DropdownMenu>
|
|
1126
|
+
</div>
|
|
1127
|
+
</div>
|
|
1128
|
+
))}
|
|
1129
|
+
{visible.length === 0 && <p className="px-4 py-6 text-center text-sm text-kumo-subtle">No videos match.</p>}
|
|
1130
|
+
</div>
|
|
1131
|
+
) : null}
|
|
1132
|
+
|
|
1133
|
+
{tab === "library" && status?.configured && (
|
|
1134
|
+
<section className="space-y-3 border-t border-kumo-line pt-4" aria-labelledby="cw-videos-tools">
|
|
1135
|
+
<h2 id="cw-videos-tools" className="text-base font-semibold">
|
|
1136
|
+
Tools
|
|
1137
|
+
</h2>
|
|
1138
|
+
<div className="flex flex-wrap gap-2">
|
|
1139
|
+
<Button variant="secondary" icon={<Plug />} disabled={Boolean(busy)} onClick={() => void test()}>
|
|
1140
|
+
Test connection
|
|
1141
|
+
</Button>
|
|
1142
|
+
<Button variant="secondary" disabled={Boolean(busy)} onClick={() => void reindex()}>
|
|
1143
|
+
Rebuild embed index
|
|
1144
|
+
</Button>
|
|
1145
|
+
{f.webhook &&
|
|
1146
|
+
(status.webhook.subscribed ? (
|
|
1147
|
+
<Button variant="secondary" disabled={Boolean(busy)} onClick={() => void webhook(false)}>
|
|
1148
|
+
Unsubscribe Stream webhook
|
|
1149
|
+
</Button>
|
|
1150
|
+
) : (
|
|
1151
|
+
<Button variant="secondary" disabled={Boolean(busy)} onClick={() => void webhook(true)}>
|
|
1152
|
+
Subscribe Stream webhook
|
|
1153
|
+
</Button>
|
|
1154
|
+
))}
|
|
1155
|
+
</div>
|
|
1156
|
+
<ul className="space-y-1 text-sm text-kumo-subtle">
|
|
1157
|
+
<li>Player host: {status.host ?? "not known yet (refresh the library, or set the customer subdomain under Settings)"}</li>
|
|
1158
|
+
{f.sitemap && (
|
|
1159
|
+
<li>
|
|
1160
|
+
Video sitemap:{" "}
|
|
1161
|
+
<a className="underline" href={status.sitemapUrl} target="_blank" rel="noreferrer">
|
|
1162
|
+
{status.sitemapUrl}
|
|
1163
|
+
</a>{" "}
|
|
1164
|
+
(add it to your robots.txt or Search Console)
|
|
1165
|
+
</li>
|
|
1166
|
+
)}
|
|
1167
|
+
{f.webhook && <li>Webhook: {status.webhook.subscribed ? `subscribed (${status.webhook.url})` : "not subscribed"}</li>}
|
|
1168
|
+
</ul>
|
|
1169
|
+
</section>
|
|
1170
|
+
)}
|
|
1171
|
+
|
|
1172
|
+
<EditDialog
|
|
1173
|
+
video={editing}
|
|
1174
|
+
onClose={() => setEditing(null)}
|
|
1175
|
+
onSaved={(message) => {
|
|
1176
|
+
setEditing(null);
|
|
1177
|
+
setNotice(message);
|
|
1178
|
+
void load(true);
|
|
1179
|
+
}}
|
|
1180
|
+
/>
|
|
1181
|
+
<CaptionsDialog video={captioning} onClose={() => setCaptioning(null)} />
|
|
1182
|
+
<DeleteDialog
|
|
1183
|
+
video={deleting}
|
|
1184
|
+
onClose={() => setDeleting(null)}
|
|
1185
|
+
onDeleted={(message) => {
|
|
1186
|
+
setDeleting(null);
|
|
1187
|
+
setNotice(message);
|
|
1188
|
+
void load(true);
|
|
1189
|
+
}}
|
|
1190
|
+
/>
|
|
1191
|
+
<UploadDialog
|
|
1192
|
+
open={uploading}
|
|
1193
|
+
onClose={() => setUploading(false)}
|
|
1194
|
+
onUploaded={(name) => {
|
|
1195
|
+
setUploading(false);
|
|
1196
|
+
setNotice(`Uploaded ${name}. Stream is processing it.`);
|
|
1197
|
+
void load(true);
|
|
1198
|
+
}}
|
|
1199
|
+
/>
|
|
1200
|
+
</div>
|
|
1201
|
+
);
|
|
1202
|
+
}
|