@rigel-cms/rigel 0.2.0-beta.1 → 1.0.0
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/README.md +16 -6
- package/package.json +3 -9
- package/src/article-body.ts +9 -2
- package/src/article-render.ts +27 -1
- package/src/config.ts +14 -1
- package/src/editor/App.svelte +9 -10
- package/src/editor/blocks/embed.ts +21 -0
- package/src/editor/blocks/index.ts +3 -1
- package/src/editor/blocks/video.ts +35 -0
- package/src/editor/lib/ActionDialog.svelte +2 -1
- package/src/editor/lib/BlockEditor.svelte +84 -15
- package/src/editor/lib/CollectionPanel.svelte +4 -3
- package/src/editor/lib/ImagePicker.svelte +141 -47
- package/src/editor/lib/MediaPicker.svelte +73 -0
- package/src/editor/lib/Select.svelte +5 -2
- package/src/editor/lib/VisualEditor.svelte +11 -6
- package/src/editor/lib/embed-preview.ts +57 -0
- package/src/editor/lib/embeds.ts +47 -0
- package/src/editor/lib/github-backend.ts +2 -1
- package/src/editor/lib/icons.ts +17 -0
- package/src/editor/lib/images.ts +20 -8
- package/src/editor/lib/media-library.ts +11 -1
- package/src/editor/lib/settings.ts +2 -2
- package/src/editor/lib/tooltip.ts +23 -4
- package/src/editor/styles.css +29 -12
- package/src/env.d.ts +1 -1
- package/src/index.ts +4 -6
- package/src/json-output.ts +5 -0
- package/src/manifest/types.ts +1 -0
- package/src/media-types.ts +14 -1
- package/src/preview-urls.ts +6 -0
- package/src/preview.ts +23 -8
- package/src/routes/local-documents.ts +1 -1
- package/src/routes/local-media.ts +33 -28
- package/src/routes/local-menu.ts +2 -1
- package/src/routes/local-post.ts +2 -1
- package/src/routes/media-files.ts +36 -1
- package/src/routes/media-validation.ts +2 -2
- package/src/schema/store.ts +3 -1
- package/src/slugs.ts +1 -1
- package/src/vite/manifest-plugin.ts +3 -0
|
@@ -1,20 +1,39 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { onMount, onDestroy } from "svelte";
|
|
2
|
+
import { onMount, onDestroy, tick } from "svelte";
|
|
3
3
|
import Icon from "./Icon.svelte";
|
|
4
|
+
import ActionDialog from "./ActionDialog.svelte";
|
|
4
5
|
import Select from "./Select.svelte";
|
|
5
6
|
import { getSettings, setSettings } from "./settings";
|
|
6
|
-
import { MEDIA_CATEGORIES, mediaMetadata, mediaAccept, safeMediaName } from "../../media-types";
|
|
7
|
-
import
|
|
7
|
+
import { DEFAULT_MAX_FILE_SIZE, MEDIA_CATEGORIES, mediaMetadata, mediaAccept, safeMediaName } from "../../media-types";
|
|
8
|
+
import config from "virtual:rigel-config";
|
|
9
|
+
import { parseEmbedUrl, embedWatchUrl, type EmbedProvider } from "./embeds";
|
|
10
|
+
import type { EmbedData } from "../blocks/embed";
|
|
11
|
+
import type { VideoData } from "../blocks/video";
|
|
12
|
+
import { filterMedia, modifiedMonth, mediaSize, mediaIcon, assetFile, type MediaAsset } from "./media-library";
|
|
8
13
|
import { searchImages, importStockImage, importImageUrl, uploadMedia, safeImageUrl, STOCK_PROVIDERS, type StockResult, type StockSource } from "./images";
|
|
9
14
|
|
|
10
|
-
let { value = "", altText = "", triggerLabel = "Insert image", onpick, inline = false, toolbar = false, onopen, onclose }: {
|
|
15
|
+
let { value = "", altText = "", triggerLabel = "Insert image", kind = "image", video, onvideo, embed, onembed, onpick, inline = false, toolbar = false, hiddenTrigger = false, onopen, onclose }: {
|
|
16
|
+
video?: VideoData;
|
|
17
|
+
onvideo?: (video: VideoData) => void;
|
|
18
|
+
embed?: EmbedData;
|
|
19
|
+
onembed?: (embed: EmbedData) => void;
|
|
20
|
+
kind?: "image" | "video";
|
|
11
21
|
value?: string; onpick?: (url: string, alt: string, credit?: StockResult) => void;
|
|
12
22
|
altText?: string; triggerLabel?: string;
|
|
13
|
-
inline?: boolean; toolbar?: boolean; onopen?: () => void; onclose?: () => void;
|
|
23
|
+
inline?: boolean; toolbar?: boolean; hiddenTrigger?: boolean; onopen?: () => void; onclose?: () => void;
|
|
14
24
|
} = $props();
|
|
25
|
+
const id = $props.id();
|
|
26
|
+
const maxFileSize = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
15
27
|
let open = $state(false);
|
|
16
|
-
let tab = $state<"library" | "upload" | "url" | StockSource>("library");
|
|
17
|
-
const
|
|
28
|
+
let tab = $state<"library" | "upload" | "url" | EmbedProvider | StockSource>("library");
|
|
29
|
+
const providerTab = $derived(tab === "youtube" || tab === "vimeo");
|
|
30
|
+
const editing = $derived(!!embed || !!value);
|
|
31
|
+
const stockKeys = $derived(open ? getSettings().apiKeys : null);
|
|
32
|
+
const tabs = $derived((kind === "video" ? ["library", "upload", "url", "youtube", "vimeo"] : ["library", "upload", "url", "picsum", ...Object.keys(STOCK_PROVIDERS).filter(source => stockKeys?.[source as keyof typeof STOCK_PROVIDERS]?.trim())]) as (typeof tab)[]);
|
|
33
|
+
let videoTitle = $state("");
|
|
34
|
+
let privacy = $state(true);
|
|
35
|
+
let muted = $state(false);
|
|
36
|
+
let controls = $state(true);
|
|
18
37
|
let query = $state("");
|
|
19
38
|
let filter = $state("");
|
|
20
39
|
let format = $state("");
|
|
@@ -35,10 +54,13 @@
|
|
|
35
54
|
let urlInput = $state("");
|
|
36
55
|
let alt = $state("");
|
|
37
56
|
let selected = $state<{url: string; stock?: StockResult; file?: File; asset?: MediaAsset} | null>(null);
|
|
57
|
+
const selectedMetadata = $derived(mediaMetadata(selected?.file?.name ?? assetFile(selected?.asset?.url ?? "")));
|
|
38
58
|
let metaName = $state("");
|
|
39
59
|
let metaTitle = $state("");
|
|
40
60
|
let metaDescription = $state("");
|
|
41
61
|
let metaAlt = $state("");
|
|
62
|
+
let confirmDelete = $state(false);
|
|
63
|
+
let deleteTrigger = $state<HTMLButtonElement>();
|
|
42
64
|
let savingMeta = $state(false);
|
|
43
65
|
let metaError = $state("");
|
|
44
66
|
let metaNotice = $state("");
|
|
@@ -69,13 +91,32 @@
|
|
|
69
91
|
} catch { metaError = "Metadata save failed"; }
|
|
70
92
|
finally { savingMeta = false; }
|
|
71
93
|
}
|
|
72
|
-
|
|
94
|
+
async function deleteFile() {
|
|
95
|
+
if (!selected?.asset || !writable || picking || savingMeta) return;
|
|
96
|
+
const url = selected.asset.url;
|
|
97
|
+
picking = true;
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch("/rigel/api/local/media", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }) });
|
|
100
|
+
if (!response.ok) throw new Error(`Could not delete file (${response.status}). Try again.`);
|
|
101
|
+
assets = assets.filter(asset => asset.url !== url);
|
|
102
|
+
librarySelection = ""; notice = "File deleted.";
|
|
103
|
+
close();
|
|
104
|
+
} finally { picking = false; }
|
|
105
|
+
}
|
|
106
|
+
const needsImport = $derived(!!selected?.file || (kind !== "video" && !!selected && ((inline && tab === "url") || !selected.url.startsWith("/")) && selected.stock?.source !== "unsplash"));
|
|
107
|
+
const imageImport = $derived(needsImport && kind === "image" && (!selected?.file || mediaMetadata(selected.file.name)?.category === "Images"));
|
|
108
|
+
const metadataImport = $derived(needsImport && (inline || imageImport));
|
|
109
|
+
function selectStock(image: StockResult) {
|
|
110
|
+
selected = { url: image.url, stock: image };
|
|
111
|
+
metaTitle = (image.title ?? "").slice(0, 200); metaDescription = (image.description ?? "").slice(0, 1000);
|
|
112
|
+
alt = (image.alt ?? altText).slice(0, 1000);
|
|
113
|
+
}
|
|
73
114
|
|
|
74
115
|
async function library() {
|
|
75
116
|
const response = await fetch("/rigel/media.json", { cache: "no-store" });
|
|
76
117
|
if (!response.ok) throw new Error("Media listing unavailable. Start the local dev server or rebuild the site.");
|
|
77
118
|
const data = await response.json();
|
|
78
|
-
assets = (data.assets ?? data.images).filter((asset: MediaAsset) => inline || asset.type?.startsWith("image/"));
|
|
119
|
+
assets = (data.assets ?? data.images).filter((asset: MediaAsset) => inline || asset.type?.startsWith(kind === "video" ? "video/" : "image/"));
|
|
79
120
|
writable = data.writable === true;
|
|
80
121
|
folder = data.folder;
|
|
81
122
|
}
|
|
@@ -89,10 +130,12 @@
|
|
|
89
130
|
if (picking) return;
|
|
90
131
|
const request = ++requestId;
|
|
91
132
|
error = "";
|
|
133
|
+
if (providerTab) return;
|
|
92
134
|
if (tab === "url") {
|
|
93
|
-
if (!safeImageUrl(urlInput.trim())) { error =
|
|
135
|
+
if (!safeImageUrl(urlInput.trim())) { error = `Enter ${kind === "video" ? "a video" : "an image"} URL starting with https:// or /`; return; }
|
|
94
136
|
const metadata = mediaMetadata(new URL(urlInput.trim(), location.origin).pathname);
|
|
95
|
-
if (
|
|
137
|
+
if (kind === "video" && metadata?.category !== "Video") { error = "Choose an MP4, WebM or MOV video URL."; return; }
|
|
138
|
+
if (kind === "image" && metadata && metadata.category !== "Images") { error = "Choose an image URL, not a document or other media file."; return; }
|
|
96
139
|
selected = { url: urlInput.trim() };
|
|
97
140
|
return;
|
|
98
141
|
}
|
|
@@ -100,7 +143,7 @@
|
|
|
100
143
|
try {
|
|
101
144
|
if (tab === "library" || tab === "upload") await library();
|
|
102
145
|
else {
|
|
103
|
-
const found = await searchImages(tab, query);
|
|
146
|
+
const found = await searchImages(tab as StockSource, query);
|
|
104
147
|
if (request === requestId) results = found;
|
|
105
148
|
}
|
|
106
149
|
if (request === requestId) searched = true;
|
|
@@ -108,17 +151,31 @@
|
|
|
108
151
|
finally { if (request === requestId) loading = false; }
|
|
109
152
|
}
|
|
110
153
|
async function confirm() {
|
|
154
|
+
if (providerTab) {
|
|
155
|
+
const ref = parseEmbedUrl(urlInput);
|
|
156
|
+
if (!ref || ref.provider !== tab) { error = `Paste a ${tab === "youtube" ? "YouTube" : "Vimeo"} video URL.`; return; }
|
|
157
|
+
onembed?.({ ...ref, title: videoTitle.trim() || undefined, privacy });
|
|
158
|
+
close();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
111
161
|
if (!selected || picking) return;
|
|
112
162
|
const request = ++requestId;
|
|
113
163
|
const choice = selected;
|
|
164
|
+
const metadata = metadataImport ? { title: metaTitle.trim(), description: metaDescription.trim(), ...(imageImport ? { alt: alt.trim() } : {}) } : null;
|
|
114
165
|
picking = true; error = "";
|
|
115
166
|
try {
|
|
116
167
|
const url = choice.file ? await uploadMedia(choice.file) : choice.stock ? await importStockImage(choice.stock) : needsImport ? await importImageUrl(choice.url) : choice.url;
|
|
117
|
-
if (!safeImageUrl(url)) throw new Error("Unsafe
|
|
168
|
+
if (!safeImageUrl(url)) throw new Error("Unsafe media URL");
|
|
169
|
+
if (metadata) {
|
|
170
|
+
const saved = await fetch("/rigel/api/local/media", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, ...metadata }) });
|
|
171
|
+
if (!saved.ok) throw new Error(`File uploaded, but metadata could not be saved (${saved.status}). Retry to finish the import.`);
|
|
172
|
+
}
|
|
173
|
+
if (kind === "video" && mediaMetadata(new URL(url, location.origin).pathname)?.category !== "Video") throw new Error("Choose an MP4, WebM or MOV video URL.");
|
|
118
174
|
if (request === requestId) {
|
|
119
175
|
if (inline) {
|
|
120
176
|
await library(); resetFilters(); notice = "Media available in the repository library.";
|
|
121
|
-
} else
|
|
177
|
+
} else if (kind === "video") onvideo?.({ src: url, caption: alt.trim() || undefined, muted, controls });
|
|
178
|
+
else onpick?.(url, alt, choice.stock);
|
|
122
179
|
close();
|
|
123
180
|
}
|
|
124
181
|
} catch (e) { if (request === requestId) error = e instanceof Error ? e.message : "Photo import failed"; }
|
|
@@ -127,22 +184,33 @@
|
|
|
127
184
|
function upload(file?: File) {
|
|
128
185
|
if (!file || picking) return;
|
|
129
186
|
error = "";
|
|
130
|
-
|
|
131
|
-
|
|
187
|
+
const limit = maxFileSize;
|
|
188
|
+
if (!safeMediaName(file.name) || (!inline && mediaMetadata(file.name)?.category !== (kind === "video" ? "Video" : "Images")) || file.size > limit) {
|
|
189
|
+
error = `Choose a supported ${inline ? "file" : kind} up to ${limit / 1024 / 1024} MB with a safe filename.`; return;
|
|
132
190
|
}
|
|
133
|
-
|
|
191
|
+
const type = mediaMetadata(file.name)!.type;
|
|
192
|
+
releaseFile(); objectURL = URL.createObjectURL(new Blob([file], { type: type === "text/plain" ? "text/plain; charset=utf-8" : type }));
|
|
193
|
+
metaTitle = file.name.replace(/\.[^.]+$/, "").slice(0, 200); metaDescription = ""; alt = altText;
|
|
134
194
|
selected = { url: objectURL, file };
|
|
135
195
|
}
|
|
136
196
|
function close() {
|
|
137
|
-
requestId++; dialog?.close(); open = false; loading = false; picking = false; selected = null; error = "";
|
|
197
|
+
requestId++; confirmDelete = false; dialog?.close(); open = false; loading = false; picking = false; selected = null; error = "";
|
|
138
198
|
releaseFile(); onclose?.(); trigger?.focus();
|
|
139
199
|
}
|
|
140
200
|
function changeTab(next: typeof tab) {
|
|
141
|
-
alt = altText;
|
|
201
|
+
alt = altText; metaTitle = ""; metaDescription = "";
|
|
142
202
|
requestId++; releaseFile(); tab = next; results = []; selected = null; error = ""; searched = false; loading = false;
|
|
203
|
+
if (next === "youtube" || next === "vimeo") {
|
|
204
|
+
urlInput = embed?.provider === next ? embedWatchUrl(embed) : next === "youtube" ? "https://www.youtube.com/watch?v=dQw4w9WgXcQ" : "https://vimeo.com/861680818";
|
|
205
|
+
videoTitle = embed?.provider === next ? embed.title ?? "" : next === "youtube" ? "Rick Astley - Never Gonna Give You Up (Rickroll)" : "The Rolling Stones - Angry";
|
|
206
|
+
privacy = embed?.provider === next ? embed.privacy : true;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
urlInput = value;
|
|
143
210
|
if (tab !== "url") void run();
|
|
144
211
|
}
|
|
145
|
-
function launch() { onopen?.(); open = true; urlInput = value; alt = altText; changeTab(inline ? "upload" : "library"); }
|
|
212
|
+
export function launch() { onopen?.(); open = true; urlInput = value; alt = altText; changeTab(inline ? "upload" : "library"); }
|
|
213
|
+
export function launchVideo(source: "library" | EmbedProvider) { onopen?.(); open = true; muted = video?.muted === true; controls = video?.controls !== false; changeTab(source); }
|
|
146
214
|
</script>
|
|
147
215
|
|
|
148
216
|
{#if inline}
|
|
@@ -153,7 +221,7 @@
|
|
|
153
221
|
{#if error && !open}<p class="error" role="alert">{error}</p>{/if}
|
|
154
222
|
</div>
|
|
155
223
|
{:else if toolbar}
|
|
156
|
-
<button bind:this={trigger} data-image-trigger type="button" aria-label={triggerLabel} data-tooltip={triggerLabel} onmousedown={event => event.preventDefault()} onclick={launch}><Icon name="image" size={18} /></button>
|
|
224
|
+
<button bind:this={trigger} data-image-trigger={kind === "image" ? "" : undefined} type="button" aria-label={triggerLabel} data-tooltip={triggerLabel} hidden={hiddenTrigger} onmousedown={event => event.preventDefault()} onclick={launch}><Icon name="image" size={18} /></button>
|
|
157
225
|
{:else}
|
|
158
226
|
<div class="image-field">
|
|
159
227
|
{#if value}<img src={value} alt="" class="preview" />{:else}<div class="preview empty">No image</div>{/if}
|
|
@@ -170,7 +238,7 @@
|
|
|
170
238
|
<button type="button" class="outlined" data-tooltip="List view" aria-label="List view" aria-pressed={view === "list"} onclick={() => setView("list")}><Icon name="listBullets" size={18} /></button>
|
|
171
239
|
</div>
|
|
172
240
|
<div class="media-select">
|
|
173
|
-
<Select ariaLabel={inline ? "Media type" : "Image format"} value={format} onchange={next => { format = String(next); }} options={inline ? [{ value: "", label: "All media items" }, ...MEDIA_CATEGORIES.map(category => ({ value: category, label: category }))] : [{ value: "", label: "All images" }, ...["jpeg", "png", "webp", "gif", "avif"].map(format => ({ value: `image/${format}`, label: format === "jpeg" ? "JPEG" : format === "webp" ? "WebP" : format.toUpperCase() }))]} />
|
|
241
|
+
<Select ariaLabel={inline ? "Media type" : kind === "video" ? "Video format" : "Image format"} value={format} onchange={next => { format = String(next); }} options={inline ? [{ value: "", label: "All media items" }, ...MEDIA_CATEGORIES.map(category => ({ value: category, label: category }))] : kind === "video" ? [{ value: "", label: "All videos" }, { value: "video/mp4", label: "MP4" }, { value: "video/webm", label: "WebM" }, { value: "video/quicktime", label: "MOV" }] : [{ value: "", label: "All images" }, ...["jpeg", "png", "webp", "gif", "avif"].map(format => ({ value: `image/${format}`, label: format === "jpeg" ? "JPEG" : format === "webp" ? "WebP" : format.toUpperCase() }))]} />
|
|
174
242
|
</div>
|
|
175
243
|
<div class="media-select">
|
|
176
244
|
<Select ariaLabel="Modified month" data-tooltip="Filesystem modified month (UTC), not upload date" value={month} onchange={next => { month = String(next); }} options={[{ value: "", label: "All modified dates" }, ...months.map(date => ({ value: date, label: new Date(date + "-01T00:00:00Z").toLocaleDateString(undefined, { month: "long", year: "numeric", timeZone: "UTC" }) }))]} />
|
|
@@ -180,48 +248,62 @@
|
|
|
180
248
|
<input class="media-search" aria-label="Search media" type="search" bind:value={filter} placeholder="Search media…" />
|
|
181
249
|
</div>
|
|
182
250
|
{#if view === "grid"}<div class="grid">
|
|
183
|
-
{#each filtered as asset}
|
|
184
|
-
|
|
251
|
+
{#each filtered as asset, index}
|
|
252
|
+
{@const extension = mediaMetadata(assetFile(asset.url))?.extension.toUpperCase() ?? "FILE"}
|
|
253
|
+
<figure><button type="button" class="thumb" aria-label={asset.name} aria-describedby={`${id}-extension-${index}`} data-tooltip={asset.name} aria-pressed={librarySelection === asset.url} onclick={() => selectAsset(asset)}>{#if asset.type?.startsWith("image/")}<img src={asset.url} alt={asset.name} loading="lazy" />{:else if asset.type?.startsWith("video/")}<video src={`${asset.url}#t=0.001`} muted preload="metadata" playsinline aria-hidden="true"></video>{:else}<span class="media-file-icon"><Icon name={mediaIcon(assetFile(asset.url))} size={40} /><span>{mediaMetadata(assetFile(asset.url))?.category}</span></span>{/if}<span id={`${id}-extension-${index}`} class="media-extension">{extension}</span></button><figcaption>{asset.name}</figcaption></figure>
|
|
185
254
|
{/each}
|
|
186
255
|
</div>
|
|
187
256
|
{:else}<div class="table-card media-table"><table aria-label="Media library"><thead><tr><th scope="col">File</th><th scope="col">Type</th><th scope="col">Size</th><th scope="col">Modified (UTC)</th></tr></thead><tbody>
|
|
188
|
-
{#each filtered as asset}<tr><td><button type="button" class="table-link media-file" aria-pressed={librarySelection === asset.url} onclick={() => selectAsset(asset)}>{#if asset.type?.startsWith("image/")}<img src={asset.url} alt="" loading="lazy" />{:else}<Icon name=
|
|
257
|
+
{#each filtered as asset}<tr><td><button type="button" class="table-link media-file" aria-pressed={librarySelection === asset.url} onclick={() => selectAsset(asset)}><span class="media-thumbnail" aria-hidden="true">{#if asset.type?.startsWith("image/")}<img src={asset.url} alt="" loading="lazy" />{:else}<Icon name={mediaIcon(assetFile(asset.url))} size={28} />{/if}</span><span>{asset.name}</span></button></td><td>{asset.type?.replace("image/", "").toUpperCase() || "—"}</td><td>{mediaSize(asset.size)}</td><td>{modifiedMonth(asset.modified) ? new Date(asset.modified!).toLocaleDateString(undefined, {timeZone: "UTC"}) : "—"}</td></tr>{/each}
|
|
189
258
|
</tbody></table></div>{/if}
|
|
190
259
|
{#if loading}<p role="status">Loading media…</p>{:else if !filtered.length}<p role="status">{assets.length ? "No media match these filters." : "No media in this folder yet."}</p>{/if}
|
|
191
260
|
{/snippet}
|
|
192
261
|
|
|
193
262
|
{#if open}
|
|
194
|
-
<dialog bind:this={dialog} class="rigel-modal-backdrop rigel image-dialog" aria-label={inline ? "Upload media" : "Choose image"} oncancel={e => { e.preventDefault(); if (!picking) close(); }} onclick={e => { if (e.target === dialog && !picking) close(); }}>
|
|
263
|
+
<dialog bind:this={dialog} class="rigel-modal-backdrop rigel image-dialog" class:media-insert={providerTab} aria-label={providerTab ? editing ? "Edit video" : "Add video link" : inline ? "Upload media" : kind === "video" ? "Choose video" : "Choose image"} oncancel={e => { e.preventDefault(); if (!picking) close(); }} onclick={e => { if (e.target === dialog && !picking) close(); }}>
|
|
195
264
|
<div class="rigel-modal image-picker">
|
|
196
|
-
<div class="media-head"><h2>{selected ? "Preview media" : inline ? "Upload media" : "Choose image"}</h2><button bind:this={cancel} type="button" class="close" aria-label="Close" disabled={picking} onclick={close}><Icon name="x" size={14} weight="bold" /></button></div>
|
|
265
|
+
<div class="media-head"><h2>{providerTab ? `${editing ? "Edit" : "Add"} ${tab === "youtube" ? "YouTube" : "Vimeo"} video` : selected ? "Preview media" : inline ? "Upload media" : kind === "video" ? "Choose video" : "Choose image"}</h2><button bind:this={cancel} type="button" class="close" aria-label="Close" disabled={picking} onclick={close}><Icon name="x" size={14} weight="bold" /></button></div>
|
|
197
266
|
{#if selected}
|
|
198
|
-
<div class="media-selection" class:media-editing={inline && !!selected.asset}>
|
|
267
|
+
<div class="media-selection" class:media-editing={(inline && !!selected.asset) || metadataImport}>
|
|
199
268
|
<div class="media-preview">
|
|
200
|
-
{#if
|
|
269
|
+
{#if kind === "video" || selectedMetadata?.category === "Video"}
|
|
270
|
+
<video src={selected.url} controls={kind === "video" ? controls : true} muted={kind === "video" ? muted : false} preload="metadata" playsinline></video>
|
|
271
|
+
{:else if selectedMetadata?.extension === "pdf" || selectedMetadata?.extension === "txt"}
|
|
272
|
+
<iframe src={`${selected.file ? selected.url : `${selected.url}?preview=1`}${selectedMetadata.extension === "pdf" ? "#navpanes=0&view=FitH" : ""}`} title={selectedMetadata.extension === "pdf" ? "PDF preview" : "Text preview"} sandbox={selectedMetadata.extension === "txt" ? "" : undefined}></iframe>
|
|
273
|
+
{:else if (!selected.file && !selected.asset) || selectedMetadata?.category === "Images"}
|
|
274
|
+
<img src={selected.url} alt="Selected image preview" />
|
|
275
|
+
{:else}
|
|
276
|
+
<div class="media-file-icon"><Icon name={mediaIcon(selected.file?.name ?? assetFile(selected.asset?.url ?? ""))} size={64} /><p>{selectedMetadata?.category} · {mediaSize(selected.file?.size ?? selected.asset?.size)}</p><p>{selectedMetadata?.type}</p></div>
|
|
277
|
+
{/if}
|
|
201
278
|
</div>
|
|
202
279
|
<div class="media-details">
|
|
203
|
-
{#if inline && selected.asset}
|
|
280
|
+
{#if (inline && selected.asset) || metadataImport}
|
|
204
281
|
<div class="media-meta">
|
|
205
|
-
<label>Display name
|
|
206
|
-
{#if selected.asset
|
|
207
|
-
<label>Title
|
|
208
|
-
<label>Description
|
|
209
|
-
<p class="hint">Author: {selected.asset.author || "Local user"}</p>
|
|
210
|
-
{#if metaError}<p class="error" role="alert">{metaError}</p>{/if}
|
|
211
|
-
{#if metaNotice}<p role="status">{metaNotice}</p>{/if}
|
|
282
|
+
{#if inline && selected.asset}<label><span>Display name</span><input aria-label="Media display name" type="text" maxlength="120" bind:value={metaName} /></label>{/if}
|
|
283
|
+
{#if imageImport}<label><span>Alt text (leave empty if decorative)</span><input aria-label="Image alt text" type="text" maxlength="1000" disabled={picking} bind:value={alt} /></label>{:else if selected.asset?.type?.startsWith("image/")}<label><span>Alt text</span><textarea aria-label="Media alt text" rows="3" maxlength="1000" bind:value={metaAlt}></textarea></label>{/if}
|
|
284
|
+
<label><span>Title</span><input aria-label={imageImport ? "Image title" : "Media title"} type="text" maxlength="200" disabled={picking} bind:value={metaTitle} /></label>
|
|
285
|
+
<label><span>Description</span><textarea aria-label={imageImport ? "Image description" : "Media description"} rows="3" maxlength="1000" disabled={picking} bind:value={metaDescription}></textarea></label>
|
|
286
|
+
{#if selected.asset}<p class="hint">Author: {selected.asset.author || "Local user"}</p>{:else if selected.file}<p class="hint">{selected.file.name}</p>{/if}
|
|
212
287
|
</div>
|
|
213
288
|
{:else if selected.file}
|
|
214
289
|
<p class="hint">{selected.file.name}</p>
|
|
215
290
|
{:else if !selected.asset}
|
|
216
|
-
<label>Image URL
|
|
291
|
+
<label><span>{kind === "video" ? "Video URL" : "Image URL"}</span><input type="text" readonly value={selected.url} /></label>
|
|
292
|
+
{/if}
|
|
293
|
+
{#if imageImport && !selected.file}<label><span>Image URL</span><input type="text" readonly value={selected.url} /></label>{/if}
|
|
294
|
+
{#if !inline && !imageImport}<label><span>{kind === "video" ? "Caption (optional)" : "Alt text (describe the image; leave empty if decorative)"}</span><input aria-label={kind === "video" ? "Video caption" : undefined} type="text" bind:value={alt} /></label>{/if}
|
|
295
|
+
{#if kind === "video"}
|
|
296
|
+
<label class="inline"><input type="checkbox" role="switch" class="switch" aria-label="Muted" bind:checked={muted} /><span>Muted</span></label>
|
|
297
|
+
<label class="inline"><input type="checkbox" role="switch" class="switch" aria-label="Show controls" bind:checked={controls} /><span>Show controls</span></label>
|
|
217
298
|
{/if}
|
|
218
|
-
{#if !inline}<label>Alt text (describe the image; leave empty if decorative)<input type="text" bind:value={alt} /></label>{/if}
|
|
219
299
|
{#if selected.stock?.credit}<p class="hint">{selected.stock.credit}</p>{/if}
|
|
220
|
-
{#if needsImport}<p class="hint">Confirmation downloads this file into the repository media folder. Content still requires Save.</p>{/if}
|
|
300
|
+
{#if needsImport}<p class="hint">Confirmation {selected.file ? "uploads" : "downloads"} this file into the repository media folder. Content still requires Save.</p>{/if}
|
|
221
301
|
</div>
|
|
222
302
|
<div class="media-actions" class:with-media-path={!!selected.asset}>
|
|
303
|
+
{#if metaError || metaNotice}<p class="media-feedback" class:error={!!metaError} role={metaError ? "alert" : "status"}>{metaError || metaNotice}</p>{/if}
|
|
223
304
|
{#if selected.asset}
|
|
224
305
|
<a class="outlined media-download" href={selected.asset.url} download={assetFile(selected.asset.url)}>Download file</a>
|
|
306
|
+
{#if inline && writable}<button bind:this={deleteTrigger} type="button" class="danger-btn" aria-label="Delete file" disabled={picking || savingMeta} onclick={() => { confirmDelete = true; }}>Delete file</button>{/if}
|
|
225
307
|
<pre class="media-path" role="region" aria-label="Media file path" tabindex="0"><code>{selected.asset.url}</code></pre>
|
|
226
308
|
{/if}
|
|
227
309
|
<div class="media-button-actions">
|
|
@@ -230,22 +312,31 @@
|
|
|
230
312
|
<button type="button" class="primary" disabled={picking || savingMeta} onclick={saveMetadata}>{savingMeta ? "Saving…" : "Save"}</button>
|
|
231
313
|
{:else}
|
|
232
314
|
<button type="button" class="outlined" disabled={picking} onclick={() => { selected = null; releaseFile(); }}>Back</button>
|
|
233
|
-
{#if inline && tab === "library"}<button type="button" class="primary" onclick={close}>Done</button>{:else}<button type="button" class="primary" disabled={picking || (needsImport && !writable)} onclick={confirm}>{picking ? "Importing photo…" : !inline ? "Insert image" : selected.file && mediaMetadata(selected.file.name)?.category !== "Images" ? "Upload file" : "Import image"}</button>{/if}
|
|
315
|
+
{#if inline && tab === "library"}<button type="button" class="primary" onclick={close}>Done</button>{:else}<button type="button" class="primary" disabled={picking || (needsImport && !writable)} onclick={confirm}>{picking ? kind === "video" ? "Uploading video…" : "Importing photo…" : kind === "video" ? "Use video" : !inline ? "Insert image" : selected.file && mediaMetadata(selected.file.name)?.category !== "Images" ? "Upload file" : "Import image"}</button>{/if}
|
|
234
316
|
{/if}</div>
|
|
235
317
|
</div>
|
|
236
318
|
</div>
|
|
237
319
|
{:else}
|
|
238
|
-
<div class="tabs" aria-label="Image sources">
|
|
239
|
-
{#each tabs.filter(t => !inline || (t !== "library" && t !== "unsplash")) as t}<button type="button" class:active={tab === t} aria-pressed={tab === t} disabled={picking} onclick={() => changeTab(t)}>{t}</button>{/each}
|
|
320
|
+
<div class="tabs" aria-label={kind === "video" ? "Video sources" : "Image sources"}>
|
|
321
|
+
{#each tabs.filter(t => !inline || (t !== "library" && t !== "unsplash")) as t}<button type="button" data-source={t} class:active={tab === t} aria-pressed={tab === t} disabled={picking} onclick={() => changeTab(t)}>{t === "youtube" ? "YouTube" : t === "vimeo" ? "Vimeo" : t}</button>{/each}
|
|
240
322
|
</div>
|
|
241
323
|
{#if inline}<p class="hint">Unsplash API photos require hotlinking and are available only when inserting images into content, not as repository uploads.</p>{/if}
|
|
242
|
-
{#if
|
|
324
|
+
{#if providerTab}
|
|
325
|
+
<p class="hint">Paste a {tab === "youtube" ? "YouTube" : "Vimeo"} link. Privacy-enhanced mode is enabled by default.</p>
|
|
326
|
+
<label><span>Video URL</span><input type="text" bind:value={urlInput} placeholder={tab === "youtube" ? "https://www.youtube.com/watch?v=…" : "https://vimeo.com/…"} /></label>
|
|
327
|
+
<label><span>Title for accessibility (optional)</span><input type="text" maxlength="200" bind:value={videoTitle} /></label>
|
|
328
|
+
<label class="inline"><input type="checkbox" role="switch" class="switch" bind:checked={privacy} /><span>Privacy-enhanced (no cookies until play)</span></label>
|
|
329
|
+
<div class="media-actions media-button-actions">
|
|
330
|
+
<button type="button" class="outlined" onclick={close}>Cancel</button>
|
|
331
|
+
<button type="button" class="primary" onclick={confirm}>{editing ? "Update video" : "Insert"}</button>
|
|
332
|
+
</div>
|
|
333
|
+
{:else if tab === "library"}
|
|
243
334
|
{@render libraryBrowser()}
|
|
244
335
|
{:else if tab === "upload"}
|
|
245
336
|
<p class="hint">Choose a local file, then preview and confirm. Nothing is uploaded until confirmation.</p>
|
|
246
|
-
{#if writable}<label>{inline ? "Upload media (images, audio, video, documents, spreadsheets, archives
|
|
337
|
+
{#if writable}<label><span>{kind === "video" ? "Upload video (MP4, WebM, MOV)" : inline ? "Upload media (images, audio, video, documents, spreadsheets, archives)" : "Upload image (JPEG, PNG, WebP, GIF, AVIF)"} · {maxFileSize / 1024 / 1024} MB maximum</span><input type="file" accept={kind === "video" ? ".mp4,.webm,.mov" : mediaAccept(!inline)} onchange={e => upload(e.currentTarget.files?.[0])} /></label>{:else}<p role="status">Local uploads require the local dev server. This library is read-only.</p>{/if}
|
|
247
338
|
{:else if tab === "url"}
|
|
248
|
-
<label>Image URL
|
|
339
|
+
<label><span>{kind === "video" ? "Video URL" : "Image URL"}</span><input type="url" bind:value={urlInput} placeholder={kind === "video" ? "https://… or /media/video.mp4" : "https://… or /images/photo.jpg"} /></label>
|
|
249
340
|
<button type="button" class="primary" onclick={run}>Use this URL</button>
|
|
250
341
|
{:else}
|
|
251
342
|
<p class="hint">{tab === "picsum" ? "Placeholder photos from Lorem Picsum (seed, not semantic search)." : tab === "unsplash" ? "Unsplash photos stay linked to Unsplash; selection records a download." : "Import selected photos into the media folder (local dev only)."}</p>
|
|
@@ -253,7 +344,7 @@
|
|
|
253
344
|
{#if searched && !results.length && !error}<p class="hint" role="status">No photos found. Try another search.</p>{/if}
|
|
254
345
|
<div class="grid">
|
|
255
346
|
{#each results as r (r.url)}
|
|
256
|
-
<figure><button type="button" class="thumb" disabled={picking} onclick={() =>
|
|
347
|
+
<figure><button type="button" class="thumb" disabled={picking} onclick={() => selectStock(r)} data-tooltip={r.credit}><img src={r.thumb} alt={r.credit} loading="lazy" /></button><figcaption>{#if r.creditURL?.startsWith("https://")}<a href={r.creditURL} target="_blank" rel="noopener noreferrer">{r.credit}</a>{:else}{r.credit}{/if}</figcaption></figure>
|
|
257
348
|
{/each}
|
|
258
349
|
</div>
|
|
259
350
|
{/if}
|
|
@@ -261,4 +352,7 @@
|
|
|
261
352
|
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
|
262
353
|
</div>
|
|
263
354
|
</dialog>
|
|
355
|
+
{#if confirmDelete && selected?.asset}
|
|
356
|
+
<ActionDialog title={`Delete “${selected.asset.name}”?`} description="This permanently deletes the file and its metadata. This cannot be undone. Any pages using this file will have broken media or links." confirmLabel="Delete file" danger onconfirm={deleteFile} onclose={async () => { confirmDelete = false; await tick(); deleteTrigger?.focus(); }} />
|
|
357
|
+
{/if}
|
|
264
358
|
{/if}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Icon from "./Icon.svelte";
|
|
3
|
+
import { tick } from "svelte";
|
|
4
|
+
import ImagePicker from "./ImagePicker.svelte";
|
|
5
|
+
import type { EmbedRef } from "./embeds";
|
|
6
|
+
import type { EmbedData } from "../blocks/embed";
|
|
7
|
+
import type { VideoData } from "../blocks/video";
|
|
8
|
+
|
|
9
|
+
let { triggerLabel = "Insert media", hiddenTrigger = false, onimage, onvideo, onembed, onopen, onclose = () => {} }: {
|
|
10
|
+
triggerLabel?: string;
|
|
11
|
+
hiddenTrigger?: boolean;
|
|
12
|
+
onimage?: () => void;
|
|
13
|
+
onvideo?: (video: VideoData) => void;
|
|
14
|
+
onembed?: (embed: EmbedRef & { privacy: boolean; title?: string }) => void;
|
|
15
|
+
onclose?: () => void;
|
|
16
|
+
onopen?: () => void;
|
|
17
|
+
} = $props();
|
|
18
|
+
const id = $props.id();
|
|
19
|
+
|
|
20
|
+
let trigger: HTMLButtonElement;
|
|
21
|
+
let menu: HTMLDivElement;
|
|
22
|
+
let videoPicker: ImagePicker;
|
|
23
|
+
let video = $state<VideoData | undefined>();
|
|
24
|
+
let embed = $state<EmbedData | undefined>();
|
|
25
|
+
|
|
26
|
+
export function toggleMenu(anchor = trigger) {
|
|
27
|
+
if (!menu || !anchor) return;
|
|
28
|
+
if (menu.matches(":popover-open")) menu.hidePopover();
|
|
29
|
+
else {
|
|
30
|
+
trigger = anchor;
|
|
31
|
+
menu.showPopover();
|
|
32
|
+
const rect = anchor.getBoundingClientRect(), bounds = menu.getBoundingClientRect();
|
|
33
|
+
const view = anchor.ownerDocument.defaultView!;
|
|
34
|
+
menu.style.left = `${Math.max(8, Math.min(rect.left, view.innerWidth - bounds.width - 8))}px`;
|
|
35
|
+
menu.style.top = `${rect.bottom + 4 + bounds.height <= view.innerHeight - 8 ? rect.bottom + 4 : Math.max(8, rect.top - bounds.height - 4)}px`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function pick(choice: "image" | "video" | "youtube" | "vimeo") {
|
|
39
|
+
if (menu?.matches(":popover-open")) menu.hidePopover();
|
|
40
|
+
onopen?.();
|
|
41
|
+
reset();
|
|
42
|
+
if (choice === "image") { onimage?.(); return; }
|
|
43
|
+
await tick();
|
|
44
|
+
videoPicker.launchVideo(choice === "video" ? "library" : choice);
|
|
45
|
+
}
|
|
46
|
+
function reset() {
|
|
47
|
+
video = undefined; embed = undefined;
|
|
48
|
+
}
|
|
49
|
+
export async function edit(value: EmbedData | VideoData) {
|
|
50
|
+
onopen?.();
|
|
51
|
+
reset();
|
|
52
|
+
if ("provider" in value) embed = value;
|
|
53
|
+
else video = value;
|
|
54
|
+
await tick();
|
|
55
|
+
videoPicker.launchVideo("provider" in value ? value.provider : "library");
|
|
56
|
+
}
|
|
57
|
+
function close() {
|
|
58
|
+
reset();
|
|
59
|
+
trigger?.focus();
|
|
60
|
+
onclose();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
{#if !hiddenTrigger}<button bind:this={trigger} data-media-trigger type="button" aria-label={triggerLabel} data-tooltip={triggerLabel} aria-haspopup="menu" popovertarget={`${id}-media`} onmousedown={event => event.preventDefault()} onclick={event => { event.preventDefault(); toggleMenu(); }}><Icon name="image" size={18} /></button>{/if}
|
|
66
|
+
<div bind:this={menu} id={`${id}-media`} class="block-menu media-menu" popover role="menu" aria-label="Insert media">
|
|
67
|
+
<button type="button" role="menuitem" onclick={() => pick("image")}><Icon name="image" size={16} />Image</button>
|
|
68
|
+
<button type="button" role="menuitem" onclick={() => pick("video")}><Icon name="filmStrip" size={16} />Video</button>
|
|
69
|
+
<button type="button" role="menuitem" onclick={() => pick("youtube")}><Icon name="youtubeLogo" size={16} />YouTube</button>
|
|
70
|
+
<button type="button" role="menuitem" onclick={() => pick("vimeo")}><Icon name="playCircle" size={16} />Vimeo</button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<ImagePicker bind:this={videoPicker} toolbar hiddenTrigger kind="video" value={video?.src ?? ""} altText={video?.caption ?? ""} {video} {onvideo} {embed} {onembed} onclose={close} />
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import { tick } from "svelte";
|
|
3
3
|
import Icon from "./Icon.svelte";
|
|
4
4
|
import type { ICONS } from "./icons";
|
|
5
|
+
import type { TooltipSide, TooltipAlign } from "./tooltip";
|
|
5
6
|
|
|
6
|
-
let { label = "", ariaLabel = label, title, "data-tooltip": tooltip = title, icon, value = "", options, multiple = false, disabled = false, onchange = () => {} }: {
|
|
7
|
+
let { label = "", ariaLabel = label, title, "data-tooltip": tooltip = title, "data-tooltip-position": tooltipPosition, "data-tooltip-align": tooltipAlign, icon, value = "", options, multiple = false, disabled = false, onchange = () => {} }: {
|
|
7
8
|
label?: string;
|
|
8
9
|
ariaLabel?: string;
|
|
9
10
|
title?: string;
|
|
10
11
|
"data-tooltip"?: string;
|
|
12
|
+
"data-tooltip-position"?: TooltipSide;
|
|
13
|
+
"data-tooltip-align"?: TooltipAlign;
|
|
11
14
|
icon?: keyof typeof ICONS;
|
|
12
15
|
value?: string | string[];
|
|
13
16
|
options: { value: string; label: string; icon?: keyof typeof ICONS; disabled?: boolean }[];
|
|
@@ -121,7 +124,7 @@
|
|
|
121
124
|
|
|
122
125
|
<div class="custom-select" onfocusout={() => { if (open) requestAnimationFrame(() => { if (trigger.ownerDocument.activeElement?.tagName === "IFRAME") close(); }); }}>
|
|
123
126
|
{#if label}<span class="select-label" id={`${id}-label`}>{label}</span>{/if}
|
|
124
|
-
<button bind:this={trigger} type="button" class="select-trigger" class:icon-only={!!icon} role="combobox" aria-label={label ? undefined : ariaLabel} aria-labelledby={label ? `${id}-label` : undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined} popovertarget={`${id}-list`} data-tooltip={tooltip} {disabled} onclick={event => { event.preventDefault(); menu.matches(":popover-open") ? close() : void reveal(); }} onkeydown={onkey}>
|
|
127
|
+
<button bind:this={trigger} type="button" class="select-trigger" class:icon-only={!!icon} role="combobox" aria-label={label ? undefined : ariaLabel} aria-labelledby={label ? `${id}-label` : undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined} popovertarget={`${id}-list`} data-tooltip={tooltip} data-tooltip-position={tooltipPosition} data-tooltip-align={tooltipAlign} {disabled} onclick={event => { event.preventDefault(); menu.matches(":popover-open") ? close() : void reveal(); }} onkeydown={onkey}>
|
|
125
128
|
{#if icon}<Icon name={icon} size={18} />{:else}<span>{text || (multiple ? `Select ${label.toLowerCase() || "options"}…` : "Choose…")}</span><Icon name="caretDown" size={12} />{/if}
|
|
126
129
|
</button>
|
|
127
130
|
<select hidden aria-hidden="true" tabindex="-1" aria-label={ariaLabel} {multiple} {disabled} {value} onchange={event => { value = multiple ? Array.from(event.currentTarget.selectedOptions, option => option.value) : event.currentTarget.value; onchange(value); }}>
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
previewPath: string | null;
|
|
26
26
|
websitePath?: string | null;
|
|
27
27
|
body?: "" | import("../../article-body").ArticleBody;
|
|
28
|
-
properties?: Snippet<[SelectedField | null, number, boolean, () => void]>;
|
|
28
|
+
properties?: Snippet<[SelectedField | null, number, boolean, () => void, boolean]>;
|
|
29
29
|
bodyInline?: boolean;
|
|
30
30
|
onbody?: (body: ArticleBody) => void;
|
|
31
31
|
creating: boolean;
|
|
@@ -39,7 +39,9 @@
|
|
|
39
39
|
let stage: HTMLDivElement;
|
|
40
40
|
let loaded = $state(false);
|
|
41
41
|
let bodyAvailable = $state(false);
|
|
42
|
-
|
|
42
|
+
let error = $state("");
|
|
43
|
+
const inlineBodyEnabled = $derived(bodyInline && !!previewPath && loaded && bodyAvailable && !error);
|
|
44
|
+
const inlineBodyLoading = $derived(bodyInline && !!previewPath && !loaded && !error);
|
|
43
45
|
let bodyEditor: ReturnType<typeof mount> | undefined;
|
|
44
46
|
let bodyThemeObserver: MutationObserver | undefined;
|
|
45
47
|
let editingBody: HTMLElement | null = null;
|
|
@@ -47,7 +49,6 @@
|
|
|
47
49
|
let bodyAttributes: [string, string | null][] = [];
|
|
48
50
|
let bodyScope: HTMLElement | null = null;
|
|
49
51
|
let removeTooltips: (() => void) | undefined;
|
|
50
|
-
let error = $state("");
|
|
51
52
|
let mode = $state<"browse" | "edit">("edit");
|
|
52
53
|
let panelOpen = $state(true);
|
|
53
54
|
let editorWidth = $state(0);
|
|
@@ -152,6 +153,7 @@
|
|
|
152
153
|
const doc = docElement();
|
|
153
154
|
if (!doc) return;
|
|
154
155
|
patchPreview(doc, previewValues ?? values, body, { documents, primary: docId, editingBody });
|
|
156
|
+
for (const button of doc.querySelectorAll<HTMLButtonElement>('[data-rigel-edit-video]')) button.hidden = !inlineBodyEnabled || mode !== "edit";
|
|
155
157
|
const signature = (Array.isArray(values.sections) ? (values.sections as Record<string, unknown>[]) : [])
|
|
156
158
|
.map((section) => `${section._key}:${section._type}`)
|
|
157
159
|
.join(",");
|
|
@@ -202,6 +204,7 @@
|
|
|
202
204
|
if (bodyEditor) { element.focus(); return; }
|
|
203
205
|
const doc = element.ownerDocument;
|
|
204
206
|
const blockIndex = event ? [...element.children].findIndex(child => child.contains(event.target as Node)) : 0;
|
|
207
|
+
const editVideo = !!(event?.target as Element | undefined)?.closest('[data-rigel-edit-video]');
|
|
205
208
|
const caret = event && doc.caretPositionFromPoint?.(event.clientX, event.clientY);
|
|
206
209
|
let offset = 0;
|
|
207
210
|
if (caret && element.children[blockIndex]?.contains(caret.offsetNode)) {
|
|
@@ -226,7 +229,8 @@
|
|
|
226
229
|
const host = doc.createElement("div"); host.className = "rigel rigel-inline";
|
|
227
230
|
scope.append(host); doc.body.append(scope); bodyScope = scope;
|
|
228
231
|
removeTooltips = tooltipRoot(host).destroy;
|
|
229
|
-
|
|
232
|
+
const mounted = mount(BlockEditor, { target: host, props: { editableRoot: element, get value() { return body as ArticleBody; }, oninput: (next: ArticleBody) => onbody(next) } });
|
|
233
|
+
bodyEditor = mounted;
|
|
230
234
|
bodyThemeObserver = new MutationObserver(syncTheme);
|
|
231
235
|
bodyThemeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
|
|
232
236
|
await tick();
|
|
@@ -241,6 +245,7 @@
|
|
|
241
245
|
}
|
|
242
246
|
const selection = doc.defaultView?.getSelection(); selection?.removeAllRanges(); selection?.addRange(range);
|
|
243
247
|
}
|
|
248
|
+
if (editVideo) mounted.editVideoAt(blockIndex);
|
|
244
249
|
}
|
|
245
250
|
function focusBody() {
|
|
246
251
|
const target = docElement()?.querySelector<HTMLElement>('[data-rigel-richtext="$body"]');
|
|
@@ -452,7 +457,7 @@
|
|
|
452
457
|
{#if !previewPath}
|
|
453
458
|
<p class="visual-error" role="status">{creating ? "New page — Save writes the file. The route renders once the site has it." : "No preview URL is mapped to this document. Use the properties panel to edit."}</p>
|
|
454
459
|
{:else}
|
|
455
|
-
<PreviewFrame bind:frame url={previewPath} {reloadToken} title="Website page editor" onload={onLoad} onloading={() => { stopBodyEdit(); inline = null; loaded = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />
|
|
460
|
+
<PreviewFrame bind:frame url={previewPath} {reloadToken} title="Website page editor" onload={onLoad} onloading={() => { stopBodyEdit(); inline = null; loaded = false; bodyAvailable = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />
|
|
456
461
|
{/if}
|
|
457
462
|
{#if inline}
|
|
458
463
|
<input
|
|
@@ -475,7 +480,7 @@
|
|
|
475
480
|
<div class="properties-divider" role="separator" tabindex="0" aria-label="Properties panel width" aria-orientation="vertical" aria-valuemin="280" aria-valuemax={panelMax} aria-valuenow={panelWidth} aria-valuetext={`${Math.round(panelWidth)} pixels`} onpointerdown={startResize} onpointermove={moveResize} onpointerup={endResize} onpointercancel={endResize} onlostpointercapture={endResize} onkeydown={keyResize}></div>
|
|
476
481
|
{/if}
|
|
477
482
|
{#if panelOpen && properties}
|
|
478
|
-
{@render properties(selected, focusToken, inlineBodyEnabled, focusBody)}
|
|
483
|
+
{@render properties(selected, focusToken, inlineBodyEnabled || inlineBodyLoading, focusBody, inlineBodyLoading)}
|
|
479
484
|
{:else if panelOpen && schema && docType}
|
|
480
485
|
<SchemaPanel
|
|
481
486
|
{schema}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { embedProviderLabel, embedWatchUrl, safeEmbed } from "./embeds";
|
|
2
|
+
|
|
3
|
+
export function createVideoOverlay(doc: Document, label: string, url: string, editable = false): HTMLElement {
|
|
4
|
+
const overlay = doc.createElement("div");
|
|
5
|
+
overlay.contentEditable = "false";
|
|
6
|
+
overlay.style.cssText = "position:absolute;inset:0;display:grid;place-items:center;padding:1rem;background:#0006";
|
|
7
|
+
const card = doc.createElement("div");
|
|
8
|
+
card.style.cssText = "box-sizing:border-box;width:100%;max-width:360px;padding:1rem;background:#18181b;color:white;border:1px solid #52525b;font:14px/1.5 system-ui;text-align:left";
|
|
9
|
+
const heading = doc.createElement("strong");
|
|
10
|
+
heading.textContent = label;
|
|
11
|
+
const link = doc.createElement("div");
|
|
12
|
+
link.textContent = url;
|
|
13
|
+
link.style.cssText = "margin:0.5rem 0 1rem;overflow-wrap:anywhere;color:#e4e4e7";
|
|
14
|
+
const edit = doc.createElement("button");
|
|
15
|
+
edit.type = "button";
|
|
16
|
+
edit.dataset.rigelEditVideo = "";
|
|
17
|
+
edit.addEventListener("pointerdown", event => event.stopPropagation());
|
|
18
|
+
edit.addEventListener("keydown", event => {
|
|
19
|
+
event.stopPropagation();
|
|
20
|
+
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); edit.click(); }
|
|
21
|
+
});
|
|
22
|
+
edit.textContent = "Edit video";
|
|
23
|
+
edit.hidden = !editable;
|
|
24
|
+
edit.style.cssText = "min-height:44px;padding:0.5rem 0.75rem;border:1px solid #d4d4d8;background:white;color:#18181b;font:inherit;cursor:pointer";
|
|
25
|
+
card.append(heading, link, edit);
|
|
26
|
+
overlay.append(card);
|
|
27
|
+
return overlay;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createEmbedPreview(value: unknown, doc: Document, editable = false): HTMLElement {
|
|
31
|
+
const figure = doc.createElement("figure");
|
|
32
|
+
figure.className = "media-embed";
|
|
33
|
+
figure.style.cssText = "max-width:100%;min-width:0;margin-inline:0";
|
|
34
|
+
const ref = safeEmbed(value);
|
|
35
|
+
if (!ref) return figure;
|
|
36
|
+
const title = typeof (value as { title?: unknown }).title === "string" ? (value as { title: string }).title : "";
|
|
37
|
+
figure.dataset.rigelEmbed = ref.provider;
|
|
38
|
+
figure.dataset.rigelVideoId = ref.id;
|
|
39
|
+
const poster = doc.createElement("div");
|
|
40
|
+
poster.style.cssText = "position:relative;width:100%;min-width:0;aspect-ratio:16/9;min-height:220px;overflow:hidden;background:#18181b";
|
|
41
|
+
if (ref.provider === "youtube") {
|
|
42
|
+
const image = doc.createElement("img");
|
|
43
|
+
image.src = `https://i.ytimg.com/vi/${ref.id}/hqdefault.jpg`;
|
|
44
|
+
image.alt = "";
|
|
45
|
+
image.loading = "lazy";
|
|
46
|
+
image.referrerPolicy = "no-referrer";
|
|
47
|
+
image.style.cssText = "position:absolute;inset:0;width:100%;height:100%;object-fit:cover;margin:0";
|
|
48
|
+
image.onerror = () => { image.hidden = true; };
|
|
49
|
+
poster.append(image);
|
|
50
|
+
}
|
|
51
|
+
const caption = doc.createElement("figcaption");
|
|
52
|
+
caption.textContent = `${embedProviderLabel(ref)} · ${title || "Video"}`;
|
|
53
|
+
caption.style.cssText = "margin-top:0.5rem;font-size:0.875rem;line-height:1.5";
|
|
54
|
+
poster.append(createVideoOverlay(doc, `${embedProviderLabel(ref)} Video`, embedWatchUrl(ref), editable));
|
|
55
|
+
figure.append(poster, caption);
|
|
56
|
+
return figure;
|
|
57
|
+
}
|