@rigel-cms/rigel 1.2.2 → 1.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/docs/content-locales.md +8 -0
  2. package/package.json +1 -1
  3. package/src/editor/App.svelte +52 -28
  4. package/src/editor/i18n/de/fields.json +3 -0
  5. package/src/editor/i18n/de/media.json +9 -1
  6. package/src/editor/i18n/de/shell.json +4 -0
  7. package/src/editor/i18n/en/fields.json +3 -0
  8. package/src/editor/i18n/en/media.json +9 -1
  9. package/src/editor/i18n/en/shell.json +4 -0
  10. package/src/editor/i18n/es/fields.json +3 -0
  11. package/src/editor/i18n/es/media.json +9 -1
  12. package/src/editor/i18n/es/shell.json +4 -0
  13. package/src/editor/i18n/fr/fields.json +3 -0
  14. package/src/editor/i18n/fr/media.json +9 -1
  15. package/src/editor/i18n/fr/shell.json +4 -0
  16. package/src/editor/i18n/it/fields.json +3 -0
  17. package/src/editor/i18n/it/media.json +9 -1
  18. package/src/editor/i18n/it/shell.json +4 -0
  19. package/src/editor/i18n/ja/fields.json +3 -0
  20. package/src/editor/i18n/ja/media.json +9 -1
  21. package/src/editor/i18n/ja/shell.json +4 -0
  22. package/src/editor/i18n/nl/fields.json +3 -0
  23. package/src/editor/i18n/nl/media.json +9 -1
  24. package/src/editor/i18n/nl/shell.json +4 -0
  25. package/src/editor/i18n/ru/fields.json +3 -0
  26. package/src/editor/i18n/ru/media.json +9 -1
  27. package/src/editor/i18n/ru/shell.json +4 -0
  28. package/src/editor/i18n/zh/fields.json +3 -0
  29. package/src/editor/i18n/zh/media.json +9 -1
  30. package/src/editor/i18n/zh/shell.json +4 -0
  31. package/src/editor/lib/ActionDialog.svelte +23 -3
  32. package/src/editor/lib/ImagePicker.svelte +27 -19
  33. package/src/editor/lib/MarkdownEditor.svelte +7 -5
  34. package/src/editor/lib/SchemaField.svelte +2 -2
  35. package/src/editor/lib/SchemaPanel.svelte +76 -4
  36. package/src/editor/lib/VisualEditor.svelte +38 -3
  37. package/src/editor/lib/icons.ts +4 -0
  38. package/src/editor/styles.css +15 -3
  39. package/src/manifest/content-locales.ts +1 -1
  40. package/src/manifest/navigation.ts +4 -3
  41. package/src/manifest/pages.ts +17 -0
  42. package/src/media-types.ts +17 -0
  43. package/src/schema/types.ts +3 -1
  44. package/src/schema/validate.ts +8 -3
@@ -1,4 +1,7 @@
1
1
  {
2
+ "shell.coreCategory": "基础",
3
+ "shell.template": "模板",
4
+ "shell.chooseTemplate": "选择模板",
2
5
  "shell.search": "搜索",
3
6
  "shell.searchShortcut": "⌘/Ctrl + K",
4
7
  "shell.closeDialog": "关闭对话框",
@@ -94,6 +97,7 @@
94
97
  "shell.eraseData": "清除数据",
95
98
  "shell.eraseBrowserDataDescription": "这会清除此浏览器中的 Rigel 设置、API 密钥、GitHub 登录信息和未保存的草稿。已保存的仓库文件不会改变。编辑器将重新加载。",
96
99
  "shell.name": "名称",
100
+ "shell.path": "路径",
97
101
  "shell.slug": "路径标识",
98
102
  "shell.title": "标题",
99
103
  "shell.category": "分类",
@@ -1,9 +1,13 @@
1
1
  <script lang="ts">
2
2
  import { t } from "../i18n";
3
3
  import Icon from "./Icon.svelte";
4
- let { title, description, confirmLabel, fieldLabel, danger = false, onconfirm, onclose }: {
4
+ import Select from "./Select.svelte";
5
+ import { untrack } from "svelte";
6
+ import type { DocumentDef } from "../../schema/types";
7
+ let { title, description, confirmLabel, fieldLabel, danger = false, templates, templateCategory, onconfirm, onclose }: {
5
8
  title: string; description: string; confirmLabel: string; fieldLabel?: string; danger?: boolean;
6
- onconfirm: (value: string) => void | Promise<void>; onclose: () => void;
9
+ templates?: DocumentDef[]; templateCategory?: string;
10
+ onconfirm: (value: string, type?: string) => void | Promise<void>; onclose: () => void;
7
11
  } = $props();
8
12
  let dialog: HTMLDialogElement;
9
13
  let cancel: HTMLButtonElement;
@@ -11,6 +15,11 @@
11
15
  let value = $state("");
12
16
  let busy = $state(false);
13
17
  let error = $state("");
18
+ let category = $state(untrack(() => templateCategory ?? ""));
19
+ let selectedTemplate = $state("");
20
+ const categories = $derived([...new Set((templates ?? []).map(type => type.category ?? ""))].sort());
21
+ const candidates = $derived((templates ?? []).filter(type => (type.category ?? "") === category));
22
+ const template = $derived(candidates.length === 1 ? candidates[0].name : selectedTemplate);
14
23
  $effect(() => {
15
24
  dialog.showModal();
16
25
  (fieldLabel ? input : cancel).focus();
@@ -20,7 +29,10 @@
20
29
  if (busy) return;
21
30
  busy = true;
22
31
  error = "";
23
- try { await onconfirm(value.trim()); onclose(); }
32
+ try {
33
+ if (templates && !candidates.some(type => type.name === template)) throw new Error($t("shell.chooseTemplate"));
34
+ await onconfirm(value.trim(), template); onclose();
35
+ }
24
36
  catch (cause) { error = cause instanceof Error ? cause.message : $t("connection.actionFailed"); }
25
37
  finally { busy = false; }
26
38
  }
@@ -31,6 +43,14 @@
31
43
  <div class="action-head"><h2 id="action-title">{title}</h2><button type="button" class="icon-btn" aria-label={$t("connection.cancel")} disabled={busy} onclick={onclose}><Icon name="x" size={16} /></button></div>
32
44
  <p id="action-description">{description}</p>
33
45
  {#if fieldLabel}<label><span>{fieldLabel}</span><input type="text" bind:this={input} bind:value required disabled={busy} /></label>{/if}
46
+ {#if templates}
47
+ {#if categories.some(category => category !== "")}
48
+ <Select label={$t("shell.category")} value={category} options={categories.map(value => ({ value, label: value || $t("shell.coreCategory") }))} disabled={busy} onchange={(value) => { if (typeof value === "string") { category = value; selectedTemplate = ""; error = ""; } }} />
49
+ {/if}
50
+ {#if candidates.length > 1}
51
+ <Select label={$t("shell.template")} value={selectedTemplate} options={[{ value: "", label: $t("shell.chooseTemplate"), disabled: true }, ...candidates.map(type => ({ value: type.name, label: type.title }))]} disabled={busy} onchange={(value) => { if (typeof value === "string") selectedTemplate = value; }} />
52
+ {/if}
53
+ {/if}
34
54
  {#if error}<p class="error" role="alert">{error}</p>{/if}
35
55
  <div class="action-actions">
36
56
  <button type="button" bind:this={cancel} disabled={busy} onclick={onclose}>{$t("connection.cancel")}</button>
@@ -5,7 +5,7 @@
5
5
  import ActionDialog from "./ActionDialog.svelte";
6
6
  import Select from "./Select.svelte";
7
7
  import { getSettings, setSettings } from "./settings";
8
- import { DEFAULT_MAX_FILE_SIZE, MEDIA_CATEGORIES, mediaMetadata, mediaAccept, safeMediaName } from "../../media-types";
8
+ import { DEFAULT_MAX_FILE_SIZE, MEDIA_CATEGORIES, mediaMetadata, mediaAccept, safeMediaName, matchesMediaAccept, safeFileUrl } from "../../media-types";
9
9
  import config from "virtual:rigel-config";
10
10
  import { parseEmbedUrl, embedWatchUrl, type EmbedProvider } from "./embeds";
11
11
  import type { EmbedData } from "../blocks/embed";
@@ -13,12 +13,13 @@
13
13
  import { filterMedia, modifiedMonth, mediaSize, mediaIcon, assetFile, type MediaAsset } from "./media-library";
14
14
  import { searchImages, importStockImage, importImageUrl, uploadMedia, safeImageUrl, STOCK_PROVIDERS, type StockResult, type StockSource } from "./images";
15
15
 
16
- let { value = "", altText = "", triggerLabel, kind = "image", video, onvideo, embed, onembed, onpick, inline = false, toolbar = false, hiddenTrigger = false, onopen, onclose }: {
16
+ let { value = "", altText = "", triggerLabel, kind = "image", accept, video, onvideo, embed, onembed, onpick, inline = false, toolbar = false, hiddenTrigger = false, onopen, onclose }: {
17
17
  video?: VideoData;
18
18
  onvideo?: (video: VideoData) => void;
19
19
  embed?: EmbedData;
20
20
  onembed?: (embed: EmbedData) => void;
21
- kind?: "image" | "video";
21
+ kind?: "image" | "video" | "file";
22
+ accept?: string;
22
23
  value?: string; onpick?: (url: string, alt: string, credit?: StockResult) => void;
23
24
  altText?: string; triggerLabel?: string;
24
25
  inline?: boolean; toolbar?: boolean; hiddenTrigger?: boolean; onopen?: () => void; onclose?: () => void;
@@ -30,7 +31,7 @@
30
31
  const providerTab = $derived(tab === "youtube" || tab === "vimeo");
31
32
  const editing = $derived(!!embed || !!value);
32
33
  const stockKeys = $derived(open ? getSettings().apiKeys : null);
33
- 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)[]);
34
+ const tabs = $derived((kind === "file" ? ["library", "upload"] : 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)[]);
34
35
  let videoTitle = $state("");
35
36
  let privacy = $state(true);
36
37
  let muted = $state(false);
@@ -73,6 +74,7 @@
73
74
  const months = $derived([...new Set(assets.map(a => modifiedMonth(a.modified)).filter(Boolean))].sort().reverse());
74
75
  function resetFilters() { filter = ""; format = ""; month = ""; }
75
76
  function selectAsset(asset: MediaAsset) {
77
+ if (kind === "file" && (!safeFileUrl(asset.url) || !matchesMediaAccept(asset.url, accept))) { error = $t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }); return; }
76
78
  librarySelection = asset.url; selected = {url: asset.url, asset};
77
79
  metaName = asset.name; metaTitle = asset.title ?? ""; metaDescription = asset.description ?? ""; metaAlt = asset.alt ?? "";
78
80
  alt = value ? altText : asset.alt ?? "";
@@ -117,7 +119,7 @@
117
119
  const response = await fetch("/rigel/media.json", { cache: "no-store", headers: { "Accept-Language": $locale } });
118
120
  if (!response.ok) throw new Error($t("media.listingUnavailable"));
119
121
  const data = await response.json();
120
- assets = (data.assets ?? data.images).filter((asset: MediaAsset) => inline || asset.type?.startsWith(kind === "video" ? "video/" : "image/"));
122
+ assets = (data.assets ?? data.images).filter((asset: MediaAsset) => inline || (kind === "file" ? safeFileUrl(asset.url) && matchesMediaAccept(asset.url, accept) : asset.type?.startsWith(kind === "video" ? "video/" : "image/")));
121
123
  writable = data.writable === true;
122
124
  folder = data.folder;
123
125
  }
@@ -162,11 +164,13 @@
162
164
  if (!selected || picking) return;
163
165
  const request = ++requestId;
164
166
  const choice = selected;
167
+ if (kind === "file" && !matchesMediaAccept(choice.file?.name ?? choice.url, accept)) { error = $t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }); return; }
165
168
  const metadata = metadataImport ? { title: metaTitle.trim(), description: metaDescription.trim(), ...(imageImport ? { alt: alt.trim() } : {}) } : null;
166
169
  picking = true; error = "";
167
170
  try {
168
171
  const url = choice.file ? await uploadMedia(choice.file) : choice.stock ? await importStockImage(choice.stock) : needsImport ? await importImageUrl(choice.url) : choice.url;
169
172
  if (!safeImageUrl(url)) throw new Error($t("media.unsafeMediaUrl"));
173
+ if (kind === "file" && (!safeFileUrl(url) || !matchesMediaAccept(url, accept))) throw new Error($t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }));
170
174
  if (metadata) {
171
175
  const saved = await fetch("/rigel/api/local/media", { method: "PUT", headers: { "Content-Type": "application/json", "Accept-Language": $locale }, body: JSON.stringify({ url, ...metadata }) });
172
176
  if (!saved.ok) throw new Error($t("media.uploadedMetadataFailed", { status: saved.status }));
@@ -179,15 +183,16 @@
179
183
  else onpick?.(url, alt, choice.stock);
180
184
  close();
181
185
  }
182
- } catch (e) { if (request === requestId) error = e instanceof Error ? e.message : $t("media.photoImportFailed"); }
186
+ } catch (e) { if (request === requestId) error = e instanceof Error ? e.message : $t(kind === "file" ? "media.uploadFailedGeneric" : "media.photoImportFailed"); }
183
187
  finally { if (request === requestId) picking = false; }
184
188
  }
185
189
  function upload(file?: File) {
186
190
  if (!file || picking) return;
187
191
  error = "";
188
192
  const limit = maxFileSize;
189
- if (!safeMediaName(file.name) || (!inline && mediaMetadata(file.name)?.category !== (kind === "video" ? "Video" : "Images")) || file.size > limit) {
190
- error = $t(inline ? "media.chooseSupportedFile" : kind === "video" ? "media.chooseSupportedVideo" : "media.chooseSupportedImage", { limit: limit / 1024 / 1024 }); return;
193
+ if (kind === "file" && !matchesMediaAccept(file.name, accept)) { error = $t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }); return; }
194
+ if (!safeMediaName(file.name) || (!inline && kind !== "file" && mediaMetadata(file.name)?.category !== (kind === "video" ? "Video" : "Images")) || file.size > limit) {
195
+ error = $t(inline || kind === "file" ? "media.chooseSupportedFile" : kind === "video" ? "media.chooseSupportedVideo" : "media.chooseSupportedImage", { limit: limit / 1024 / 1024 }); return;
191
196
  }
192
197
  const type = mediaMetadata(file.name)!.type;
193
198
  releaseFile(); objectURL = URL.createObjectURL(new Blob([file], { type: type === "text/plain" ? "text/plain; charset=utf-8" : type }));
@@ -225,9 +230,11 @@
225
230
  <button bind:this={trigger} data-image-trigger={kind === "image" ? "" : undefined} type="button" aria-label={triggerLabel ?? $t("media.insertImage")} data-tooltip={triggerLabel ?? $t("media.insertImage")} hidden={hiddenTrigger} onmousedown={event => event.preventDefault()} onclick={launch}><Icon name="image" size={18} /></button>
226
231
  {:else}
227
232
  <div class="image-field">
228
- {#if value}<img src={value} alt="" class="preview" />{:else}<div class="preview empty">{$t("media.noImage")}</div>{/if}
233
+ {#if kind === "file"}
234
+ {#if value && safeFileUrl(value)}<a href={value} download>{assetFile(value)}</a>{:else}<span>{$t("media.noFile")}</span>{/if}
235
+ {:else if value}<img src={value} alt="" class="preview" />{:else}<div class="preview empty">{$t("media.noImage")}</div>{/if}
229
236
  <button bind:this={trigger} type="button" class="outlined" onclick={launch}>{$t("media.choose")}</button>
230
- {#if value && onpick}<button type="button" class="outlined" aria-label={$t("media.removeImage")} data-tooltip={$t("media.removeImage")} onclick={() => onpick?.("", "")}><Icon name="x" size={14} /></button>{/if}
237
+ {#if value && onpick}<button type="button" class="outlined icon-only" aria-label={$t(kind === "file" ? "media.removeFile" : "media.removeImage")} data-tooltip={$t(kind === "file" ? "media.removeFile" : "media.removeImage")} onclick={() => onpick?.("", "")}><Icon name="x" size={14} /></button>{/if}
231
238
  </div>
232
239
  {/if}
233
240
 
@@ -235,11 +242,11 @@
235
242
  <p class="hint">{$t(writable ? "media.repositoryWritable" : "media.repositorySnapshot", { folder: folder || "…" })}</p>
236
243
  <div class="media-tools">
237
244
  <div class="media-views" role="group" aria-label={$t("media.view")}>
238
- <button type="button" class="outlined" data-tooltip={$t("media.gridView")} aria-label={$t("media.gridView")} aria-pressed={view === "grid"} onclick={() => setView("grid")}><Icon name="squaresFour" size={18} /></button>
239
- <button type="button" class="outlined" data-tooltip={$t("media.listView")} aria-label={$t("media.listView")} aria-pressed={view === "list"} onclick={() => setView("list")}><Icon name="listBullets" size={18} /></button>
245
+ <button type="button" class="outlined icon-only" data-tooltip={$t("media.gridView")} aria-label={$t("media.gridView")} aria-pressed={view === "grid"} onclick={() => setView("grid")}><Icon name="squaresFour" size={18} /></button>
246
+ <button type="button" class="outlined icon-only" data-tooltip={$t("media.listView")} aria-label={$t("media.listView")} aria-pressed={view === "list"} onclick={() => setView("list")}><Icon name="listBullets" size={18} /></button>
240
247
  </div>
241
248
  <div class="media-select">
242
- <Select ariaLabel={$t(inline ? "media.type" : kind === "video" ? "media.videoFormat" : "media.imageFormat")} value={format} onchange={next => { format = String(next); }} options={inline ? [{ value: "", label: $t("media.allItems") }, ...MEDIA_CATEGORIES.map(category => ({ value: category, label: $t(`media.category.${category}`) }))] : kind === "video" ? [{ value: "", label: $t("media.allVideos") }, { value: "video/mp4", label: "MP4" }, { value: "video/webm", label: "WebM" }, { value: "video/quicktime", label: "MOV" }] : [{ value: "", label: $t("media.allImages") }, ...["jpeg", "png", "webp", "gif", "avif"].map(format => ({ value: `image/${format}`, label: format === "jpeg" ? "JPEG" : format === "webp" ? "WebP" : format.toUpperCase() }))]} />
249
+ <Select ariaLabel={$t(inline || kind === "file" ? "media.type" : kind === "video" ? "media.videoFormat" : "media.imageFormat")} value={format} onchange={next => { format = String(next); }} options={inline || kind === "file" ? [{ value: "", label: $t("media.allItems") }, ...MEDIA_CATEGORIES.map(category => ({ value: category, label: $t(`media.category.${category}`) }))] : kind === "video" ? [{ value: "", label: $t("media.allVideos") }, { value: "video/mp4", label: "MP4" }, { value: "video/webm", label: "WebM" }, { value: "video/quicktime", label: "MOV" }] : [{ value: "", label: $t("media.allImages") }, ...["jpeg", "png", "webp", "gif", "avif"].map(format => ({ value: `image/${format}`, label: format === "jpeg" ? "JPEG" : format === "webp" ? "WebP" : format.toUpperCase() }))]} />
243
250
  </div>
244
251
  <div class="media-select">
245
252
  <Select ariaLabel={$t("media.modifiedMonth")} data-tooltip={$t("media.modifiedMonthHint")} value={month} onchange={next => { month = String(next); }} options={[{ value: "", label: $t("media.allModifiedDates") }, ...months.map(date => ({ value: date, label: new Date(date + "-01T00:00:00Z").toLocaleDateString($locale, { month: "long", year: "numeric", timeZone: "UTC" }) }))]} />
@@ -261,9 +268,9 @@
261
268
  {/snippet}
262
269
 
263
270
  {#if open}
264
- <dialog bind:this={dialog} class="rigel-modal-backdrop rigel image-dialog" class:media-insert={providerTab} aria-label={$t(providerTab ? editing ? "media.editVideo" : "media.addVideoLink" : inline ? "media.uploadMedia" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")} oncancel={e => { e.preventDefault(); if (!picking) close(); }} onclick={e => { if (e.target === dialog && !picking) close(); }}>
271
+ <dialog bind:this={dialog} class="rigel-modal-backdrop rigel image-dialog" class:media-insert={providerTab} aria-label={$t(providerTab ? editing ? "media.editVideo" : "media.addVideoLink" : inline ? "media.uploadMedia" : kind === "file" ? "media.chooseFile" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")} oncancel={e => { e.preventDefault(); if (!picking) close(); }} onclick={e => { if (e.target === dialog && !picking) close(); }}>
265
272
  <div class="rigel-modal image-picker">
266
- <div class="media-head"><h2>{providerTab ? $t(editing ? "media.editProviderVideo" : "media.addProviderVideo", { provider: tab === "youtube" ? "YouTube" : "Vimeo" }) : $t(selected ? "media.previewMedia" : inline ? "media.uploadMedia" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")}</h2><button bind:this={cancel} type="button" class="close" aria-label={$t("media.close")} disabled={picking} onclick={close}><Icon name="x" size={14} weight="bold" /></button></div>
273
+ <div class="media-head"><h2>{providerTab ? $t(editing ? "media.editProviderVideo" : "media.addProviderVideo", { provider: tab === "youtube" ? "YouTube" : "Vimeo" }) : $t(selected ? "media.previewMedia" : inline ? "media.uploadMedia" : kind === "file" ? "media.chooseFile" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")}</h2><button bind:this={cancel} type="button" class="close" aria-label={$t("media.close")} disabled={picking} onclick={close}><Icon name="x" size={14} weight="bold" /></button></div>
267
274
  {#if selected}
268
275
  <div class="media-selection" class:media-editing={(inline && !!selected.asset) || metadataImport}>
269
276
  <div class="media-preview">
@@ -292,7 +299,7 @@
292
299
  <label><span>{$t(kind === "video" ? "media.videoUrl" : "media.imageUrl")}</span><input type="text" readonly value={selected.url} /></label>
293
300
  {/if}
294
301
  {#if imageImport && !selected.file}<label><span>{$t("media.imageUrl")}</span><input type="text" readonly value={selected.url} /></label>{/if}
295
- {#if !inline && !imageImport}<label><span>{$t(kind === "video" ? "media.captionOptional" : "media.descriptiveAlt")}</span><input aria-label={kind === "video" ? $t("media.videoCaption") : undefined} type="text" bind:value={alt} /></label>{/if}
302
+ {#if !inline && kind !== "file" && !imageImport}<label><span>{$t(kind === "video" ? "media.captionOptional" : "media.descriptiveAlt")}</span><input aria-label={kind === "video" ? $t("media.videoCaption") : undefined} type="text" bind:value={alt} /></label>{/if}
296
303
  {#if kind === "video"}
297
304
  <label class="inline"><input type="checkbox" role="switch" class="switch" aria-label={$t("media.muted")} bind:checked={muted} /><span>{$t("media.muted")}</span></label>
298
305
  <label class="inline"><input type="checkbox" role="switch" class="switch" aria-label={$t("media.showControls")} bind:checked={controls} /><span>{$t("media.showControls")}</span></label>
@@ -313,12 +320,12 @@
313
320
  <button type="button" class="primary" disabled={picking || savingMeta} onclick={saveMetadata}>{$t(savingMeta ? "media.saving" : "media.save")}</button>
314
321
  {:else}
315
322
  <button type="button" class="outlined" disabled={picking} onclick={() => { selected = null; releaseFile(); }}>{$t("media.back")}</button>
316
- {#if inline && tab === "library"}<button type="button" class="primary" onclick={close}>{$t("media.done")}</button>{:else}<button type="button" class="primary" disabled={picking || (needsImport && !writable)} onclick={confirm}>{$t(picking ? kind === "video" ? "media.uploadingVideo" : "media.importingPhoto" : kind === "video" ? "media.useVideo" : !inline ? "media.insertImage" : selected.file && mediaMetadata(selected.file.name)?.category !== "Images" ? "media.uploadFile" : "media.importImage")}</button>{/if}
323
+ {#if inline && tab === "library"}<button type="button" class="primary" onclick={close}>{$t("media.done")}</button>{:else}<button type="button" class="primary" disabled={picking || (needsImport && !writable)} onclick={confirm}>{$t(picking ? kind === "file" ? "media.uploadingFile" : kind === "video" ? "media.uploadingVideo" : "media.importingPhoto" : kind === "file" ? selected.file ? "media.uploadFile" : "media.useFile" : kind === "video" ? "media.useVideo" : !inline ? "media.insertImage" : selected.file && mediaMetadata(selected.file.name)?.category !== "Images" ? "media.uploadFile" : "media.importImage")}</button>{/if}
317
324
  {/if}</div>
318
325
  </div>
319
326
  </div>
320
327
  {:else}
321
- <div class="tabs" aria-label={$t(kind === "video" ? "media.videoSources" : "media.imageSources")}>
328
+ <div class="tabs" aria-label={$t(kind === "file" ? "media.fileSources" : kind === "video" ? "media.videoSources" : "media.imageSources")}>
322
329
  {#each tabs.filter(source => !inline || (source !== "library" && source !== "unsplash")) as source}<button type="button" data-source={source} class:active={tab === source} aria-pressed={tab === source} disabled={picking} onclick={() => changeTab(source)}>{source === "youtube" ? "YouTube" : source === "vimeo" ? "Vimeo" : source === "library" || source === "upload" || source === "url" ? $t(`media.source.${source}`) : source}</button>{/each}
323
330
  </div>
324
331
  {#if inline}<p class="hint">{$t("media.unsplashUploadsHint")}</p>{/if}
@@ -335,7 +342,8 @@
335
342
  {@render libraryBrowser()}
336
343
  {:else if tab === "upload"}
337
344
  <p class="hint">{$t("media.localFileHint")}</p>
338
- {#if writable}<label><span>{$t(kind === "video" ? "media.uploadVideoLimit" : inline ? "media.uploadMediaLimit" : "media.uploadImageLimit", { limit: maxFileSize / 1024 / 1024 })}</span><input type="file" accept={kind === "video" ? ".mp4,.webm,.mov" : mediaAccept(!inline)} onchange={e => upload(e.currentTarget.files?.[0])} /></label>{:else}<p role="status">{$t("media.readOnlyUploads")}</p>{/if}
345
+ {#if kind === "file" && accept}<p class="hint">{$t("media.chooseAcceptedFile", { accept })}</p>{/if}
346
+ {#if writable}<label><span>{$t(kind === "video" ? "media.uploadVideoLimit" : inline || kind === "file" ? "media.uploadMediaLimit" : "media.uploadImageLimit", { limit: maxFileSize / 1024 / 1024 })}</span><input type="file" accept={kind === "file" ? accept ?? mediaAccept() : kind === "video" ? ".mp4,.webm,.mov" : mediaAccept(!inline)} onchange={e => upload(e.currentTarget.files?.[0])} /></label>{:else}<p role="status">{$t("media.readOnlyUploads")}</p>{/if}
339
347
  {:else if tab === "url"}
340
348
  <label><span>{$t(kind === "video" ? "media.videoUrl" : "media.imageUrl")}</span><input type="url" bind:value={urlInput} placeholder={$t(kind === "video" ? "media.videoUrlPlaceholder" : "media.imageUrlPlaceholder")} /></label>
341
349
  <button type="button" class="primary" onclick={run}>{$t("media.useUrl")}</button>
@@ -14,7 +14,7 @@
14
14
  import type { SelectionBookmark } from "prosemirror-state";
15
15
  import { parseVisualMarkdown, serializeMarkdown, safeMarkdownUrl, schema } from "./rich-markdown";
16
16
 
17
- let { value, label, oninput }: { value: string; label: string; oninput: (value: string) => void } = $props();
17
+ let { value, label, oninput, editableRoot }: { value: string; label: string; oninput: (value: string) => void; editableRoot?: HTMLElement } = $props();
18
18
  const id = $props.id();
19
19
  let host: HTMLDivElement;
20
20
  let mode = $state<"visual" | "source">("visual");
@@ -66,9 +66,10 @@
66
66
  }
67
67
 
68
68
  $effect(() => {
69
- if (!host) return;
69
+ if (!editableRoot && !host) return;
70
70
  if (!view) {
71
- view = new EditorView(host, {
71
+ if (editableRoot) editableRoot.classList.add("rigel-markdown-content");
72
+ view = new EditorView(editableRoot ? { mount: editableRoot } : host, {
72
73
  state: EditorState.create({ schema }),
73
74
  attributes: { role: "textbox", "aria-label": label, "aria-multiline": "true" },
74
75
  dispatchTransaction(transaction) {
@@ -92,6 +93,7 @@
92
93
  load(value);
93
94
  }
94
95
  });
96
+ $effect(() => { const editable = mode === "visual"; view?.setProps({ editable: () => editable }); });
95
97
  onDestroy(() => view?.destroy());
96
98
 
97
99
  function activeMark(type: string) {
@@ -162,7 +164,7 @@
162
164
 
163
165
  <svelte:window onpointerdown={event => { if (linkOpen && !document.getElementById(`${id}-link`)?.contains(event.target as Node) && !linkButton?.contains(event.target as Node)) linkOpen = false; }} />
164
166
 
165
- <div class="markdown-editor body-editor">
167
+ <div class="markdown-editor body-editor" class:markdown-inline={!!editableRoot}>
166
168
  <div class="markdown-heading">
167
169
  <div class="field-label" id={`${id}-label`}>{label}</div>
168
170
  <div class="markdown-modes" role="group" aria-label={$t("richtext.editingMode", { label })}>
@@ -199,7 +201,7 @@
199
201
  {#if linkError}<p id={`${id}-error`} role="alert">{linkError}</p>{/if}
200
202
  </div>
201
203
  {/if}
202
- <div class="md-preview rich-text" bind:this={host}></div>
204
+ {#if !editableRoot}<div class="md-preview rich-text" bind:this={host}></div>{/if}
203
205
  </div>
204
206
  {#if mode === "source"}
205
207
  <textarea class="mono" rows="14" aria-label={$t("richtext.markdownSource", { label })} value={value} oninput={(event) => { warning = ""; emit(event.currentTarget.value); }}></textarea>
@@ -86,10 +86,10 @@
86
86
  </label>
87
87
  {:else if field.type === "markdown"}
88
88
  <MarkdownEditor label={fieldLabel} value={typeof value === "string" ? value : ""} oninput={(next) => onvalue(next)} />
89
- {:else if field.type === "image"}
89
+ {:else if field.type === "image" || field.type === "file"}
90
90
  <div class="schema-field">
91
91
  <span class="schema-label">{label}</span>
92
- <ImagePicker value={typeof value === "string" ? value : ""} onpick={(url) => onvalue(url)} />
92
+ <ImagePicker kind={field.type} accept={field.accept} value={typeof value === "string" ? value : ""} onpick={(url) => onvalue(url)} />
93
93
  </div>
94
94
  {:else if field.type === "boolean"}
95
95
  <label class="inline">
@@ -5,7 +5,7 @@
5
5
  import PropertiesTabs from "./PropertiesTabs.svelte";
6
6
  import SeoGuidance from "./SeoGuidance.svelte";
7
7
  import Select from "./Select.svelte";
8
- import { tick } from "svelte";
8
+ import { onDestroy, tick } from "svelte";
9
9
  import {
10
10
  blankItem,
11
11
  duplicateItem,
@@ -89,8 +89,79 @@
89
89
  function removeSection(index: number) {
90
90
  setSections(sections.filter((_, position) => position !== index));
91
91
  }
92
+
93
+ let dragging = $state<string | null>(null);
94
+ let target = $state<number | null>(null);
95
+ let message = $state("");
96
+ let pointer: { key: string; docId: string; pointerId: number; x: number; y: number; handle: HTMLButtonElement } | null = null;
97
+
98
+ function cancelDrag() {
99
+ const active = pointer;
100
+ pointer = null;
101
+ dragging = null;
102
+ target = null;
103
+ if (active?.handle.hasPointerCapture(active.pointerId)) active.handle.releasePointerCapture(active.pointerId);
104
+ }
105
+ onDestroy(cancelDrag);
106
+
107
+ function startDrag(event: PointerEvent, key: string) {
108
+ if (event.button !== 0 || !event.isPrimary || pointer) return;
109
+ const handle = event.currentTarget as HTMLButtonElement;
110
+ pointer = { key, docId, pointerId: event.pointerId, x: event.clientX, y: event.clientY, handle };
111
+ handle.setPointerCapture(event.pointerId);
112
+ }
113
+
114
+ function dropAt(x: number, y: number) {
115
+ const list = document.elementFromPoint(x, y)?.closest('.structure-list');
116
+ const source = pointer?.handle.closest('li');
117
+ if (!list || source?.parentElement !== list || pointer?.docId !== docId) return null;
118
+ const rows = [...list.children];
119
+ const before = rows.findIndex(row => { const rect = row.getBoundingClientRect(); return y < rect.top + rect.height / 2; });
120
+ const insertion = before < 0 ? rows.length : before;
121
+ const from = sections.findIndex(section => section._key === pointer?.key);
122
+ return from < 0 || insertion === from || insertion === from + 1 ? null : insertion;
123
+ }
124
+
125
+ function dragPointer(event: PointerEvent) {
126
+ if (!pointer || event.pointerId !== pointer.pointerId) return;
127
+ if (!dragging && Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) < 5) return;
128
+ dragging = pointer.key;
129
+ target = dropAt(event.clientX, event.clientY);
130
+ }
131
+
132
+ function finishDrag(event: PointerEvent) {
133
+ if (!pointer || event.pointerId !== pointer.pointerId) return;
134
+ const captured = pointer.handle.hasPointerCapture(event.pointerId);
135
+ const from = captured ? sections.findIndex(section => section._key === dragging) : -1;
136
+ const insertion = dropAt(event.clientX, event.clientY);
137
+ cancelDrag();
138
+ if (from < 0 || insertion === null) return;
139
+ const next = [...sections];
140
+ const [section] = next.splice(from, 1);
141
+ const index = insertion > from ? insertion - 1 : insertion;
142
+ next.splice(index, 0, section);
143
+ setSections(next);
144
+ message = $t("fields.sectionMoved", { label: sectionLabel(schema, section._type), position: index + 1, count: next.length });
145
+ }
146
+
147
+ async function moveWithKeyboard(event: KeyboardEvent, index: number) {
148
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
149
+ event.preventDefault();
150
+ if (pointer) return;
151
+ const delta = event.key === "ArrowUp" ? -1 : 1;
152
+ const position = index + delta;
153
+ if (position < 0 || position >= sections.length) return;
154
+ const handle = event.currentTarget as HTMLButtonElement;
155
+ const label = sectionLabel(schema, sections[index]._type);
156
+ setSections(moveItem(sections, index, delta) as Record<string, unknown>[]);
157
+ message = $t("fields.sectionMoved", { label, position: position + 1, count: sections.length });
158
+ await tick();
159
+ handle.focus({ preventScroll: true });
160
+ }
92
161
  </script>
93
162
 
163
+ <svelte:window onpointermove={dragPointer} onpointerup={finishDrag} onpointercancel={cancelDrag} onkeydown={event => { if (event.key === 'Escape' && pointer) { event.preventDefault(); cancelDrag(); message = $t('fields.menuMoveCancelled'); } }} />
164
+
94
165
  <div class="schema-panel" bind:this={host}>
95
166
  {#if docType.shared}
96
167
  <p class="shared-badge"><Icon name="info" size={12} /> {$t(docType.demo ? "fields.demoContentNotice" : "fields.sharedContentNotice")}</p>
@@ -115,16 +186,17 @@
115
186
  {#if sections.length || values.sections !== undefined}
116
187
  <section>
117
188
  <h3>{$t("fields.structure")}</h3>
189
+ <p id="section-reorder-hint" class="reorder-status">{$t("fields.sectionReorderHint")}</p>
190
+ <p class="reorder-status" role="status" aria-live="polite">{message}</p>
118
191
  <ul class="structure-list">
119
192
  {#each sections as section, index (section._key)}
120
- <li class:active={selected?.sectionKey === section._key}>
193
+ <li class:active={selected?.sectionKey === section._key} class:is-dragging={dragging === section._key} class:drop-before={target === index} class:drop-after={target === sections.length && index === sections.length - 1} data-section-key={section._key}>
194
+ <button type="button" class="outlined icon-only section-drag" aria-label={$t("fields.reorderSection", { label: sectionLabel(schema, section._type) })} aria-describedby="section-reorder-hint" onpointerdown={event => startDrag(event, String(section._key))} onlostpointercapture={cancelDrag} onkeydown={event => moveWithKeyboard(event, index)}><Icon name="dotsSixVertical" /></button>
121
195
  <button type="button" class="structure-row" onclick={() => onselect({ docId, path: "", sectionKey: String(section._key) })}>
122
196
  <span>{sectionLabel(schema, section._type, $t("fields.section"))}</span>
123
197
  {#if devMode}<span class="schema-key">{section._key}</span>{/if}
124
198
  </button>
125
199
  <div class="structure-actions">
126
- <button type="button" aria-label={$t("fields.moveSectionUp", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.moveUp")} disabled={index === 0} onclick={() => setSections(moveItem(sections, index, -1) as Record<string, unknown>[])}>↑</button>
127
- <button type="button" aria-label={$t("fields.moveSectionDown", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.moveDown")} disabled={index === sections.length - 1} onclick={() => setSections(moveItem(sections, index, 1) as Record<string, unknown>[])}>↓</button>
128
200
  <button type="button" aria-label={$t("fields.duplicateSection", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.duplicate")} onclick={() => setSections(duplicateItem(sections, index) as Record<string, unknown>[])}>⧉</button>
129
201
  <button type="button" aria-label={$t("fields.removeSection", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.remove")} onclick={() => removeSection(index)}>×</button>
130
202
  </div>
@@ -2,6 +2,7 @@
2
2
  import { t } from "../i18n";
3
3
  import { mount, unmount, onDestroy, tick, type Snippet } from "svelte";
4
4
  import BlockEditor from "./BlockEditor.svelte";
5
+ import MarkdownEditor from "./MarkdownEditor.svelte";
5
6
  import { tooltipRoot } from "./tooltip";
6
7
  import editorStyles from "../styles.css?inline";
7
8
  import type { ArticleBody } from "../../article-body";
@@ -51,7 +52,7 @@
51
52
  let bodyScope: HTMLElement | null = null;
52
53
  let removeTooltips: (() => void) | undefined;
53
54
  let mode = $state<"browse" | "edit">("edit");
54
- let panelOpen = $state(true);
55
+ let panelOpen = $state(false);
55
56
  let editorWidth = $state(0);
56
57
  let requestedWidth = $state(340);
57
58
  let resizing = $state(false);
@@ -248,11 +249,42 @@
248
249
  }
249
250
  if (editVideo) mounted.editVideoAt(blockIndex);
250
251
  }
252
+ function startMarkdownEdit(element: HTMLElement) {
253
+ const field = fieldPathFrom(element);
254
+ if (!field || field.doc !== docId || !schema || !docType) return false;
255
+ const def = fieldForPath(schema, docType, values, field.path);
256
+ if (def?.type !== "markdown") return false;
257
+ stopBodyEdit();
258
+ const doc = element.ownerDocument;
259
+ let editorStyle = doc.querySelector<HTMLStyleElement>('style[data-rigel-editor-styles]');
260
+ if (!editorStyle) { editorStyle = doc.createElement("style"); editorStyle.dataset.rigelEditorStyles = ""; doc.head.append(editorStyle); }
261
+ if (editorStyle.textContent !== editorStyles) editorStyle.textContent = editorStyles;
262
+ clearHover(); clearHighlight();
263
+ editingBody = element; bodyContents = [...element.childNodes];
264
+ const scope = doc.createElement("div");
265
+ bodyAttributes = ["class", "style", "role", "aria-label", "aria-multiline", "contenteditable", "tabindex", "translate"].map(name => [name, element.getAttribute(name)]);
266
+ const syncTheme = () => { scope.dataset.theme = document.documentElement.dataset.theme; scope.dataset.contrast = document.documentElement.dataset.contrast; };
267
+ syncTheme();
268
+ const host = doc.createElement("div"); host.className = "rigel rigel-inline";
269
+ scope.append(host); doc.body.append(scope); bodyScope = scope;
270
+ removeTooltips = tooltipRoot(host).destroy;
271
+ bodyEditor = mount(MarkdownEditor, { target: host, props: {
272
+ editableRoot: element,
273
+ label: def.labelKey ? $t(def.labelKey) : def.label,
274
+ get value() { return String(anchorValue(values, field.path).value ?? ""); },
275
+ oninput: (next: string) => onchange(replaceAt(values, field.path, next)),
276
+ } });
277
+ bodyThemeObserver = new MutationObserver(syncTheme);
278
+ bodyThemeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
279
+ void tick().then(() => (element.isContentEditable ? element : host.querySelector<HTMLTextAreaElement>("textarea"))?.focus());
280
+ return true;
281
+ }
282
+
251
283
  function focusBody() {
252
284
  const target = docElement()?.querySelector<HTMLElement>('[data-rigel-richtext="$body"]');
253
285
  if (inlineBodyEnabled && target) { target.scrollIntoView({ block: "center" }); void startBodyEdit(target); }
254
286
  }
255
- $effect(() => { if (!inlineBodyEnabled || mode !== "edit") stopBodyEdit(); });
287
+ $effect(() => { if ((!schema && !inlineBodyEnabled) || mode !== "edit") stopBodyEdit(); });
256
288
  onDestroy(stopBodyEdit);
257
289
 
258
290
  function onClick(event: MouseEvent) {
@@ -264,6 +296,9 @@
264
296
  if (!target) return;
265
297
  const articleBody = target.closest<HTMLElement>('[data-rigel-richtext="$body"]');
266
298
  if (inlineBodyEnabled && articleBody) { void startBodyEdit(articleBody, event); return; }
299
+ const markdown = target.closest<HTMLElement>("[data-rigel-richtext]");
300
+ if (markdown && startMarkdownEdit(markdown)) return;
301
+ stopBodyEdit();
267
302
  const element = target.closest<HTMLElement>("[data-rigel-tags],[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
268
303
  if (element) selectFromElement(element);
269
304
  }
@@ -360,7 +395,7 @@
360
395
 
361
396
  function setMode(next: "browse" | "edit") {
362
397
  mode = next;
363
- panelOpen = next === "edit";
398
+ panelOpen = false;
364
399
  if (next === "browse") {
365
400
  clearHighlight();
366
401
  clearHover();
@@ -78,6 +78,10 @@ export const ICONS = {
78
78
  viewBox: "0 0 256 256",
79
79
  d: "M128,96a32,32,0,1,0,32,32A32,32,0,0,0,128,96Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,128,144Zm0-64A32,32,0,1,0,96,48,32,32,0,0,0,128,80Zm0-48a16,16,0,1,1-16,16A16,16,0,0,1,128,32Zm0,144a32,32,0,1,0,32,32A32,32,0,0,0,128,176Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,128,224Z",
80
80
  },
81
+ dotsSixVertical: {
82
+ viewBox: "0 0 256 256",
83
+ d: "M104,60A12,12,0,1,1,92,48,12,12,0,0,1,104,60Zm60,12a12,12,0,1,0-12-12A12,12,0,0,0,164,72ZM92,116a12,12,0,1,0,12,12A12,12,0,0,0,92,116Zm72,0a12,12,0,1,0,12,12A12,12,0,0,0,164,116ZM92,184a12,12,0,1,0,12,12A12,12,0,0,0,92,184Zm72,0a12,12,0,1,0,12,12A12,12,0,0,0,164,184Z",
84
+ },
81
85
  keyboard: {
82
86
  viewBox: "0 0 256 256",
83
87
  d: "M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z",
@@ -849,7 +849,8 @@
849
849
  .rigel .media-head h2 { margin: 0; font-size: 1rem; }
850
850
  .rigel .media-tools { align-items: center; flex-wrap: wrap; gap: 0.5rem; padding: 0.75rem; border: 1px solid var(--border-control); background: var(--panel); }
851
851
  .rigel .media-views { display: flex; flex-shrink: 0; margin-inline-end: 1rem; }
852
- .rigel .media-views button { display: inline-flex; align-items: center; justify-content: center; padding-inline: 0.5rem; }
852
+ .rigel button.outlined.icon-only, .rigel :is(.structure-actions, .schema-item-actions) button { display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; padding: 0; flex-shrink: 0; }
853
+ @media (pointer: coarse) { .rigel button.outlined.icon-only, .rigel :is(.structure-actions, .schema-item-actions) button { width: 44px; height: 44px; } }
853
854
  .rigel .media-views button + button { border-left: 0; }
854
855
  .rigel .media-views button[aria-pressed=true] { background: var(--accent-soft); color: var(--on-accent-soft); }
855
856
  .rigel .media-tools .media-select { position: relative; max-width: 100%; min-width: 0; }
@@ -1182,6 +1183,9 @@
1182
1183
  .rigel.rigel-inline .markdown-link, .rigel .block-editor .markdown-link { position: fixed; inset: auto; width: 340px; max-width: calc(100vw - 16px); z-index: 121; }
1183
1184
  .rigel.rigel-inline .block-markdown-view { position: fixed; inset: 12px; max-height: calc(100vh - 24px); overflow: auto; padding: 0.5rem; border: 1px solid var(--border-control); background: var(--panel); }
1184
1185
  .rigel-article-content:focus { outline: 0; }
1186
+ .rigel.rigel-inline .markdown-inline { position: fixed; top: 8px; left: 8px; width: max-content; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow: auto; background: var(--panel); border: 1px solid var(--border-control); box-shadow: var(--shadow-lg); pointer-events: auto; }
1187
+ .rigel .markdown-editor [hidden] { display: none !important; }
1188
+ .rigel-markdown-content { white-space: pre-wrap; overflow-wrap: anywhere; }
1185
1189
  :is(.rigel .block-content, .rigel-article-content)::highlight(rigel-link-selection), :is(.rigel .block-content, .rigel-article-content) ::highlight(rigel-link-selection) { background-color: Highlight; color: HighlightText; }
1186
1190
  .rigel-article-content:focus-visible > [data-rigel-active-block] { outline: 3px solid Highlight; outline-offset: 3px; }
1187
1191
  .rigel-article-content .block-bold { font-weight: 700; }
@@ -1305,11 +1309,19 @@
1305
1309
  .rigel .panel-note { margin: 0 0 0.75rem; font-size: 0.75rem; color: var(--text-3); }
1306
1310
  .rigel .shared-badge { display: flex; align-items: center; gap: 0.375rem; margin: 0 0 0.75rem; padding: 0.5rem; border: 1px solid var(--accent); color: var(--text-2); font-size: 0.75rem; }
1307
1311
  .rigel .structure-list { list-style: none; margin: 0 0 0.75rem; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
1308
- .rigel .structure-list li { display: flex; align-items: stretch; border: 1px solid var(--border); }
1312
+ .rigel .structure-list li { position: relative; display: flex; align-items: stretch; border: 1px solid var(--border); }
1313
+ .rigel .structure-list .section-drag { cursor: grab; touch-action: none; user-select: none; align-self: center; border: 0; background: transparent; }
1314
+ .rigel .structure-list .section-drag:active { cursor: grabbing; }
1315
+ .rigel .structure-list li.is-dragging { border-style: dashed; }
1316
+ .rigel .structure-list li.drop-before::before, .rigel .structure-list li.drop-after::after { content: ""; position: absolute; left: -1px; right: -1px; height: 3px; background: var(--accent); pointer-events: none; }
1317
+ .rigel .structure-list li.drop-before::before { top: -5px; }
1318
+ .rigel .structure-list li.drop-after::after { bottom: -5px; }
1319
+ .rigel .reorder-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
1309
1320
  .rigel .structure-list li.active { border-color: var(--accent); }
1310
1321
  .rigel .structure-row { flex: 1; min-width: 0; display: flex; justify-content: space-between; gap: 0.5rem; align-items: center; border: 0; border-radius: 0; background: transparent; color: var(--text); padding: 0.5rem; text-align: left; cursor: pointer; font-size: 0.8125rem; }
1322
+ .rigel .structure-row > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1311
1323
  .rigel .structure-actions, .rigel .schema-item-actions { display: flex; align-items: center; }
1312
- .rigel .structure-actions button, .rigel .schema-item-actions button { border: 0; border-left: 1px solid var(--border); border-radius: 0; background: transparent; color: var(--text-2); min-width: 26px; height: 100%; padding: 0 0.25rem; cursor: pointer; }
1324
+ .rigel .structure-actions button, .rigel .schema-item-actions button { border: 0; border-left: 1px solid var(--border); border-radius: 0; background: transparent; color: var(--text-2); cursor: pointer; }
1313
1325
  .rigel .structure-actions button:disabled, .rigel .schema-item-actions button:disabled { opacity: 0.35; cursor: default; }
1314
1326
  .rigel .add-section { display: flex; gap: 0.5rem; }
1315
1327
  .rigel .add-section select { flex: 1; min-width: 0; }
@@ -35,7 +35,7 @@ export function selectionInContent(manifest: Manifest, selection: Selection | nu
35
35
  if (!selection) return true;
36
36
  switch (selection.kind) {
37
37
  case "page": return manifest.pages.some(page => page.slug === selection.slug);
38
- case "pages": return !selection.category || manifest.pages.some(page => !page.shared && page.category === selection.category);
38
+ case "pages": return !selection.category || manifest.pages.some(page => !page.shared && page.category === selection.category) || !!manifest.schema?.documents.some(type => !type.shared && type.category === selection.category);
39
39
  case "document":
40
40
  case "documents": return !!manifest.schema?.documents.some(type => type.name === selection.type);
41
41
  case "post": return !!manifest.collections?.some(collection => collection.name === selection.collection);
@@ -1,4 +1,5 @@
1
1
  import type { Manifest } from "./types";
2
+ import { pageCategories } from "./pages";
2
3
  import { systemMessage, type Translator } from "../editor/i18n/format";
3
4
 
4
5
  export type Selection =
@@ -31,8 +32,7 @@ export function navigationKey(target: Selection): string {
31
32
  export function buildSidebar(manifest: Manifest, message: Translator = systemMessage): SidebarNode[] {
32
33
  const link = (target: SidebarTarget, label: string): SidebarLink => ({ kind: "link", target, label });
33
34
  const types = manifest.schema?.documents ?? [];
34
- const pages = manifest.pages.filter(page => !page.shared);
35
- const categories = [...new Set(pages.map(page => page.category).filter((category): category is string => !!category))].sort();
35
+ const categories = pageCategories(manifest);
36
36
  const defaults: SidebarNode[] = [
37
37
  { kind: "heading", label: message("system.sidebar.website") },
38
38
  link({ kind: "pages" }, message("system.sidebar.pages")),
@@ -72,7 +72,8 @@ export function buildSidebar(manifest: Manifest, message: Translator = systemMes
72
72
  used.add(key);
73
73
  nodes.push({ ...item, label: entry.label ?? item.label, ...(entry.icon ? { icon: entry.icon } : {}) });
74
74
  }
75
- const remaining = defaults.filter((node): node is SidebarLink => node.kind === "link" && !used.has(navigationKey(node.target)));
75
+ const allPages = used.has(navigationKey({ kind: "pages" }));
76
+ const remaining = defaults.filter((node): node is SidebarLink => node.kind === "link" && !used.has(navigationKey(node.target)) && !(allPages && node.target.kind === "pages" && node.target.category));
76
77
  if (remaining.length) nodes.push({ kind: "heading", label: message("system.sidebar.more") }, ...remaining);
77
78
  return nodes;
78
79
  }
@@ -0,0 +1,17 @@
1
+ import type { Manifest } from "./types";
2
+
3
+ export function pageCategories(manifest: Manifest): string[] {
4
+ return [...new Set([...manifest.pages, ...(manifest.schema?.documents ?? [])].filter(entry => !entry.shared).map(entry => entry.category).filter((category): category is string => !!category))].sort();
5
+ }
6
+
7
+ export function pageTemplates(manifest: Manifest) {
8
+ return (manifest.schema?.documents ?? []).filter(type => !type.shared && !type.singleton && type.creatable !== false);
9
+ }
10
+
11
+ export function defaultPageCategory(manifest: Manifest): "" | null {
12
+ return [...manifest.pages, ...(manifest.schema?.documents ?? [])].some(entry => !entry.shared && entry.category === undefined) ? "" : null;
13
+ }
14
+
15
+ export function inPageCategory(category: string | undefined, filter: string | null): boolean {
16
+ return filter === null || (category ?? "") === filter;
17
+ }