@rigel-cms/rigel 1.0.0 → 1.1.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.
Files changed (156) hide show
  1. package/README.md +4 -0
  2. package/docs/extensions.md +194 -0
  3. package/package.json +15 -3
  4. package/src/article-body.ts +49 -37
  5. package/src/article-render.ts +25 -2
  6. package/src/article.ts +44 -0
  7. package/src/config.ts +12 -2
  8. package/src/editor/App.svelte +98 -97
  9. package/src/editor/blocks/extension.ts +29 -0
  10. package/src/editor/blocks/index.ts +17 -15
  11. package/src/editor/blocks/video.ts +2 -1
  12. package/src/editor/i18n/README.md +23 -0
  13. package/src/editor/i18n/de/connection.json +136 -0
  14. package/src/editor/i18n/de/defaults.json +6 -0
  15. package/src/editor/i18n/de/fields.json +130 -0
  16. package/src/editor/i18n/de/local.json +49 -0
  17. package/src/editor/i18n/de/media.json +166 -0
  18. package/src/editor/i18n/de/richtext.json +116 -0
  19. package/src/editor/i18n/de/shell.json +181 -0
  20. package/src/editor/i18n/de/system.json +14 -0
  21. package/src/editor/i18n/de/validation.json +82 -0
  22. package/src/editor/i18n/en/connection.json +136 -0
  23. package/src/editor/i18n/en/defaults.json +6 -0
  24. package/src/editor/i18n/en/fields.json +130 -0
  25. package/src/editor/i18n/en/local.json +49 -0
  26. package/src/editor/i18n/en/media.json +166 -0
  27. package/src/editor/i18n/en/richtext.json +116 -0
  28. package/src/editor/i18n/en/shell.json +181 -0
  29. package/src/editor/i18n/en/system.json +14 -0
  30. package/src/editor/i18n/en/validation.json +82 -0
  31. package/src/editor/i18n/es/connection.json +136 -0
  32. package/src/editor/i18n/es/defaults.json +6 -0
  33. package/src/editor/i18n/es/fields.json +130 -0
  34. package/src/editor/i18n/es/local.json +49 -0
  35. package/src/editor/i18n/es/media.json +166 -0
  36. package/src/editor/i18n/es/richtext.json +116 -0
  37. package/src/editor/i18n/es/shell.json +181 -0
  38. package/src/editor/i18n/es/system.json +14 -0
  39. package/src/editor/i18n/es/validation.json +82 -0
  40. package/src/editor/i18n/format.ts +26 -0
  41. package/src/editor/i18n/fr/connection.json +136 -0
  42. package/src/editor/i18n/fr/defaults.json +6 -0
  43. package/src/editor/i18n/fr/fields.json +130 -0
  44. package/src/editor/i18n/fr/local.json +49 -0
  45. package/src/editor/i18n/fr/media.json +166 -0
  46. package/src/editor/i18n/fr/richtext.json +116 -0
  47. package/src/editor/i18n/fr/shell.json +181 -0
  48. package/src/editor/i18n/fr/system.json +14 -0
  49. package/src/editor/i18n/fr/validation.json +82 -0
  50. package/src/editor/i18n/index.ts +18 -0
  51. package/src/editor/i18n/it/connection.json +136 -0
  52. package/src/editor/i18n/it/defaults.json +6 -0
  53. package/src/editor/i18n/it/fields.json +130 -0
  54. package/src/editor/i18n/it/local.json +49 -0
  55. package/src/editor/i18n/it/media.json +166 -0
  56. package/src/editor/i18n/it/richtext.json +116 -0
  57. package/src/editor/i18n/it/shell.json +181 -0
  58. package/src/editor/i18n/it/system.json +14 -0
  59. package/src/editor/i18n/it/validation.json +82 -0
  60. package/src/editor/i18n/ja/connection.json +136 -0
  61. package/src/editor/i18n/ja/defaults.json +6 -0
  62. package/src/editor/i18n/ja/fields.json +130 -0
  63. package/src/editor/i18n/ja/local.json +49 -0
  64. package/src/editor/i18n/ja/media.json +166 -0
  65. package/src/editor/i18n/ja/richtext.json +116 -0
  66. package/src/editor/i18n/ja/shell.json +181 -0
  67. package/src/editor/i18n/ja/system.json +14 -0
  68. package/src/editor/i18n/ja/validation.json +82 -0
  69. package/src/editor/i18n/locales.ts +13 -0
  70. package/src/editor/i18n/nl/connection.json +136 -0
  71. package/src/editor/i18n/nl/defaults.json +6 -0
  72. package/src/editor/i18n/nl/fields.json +130 -0
  73. package/src/editor/i18n/nl/local.json +49 -0
  74. package/src/editor/i18n/nl/media.json +166 -0
  75. package/src/editor/i18n/nl/richtext.json +116 -0
  76. package/src/editor/i18n/nl/shell.json +181 -0
  77. package/src/editor/i18n/nl/system.json +14 -0
  78. package/src/editor/i18n/nl/validation.json +82 -0
  79. package/src/editor/i18n/ru/connection.json +138 -0
  80. package/src/editor/i18n/ru/defaults.json +6 -0
  81. package/src/editor/i18n/ru/fields.json +132 -0
  82. package/src/editor/i18n/ru/local.json +49 -0
  83. package/src/editor/i18n/ru/media.json +166 -0
  84. package/src/editor/i18n/ru/richtext.json +116 -0
  85. package/src/editor/i18n/ru/shell.json +181 -0
  86. package/src/editor/i18n/ru/system.json +14 -0
  87. package/src/editor/i18n/ru/validation.json +82 -0
  88. package/src/editor/i18n/server.ts +13 -0
  89. package/src/editor/i18n/zh/connection.json +136 -0
  90. package/src/editor/i18n/zh/defaults.json +6 -0
  91. package/src/editor/i18n/zh/fields.json +130 -0
  92. package/src/editor/i18n/zh/local.json +49 -0
  93. package/src/editor/i18n/zh/media.json +166 -0
  94. package/src/editor/i18n/zh/richtext.json +116 -0
  95. package/src/editor/i18n/zh/shell.json +181 -0
  96. package/src/editor/i18n/zh/system.json +14 -0
  97. package/src/editor/i18n/zh/validation.json +82 -0
  98. package/src/editor/lib/ActionDialog.svelte +5 -4
  99. package/src/editor/lib/BlockEditor.svelte +305 -88
  100. package/src/editor/lib/CollectionPanel.svelte +17 -15
  101. package/src/editor/lib/DatePicker.svelte +15 -12
  102. package/src/editor/lib/ExtensionMount.svelte +12 -0
  103. package/src/editor/lib/Field.svelte +3 -2
  104. package/src/editor/lib/ImagePicker.svelte +82 -81
  105. package/src/editor/lib/MarkdownEditor.svelte +25 -24
  106. package/src/editor/lib/MediaPicker.svelte +6 -5
  107. package/src/editor/lib/MenuEditor.svelte +31 -30
  108. package/src/editor/lib/PreviewFrame.svelte +11 -6
  109. package/src/editor/lib/PropertiesTabs.svelte +3 -2
  110. package/src/editor/lib/PublishDialog.svelte +15 -14
  111. package/src/editor/lib/ReferenceSelect.svelte +3 -2
  112. package/src/editor/lib/SchemaField.svelte +15 -13
  113. package/src/editor/lib/SchemaPanel.svelte +20 -19
  114. package/src/editor/lib/Select.svelte +3 -2
  115. package/src/editor/lib/SeoGuidance.svelte +6 -5
  116. package/src/editor/lib/Settings.svelte +54 -54
  117. package/src/editor/lib/Shortcuts.svelte +7 -6
  118. package/src/editor/lib/SitePreview.svelte +10 -9
  119. package/src/editor/lib/TopBar.svelte +18 -17
  120. package/src/editor/lib/UnsavedChanges.svelte +7 -6
  121. package/src/editor/lib/VisualEditor.svelte +14 -13
  122. package/src/editor/lib/calendar.ts +10 -4
  123. package/src/editor/lib/embed-preview.ts +23 -5
  124. package/src/editor/lib/extension-preview.ts +121 -0
  125. package/src/editor/lib/github-auth.ts +23 -22
  126. package/src/editor/lib/github-backend.ts +96 -91
  127. package/src/editor/lib/images.ts +33 -31
  128. package/src/editor/lib/menu-tree.ts +5 -4
  129. package/src/editor/lib/preview-frame.ts +4 -3
  130. package/src/editor/lib/schema-edit.ts +3 -2
  131. package/src/editor/lib/settings.ts +5 -3
  132. package/src/editor/lib/slash-command.ts +1 -0
  133. package/src/editor/lib/tooltip.ts +1 -1
  134. package/src/editor/main.ts +9 -0
  135. package/src/editor/styles.css +6 -3
  136. package/src/env.d.ts +11 -1
  137. package/src/extensions.ts +160 -0
  138. package/src/manifest/collections.ts +6 -5
  139. package/src/manifest/menu.ts +12 -10
  140. package/src/manifest/navigation.ts +9 -8
  141. package/src/manifest/references.ts +8 -7
  142. package/src/manifest/seo.ts +4 -3
  143. package/src/manifest/types.ts +1 -0
  144. package/src/preview.ts +22 -2
  145. package/src/routes/local-api.ts +5 -3
  146. package/src/routes/local-documents.ts +20 -16
  147. package/src/routes/local-media.ts +21 -16
  148. package/src/routes/local-menu.ts +9 -7
  149. package/src/routes/local-post.ts +18 -12
  150. package/src/routes/media-files.ts +10 -8
  151. package/src/routes/rigel-page.astro +2 -1
  152. package/src/schema/store.ts +22 -20
  153. package/src/schema/types.ts +1 -0
  154. package/src/schema/validate.ts +73 -69
  155. package/src/slugs.ts +11 -10
  156. package/src/vite/manifest-plugin.ts +33 -1
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { t } from "../i18n";
2
3
  import { tick } from "svelte";
3
4
  import type { CollectionField } from "../../manifest/types";
4
5
  import type { SelectedField } from "./schema-edit";
@@ -51,51 +52,52 @@
51
52
  <PropertiesTabs bind:tab />
52
53
  <div id="properties-content" role="tabpanel" aria-labelledby="properties-tab-content" hidden={tab !== "content"}>
53
54
  <section>
54
- <h3>Article details</h3>
55
- <label><span>URL slug</span><input type="text" value={String(values.slug ?? storageSlug)} oninput={(event) => onchange("slug", event.currentTarget.value)} onblur={() => { if (Object.hasOwn(values, "slug")) onchange("slug", slugify(String(values.slug))); }} /></label>
56
- <p class="hint">Changes the URL, not the content filename. The website route must use the slug metadata.</p>
55
+ <h3>{$t("fields.articleDetails")}</h3>
56
+ <label><span>{$t("fields.urlSlug")}</span><input type="text" value={String(values.slug ?? storageSlug)} oninput={(event) => onchange("slug", event.currentTarget.value)} onblur={() => { if (Object.hasOwn(values, "slug")) onchange("slug", slugify(String(values.slug))); }} /></label>
57
+ <p class="hint">{$t("fields.collectionSlugHint")}</p>
57
58
  {#each fields.filter(field => field.key !== "slug" && !isSeoField(field.key)) as field (field.key)}
59
+ {@const label = field.labelKey ? $t(field.labelKey) : field.label}
58
60
  <div data-collection-field={field.key}>
59
61
  {#if field.type === "reference"}
60
- <ReferenceSelect label={field.label} value={values[field.key]} options={referenceOptions(field, documents)} multiple={field.multiple} onchange={(value) => onchange(field.key, value)} />
62
+ <ReferenceSelect {label} value={values[field.key]} options={referenceOptions(field, documents)} multiple={field.multiple} onchange={(value) => onchange(field.key, value)} />
61
63
  {:else if field.type === "boolean"}
62
64
  <label class="inline">
63
65
  <input type="checkbox" role="switch" class="switch" checked={Boolean(values[field.key])} onchange={(event) => onchange(field.key, event.currentTarget.checked)} />
64
- <span>{field.label}{devMode ? ` (${field.key})` : ""}</span>
66
+ <span>{label}{devMode ? ` (${field.key})` : ""}</span>
65
67
  </label>
66
68
  {:else if field.type === "markdown"}
67
- <MarkdownEditor label={field.label} value={String(values[field.key] ?? "")} oninput={(value) => onchange(field.key, value)} />
69
+ <MarkdownEditor {label} value={String(values[field.key] ?? "")} oninput={(value) => onchange(field.key, value)} />
68
70
  {:else if field.type === "image"}
69
- <label><span>{field.label}{devMode ? ` (${field.key})` : ""}</span><ImagePicker value={String(values[field.key] ?? "")} onpick={(value) => onchange(field.key, value)} /></label>
71
+ <label><span>{label}{devMode ? ` (${field.key})` : ""}</span><ImagePicker value={String(values[field.key] ?? "")} onpick={(value) => onchange(field.key, value)} /></label>
70
72
  {:else if field.type === "tags"}
71
73
  <label>
72
- <span>{field.label}{devMode ? ` (${field.key})` : ""} (comma-separated)</span>
74
+ <span>{$t("fields.commaSeparatedLabel", { label: `${label}${devMode ? ` (${field.key})` : ""}` })}</span>
73
75
  <input type="text" value={Array.isArray(values[field.key]) ? (values[field.key] as string[]).join(", ") : String(values[field.key] ?? "")} oninput={(event) => onchange(field.key, event.currentTarget.value.split(",").map(value => value.trim()).filter(Boolean))} />
74
76
  </label>
75
77
  {:else if field.type === "date"}
76
- <DatePicker label={`${field.label}${devMode ? ` (${field.key})` : ""}`} value={String(values[field.key] ?? "")} onchange={(value) => onchange(field.key, value)} />
78
+ <DatePicker label={`${label}${devMode ? ` (${field.key})` : ""}`} value={String(values[field.key] ?? "")} onchange={(value) => onchange(field.key, value)} />
77
79
  {:else if field.type === "text"}
78
- <label><span>{field.label}{devMode ? ` (${field.key})` : ""}</span><textarea value={String(values[field.key] ?? "")} oninput={(event) => onchange(field.key, event.currentTarget.value)}></textarea></label>
80
+ <label><span>{label}{devMode ? ` (${field.key})` : ""}</span><textarea value={String(values[field.key] ?? "")} oninput={(event) => onchange(field.key, event.currentTarget.value)}></textarea></label>
79
81
  {:else}
80
- <label><span>{field.label}{devMode ? ` (${field.key})` : ""}</span><input type="text" value={String(values[field.key] ?? "")} oninput={(event) => onchange(field.key, event.currentTarget.value)} /></label>
82
+ <label><span>{label}{devMode ? ` (${field.key})` : ""}</span><input type="text" value={String(values[field.key] ?? "")} oninput={(event) => onchange(field.key, event.currentTarget.value)} /></label>
81
83
  {/if}
82
84
  </div>
83
85
  {/each}
84
86
  </section>
85
87
  <section data-collection-field="$body">
86
88
  {#if bodyInline}
87
- <button class="outlined" disabled={bodyLoading} onclick={oneditbody}>Edit article body in preview</button>
88
- <p class="hint">{bodyLoading ? "Loading preview…" : "Click the article body in the preview to edit. Content changes require Save."}</p>
89
+ <button class="outlined" disabled={bodyLoading} onclick={oneditbody}>{$t("fields.editArticleBody")}</button>
90
+ <p class="hint">{$t(bodyLoading ? "fields.loadingPreview" : "fields.editArticleBodyHint")}</p>
89
91
  {:else}<BlockEditor value={body} oninput={onbody} />{/if}
90
92
  </section>
91
93
  </div>
92
94
  <div id="properties-seo" role="tabpanel" aria-labelledby="properties-tab-seo" hidden={tab !== "seo"}>
93
95
  <section class="seo-fields">
94
- <h3>SEO</h3>
96
+ <h3>{$t("fields.seo")}</h3>
95
97
  {#each fields.filter(field => isSeoField(field.key)) as field (field.key)}
96
98
  <div data-collection-field={field.key}>
97
99
  <label>
98
- <span>{field.label}</span>
100
+ <span>{field.labelKey ? $t(field.labelKey) : field.label}</span>
99
101
  {#if field.type === "image"}<ImagePicker value={String(values[field.key] ?? "")} onpick={(value) => onchange(field.key, value)} />
100
102
  {:else if field.type === "text"}<textarea rows="3" value={String(values[field.key] ?? "")} oninput={(event) => onchange(field.key, event.currentTarget.value)}></textarea>
101
103
  {:else}<input type="text" value={String(values[field.key] ?? "")} oninput={(event) => onchange(field.key, event.currentTarget.value)} />{/if}
@@ -1,12 +1,15 @@
1
1
  <script lang="ts">
2
+ import { locale, t } from "../i18n";
2
3
  import { tick } from "svelte";
3
4
  import Icon from "./Icon.svelte";
4
5
  import Select from "./Select.svelte";
5
- import { formatDate, months, monthDays, moveDay, moveMonth, parseDate, todayDate } from "./calendar";
6
+ import { formatDate, monthLabels, monthDays, moveDay, moveMonth, parseDate, todayDate } from "./calendar";
6
7
 
7
8
  let { label, value = "", required = false, onchange }: { label: string; value?: string; required?: boolean; onchange: (value: string) => void } = $props();
8
9
  const id = $props.id();
9
- const monthOptions = months.map((label, index) => ({ label, value: String(index + 1) }));
10
+ const months = $derived(monthLabels($t));
11
+ const monthOptions = $derived(months.map((label, index) => ({ label, value: String(index + 1) })));
12
+ const weekdays = $derived(["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"].map(day => ({ label: $t(`fields.weekday.${day}`), short: $t(`fields.weekdayShort.${day}`) })));
10
13
  let host: HTMLDivElement;
11
14
  let trigger: HTMLButtonElement;
12
15
  let menu: HTMLDivElement;
@@ -18,7 +21,7 @@
18
21
  const month = $derived(Number(focused.slice(5, 7)));
19
22
  const cells = $derived(monthDays(focused));
20
23
  const weeks = $derived(Array.from({ length: 6 }, (_, row) => cells.slice(row * 7, row * 7 + 7)));
21
- const heading = $derived(`${months[month - 1]} ${year}`);
24
+ const heading = $derived($t("fields.calendarHeading", { month: months[month - 1], year }));
22
25
 
23
26
  function position() {
24
27
  if (!open || !trigger || !menu) return;
@@ -87,20 +90,20 @@
87
90
  <div class="date-picker" bind:this={host} onfocusout={() => { if (open) requestAnimationFrame(() => { if (!host?.contains(trigger.ownerDocument.activeElement)) close(); }); }}>
88
91
  <span class="select-label" id={`${id}-label`}>{label}</span>
89
92
  <button bind:this={trigger} type="button" class="select-trigger date-trigger" aria-labelledby={`${id}-label ${id}-value`} aria-haspopup="dialog" aria-expanded={open} aria-controls={`${id}-calendar`} popovertarget={`${id}-calendar`} onclick={event => { event.preventDefault(); menu.matches(':popover-open') ? close() : void reveal(); }} onkeydown={event => { if (event.key === 'ArrowDown') { event.preventDefault(); void reveal(); } }}>
90
- <span id={`${id}-value`}>{formatDate(value) || "Choose date…"}</span><Icon name="calendar" size={18} />
93
+ <span id={`${id}-value`}>{formatDate(value, $locale) || $t("fields.chooseDate")}</span><Icon name="calendar" size={18} />
91
94
  </button>
92
- <div bind:this={menu} id={`${id}-calendar`} class="date-popover" popover="auto" role="dialog" aria-label={`${label} calendar`} style={placement} ontoggle={event => { open = event.currentTarget.matches(':popover-open'); }} onkeydown={event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); } }}>
95
+ <div bind:this={menu} id={`${id}-calendar`} class="date-popover" popover="auto" role="dialog" aria-label={$t("fields.calendarFor", { label })} style={placement} ontoggle={event => { open = event.currentTarget.matches(':popover-open'); }} onkeydown={event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); } }}>
93
96
  <div class="date-header">
94
- <button type="button" class="date-previous" aria-label="Previous month" disabled={focused.startsWith('0001-01')} onclick={() => focused = moveMonth(focused, -1)}><Icon name="caretRight" size={16} /></button>
95
- <Select ariaLabel="Calendar month" value={String(month)} options={monthOptions} onchange={next => focused = moveMonth(focused, Number(next) - month)} />
96
- <input type="number" aria-label="Calendar year" min="1" max="9999" value={year} onchange={setYear} onkeydown={event => { if (event.key === 'Enter') { event.preventDefault(); setYear(event); void focusDay(); } }} />
97
- <button type="button" aria-label="Next month" disabled={focused.startsWith('9999-12')} onclick={() => focused = moveMonth(focused, 1)}><Icon name="caretRight" size={16} /></button>
97
+ <button type="button" class="date-previous" aria-label={$t("fields.previousMonth")} disabled={focused.startsWith('0001-01')} onclick={() => focused = moveMonth(focused, -1)}><Icon name="caretRight" size={16} /></button>
98
+ <Select ariaLabel={$t("fields.calendarMonth")} value={String(month)} options={monthOptions} onchange={next => focused = moveMonth(focused, Number(next) - month)} />
99
+ <input type="number" aria-label={$t("fields.calendarYear")} min="1" max="9999" value={year} onchange={setYear} onkeydown={event => { if (event.key === 'Enter') { event.preventDefault(); setYear(event); void focusDay(); } }} />
100
+ <button type="button" aria-label={$t("fields.nextMonth")} disabled={focused.startsWith('9999-12')} onclick={() => focused = moveMonth(focused, 1)}><Icon name="caretRight" size={16} /></button>
98
101
  </div>
99
102
  <span class="sr-only" id={`${id}-month`} aria-live="polite">{heading}</span>
100
103
  <table class="date-grid" role="grid" aria-labelledby={`${id}-month`} onkeydown={gridKey}>
101
- <thead><tr>{#each ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as day}<th scope="col" aria-label={day}>{day.slice(0, 2)}</th>{/each}</tr></thead>
102
- <tbody>{#each weeks as week}<tr>{#each week as day}<td role="gridcell" aria-selected={day ? day.value === value : undefined}>{#if day}<button type="button" data-date={day.value} tabindex={day.value === focused ? 0 : -1} aria-label={formatDate(day.value)} aria-current={day.value === today ? 'date' : undefined} onfocus={() => focused = day.value} onclick={() => choose(day.value)}>{day.day}</button>{/if}</td>{/each}</tr>{/each}</tbody>
104
+ <thead><tr>{#each weekdays as day}<th scope="col" aria-label={day.label}>{day.short}</th>{/each}</tr></thead>
105
+ <tbody>{#each weeks as week}<tr>{#each week as day}<td role="gridcell" aria-selected={day ? day.value === value : undefined}>{#if day}<button type="button" data-date={day.value} tabindex={day.value === focused ? 0 : -1} aria-label={formatDate(day.value, $locale)} aria-current={day.value === today ? 'date' : undefined} onfocus={() => focused = day.value} onclick={() => choose(day.value)}>{day.day}</button>{/if}</td>{/each}</tr>{/each}</tbody>
103
106
  </table>
104
- <div class="date-actions"><button type="button" class="outlined" onclick={() => choose(todayDate())}>Today</button><button type="button" class="outlined" disabled={required || !value} onclick={() => choose('')}>Clear</button></div>
107
+ <div class="date-actions"><button type="button" class="outlined" onclick={() => choose(todayDate())}>{$t("fields.today")}</button><button type="button" class="outlined" disabled={required || !value} onclick={() => choose('')}>{$t("fields.clear")}</button></div>
105
108
  </div>
106
109
  </div>
@@ -0,0 +1,12 @@
1
+ <script lang="ts">
2
+ import type { Component as SvelteComponent } from "svelte";
3
+ import type { BlockData } from "../../extensions";
4
+ import { t } from "../i18n";
5
+ let { component: Component, data, onchange = undefined, report }: { component: SvelteComponent<any>; data: BlockData; onchange?: (data: BlockData) => void; report: (error: unknown) => void } = $props();
6
+ export function update(next: BlockData) { data = next; }
7
+ </script>
8
+
9
+ <svelte:boundary onerror={report}>
10
+ <Component {data} {onchange} />
11
+ {#snippet failed()}<p role="alert">{$t("richtext.loadArticleError")}</p>{/snippet}
12
+ </svelte:boundary>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { t } from "../i18n";
2
3
  import type { CopyField } from "../manifest/types";
3
4
  import Field from "./Field.svelte";
4
5
  import ImagePicker from "./ImagePicker.svelte";
@@ -63,7 +64,7 @@
63
64
  class="remove"
64
65
  onclick={() => oninput(items.filter((_, j) => j !== i))}
65
66
  >
66
- Remove
67
+ {$t("fields.remove")}
67
68
  </button>
68
69
  </div>
69
70
  {/each}
@@ -75,6 +76,6 @@
75
76
  oninput([...items, blank]);
76
77
  }}
77
78
  >
78
- Add item
79
+ {$t("fields.addItem")}
79
80
  </button>
80
81
  {/if}
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { locale, t } from "../i18n";
2
3
  import { onMount, onDestroy, tick } from "svelte";
3
4
  import Icon from "./Icon.svelte";
4
5
  import ActionDialog from "./ActionDialog.svelte";
@@ -12,7 +13,7 @@
12
13
  import { filterMedia, modifiedMonth, mediaSize, mediaIcon, assetFile, type MediaAsset } from "./media-library";
13
14
  import { searchImages, importStockImage, importImageUrl, uploadMedia, safeImageUrl, STOCK_PROVIDERS, type StockResult, type StockSource } from "./images";
14
15
 
15
- let { value = "", altText = "", triggerLabel = "Insert image", kind = "image", video, onvideo, embed, onembed, onpick, inline = false, toolbar = false, hiddenTrigger = false, onopen, onclose }: {
16
+ let { value = "", altText = "", triggerLabel, kind = "image", video, onvideo, embed, onembed, onpick, inline = false, toolbar = false, hiddenTrigger = false, onopen, onclose }: {
16
17
  video?: VideoData;
17
18
  onvideo?: (video: VideoData) => void;
18
19
  embed?: EmbedData;
@@ -83,12 +84,12 @@
83
84
  const url = selected.asset.url;
84
85
  savingMeta = true; metaError = ""; metaNotice = "";
85
86
  try {
86
- const response = await fetch("/rigel/api/local/media", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, name: metaName.trim() || undefined, title: metaTitle.trim() || undefined, description: metaDescription.trim() || undefined, alt: selected.asset.type?.startsWith("image/") ? metaAlt : undefined }) });
87
- if (!response.ok) { metaError = `Metadata save failed (${response.status})`; return; }
87
+ const response = await fetch("/rigel/api/local/media", { method: "PUT", headers: { "Content-Type": "application/json", "Accept-Language": $locale }, body: JSON.stringify({ url, name: metaName.trim() || undefined, title: metaTitle.trim() || undefined, description: metaDescription.trim() || undefined, alt: selected.asset.type?.startsWith("image/") ? metaAlt : undefined }) });
88
+ if (!response.ok) { metaError = $t("media.metadataSaveFailedStatus", { status: response.status }); return; }
88
89
  await library();
89
90
  selected = { url, asset: assets.find(a => a.url === url) };
90
- metaNotice = "Metadata saved.";
91
- } catch { metaError = "Metadata save failed"; }
91
+ metaNotice = $t("media.metadataSaved");
92
+ } catch { metaError = $t("media.metadataSaveFailed"); }
92
93
  finally { savingMeta = false; }
93
94
  }
94
95
  async function deleteFile() {
@@ -96,10 +97,10 @@
96
97
  const url = selected.asset.url;
97
98
  picking = true;
98
99
  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.`);
100
+ const response = await fetch("/rigel/api/local/media", { method: "DELETE", headers: { "Content-Type": "application/json", "Accept-Language": $locale }, body: JSON.stringify({ url }) });
101
+ if (!response.ok) throw new Error($t("media.deleteFailed", { status: response.status }));
101
102
  assets = assets.filter(asset => asset.url !== url);
102
- librarySelection = ""; notice = "File deleted.";
103
+ librarySelection = ""; notice = $t("media.fileDeleted");
103
104
  close();
104
105
  } finally { picking = false; }
105
106
  }
@@ -113,8 +114,8 @@
113
114
  }
114
115
 
115
116
  async function library() {
116
- const response = await fetch("/rigel/media.json", { cache: "no-store" });
117
- if (!response.ok) throw new Error("Media listing unavailable. Start the local dev server or rebuild the site.");
117
+ const response = await fetch("/rigel/media.json", { cache: "no-store", headers: { "Accept-Language": $locale } });
118
+ if (!response.ok) throw new Error($t("media.listingUnavailable"));
118
119
  const data = await response.json();
119
120
  assets = (data.assets ?? data.images).filter((asset: MediaAsset) => inline || asset.type?.startsWith(kind === "video" ? "video/" : "image/"));
120
121
  writable = data.writable === true;
@@ -132,10 +133,10 @@
132
133
  error = "";
133
134
  if (providerTab) return;
134
135
  if (tab === "url") {
135
- if (!safeImageUrl(urlInput.trim())) { error = `Enter ${kind === "video" ? "a video" : "an image"} URL starting with https:// or /`; return; }
136
+ if (!safeImageUrl(urlInput.trim())) { error = $t(kind === "video" ? "media.enterVideoUrl" : "media.enterImageUrl"); return; }
136
137
  const metadata = mediaMetadata(new URL(urlInput.trim(), location.origin).pathname);
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; }
138
+ if (kind === "video" && metadata?.category !== "Video") { error = $t("media.chooseVideoUrl"); return; }
139
+ if (kind === "image" && metadata && metadata.category !== "Images") { error = $t("media.chooseImageUrl"); return; }
139
140
  selected = { url: urlInput.trim() };
140
141
  return;
141
142
  }
@@ -147,13 +148,13 @@
147
148
  if (request === requestId) results = found;
148
149
  }
149
150
  if (request === requestId) searched = true;
150
- } catch (e) { if (request === requestId) error = e instanceof Error ? e.message : "Search failed"; }
151
+ } catch (e) { if (request === requestId) error = e instanceof Error ? e.message : $t("media.searchFailed"); }
151
152
  finally { if (request === requestId) loading = false; }
152
153
  }
153
154
  async function confirm() {
154
155
  if (providerTab) {
155
156
  const ref = parseEmbedUrl(urlInput);
156
- if (!ref || ref.provider !== tab) { error = `Paste a ${tab === "youtube" ? "YouTube" : "Vimeo"} video URL.`; return; }
157
+ if (!ref || ref.provider !== tab) { error = $t("media.pasteVideoUrl", { provider: tab === "youtube" ? "YouTube" : "Vimeo" }); return; }
157
158
  onembed?.({ ...ref, title: videoTitle.trim() || undefined, privacy });
158
159
  close();
159
160
  return;
@@ -165,20 +166,20 @@
165
166
  picking = true; error = "";
166
167
  try {
167
168
  const url = choice.file ? await uploadMedia(choice.file) : choice.stock ? await importStockImage(choice.stock) : needsImport ? await importImageUrl(choice.url) : choice.url;
168
- if (!safeImageUrl(url)) throw new Error("Unsafe media URL");
169
+ if (!safeImageUrl(url)) throw new Error($t("media.unsafeMediaUrl"));
169
170
  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.`);
171
+ const saved = await fetch("/rigel/api/local/media", { method: "PUT", headers: { "Content-Type": "application/json", "Accept-Language": $locale }, body: JSON.stringify({ url, ...metadata }) });
172
+ if (!saved.ok) throw new Error($t("media.uploadedMetadataFailed", { status: saved.status }));
172
173
  }
173
- if (kind === "video" && mediaMetadata(new URL(url, location.origin).pathname)?.category !== "Video") throw new Error("Choose an MP4, WebM or MOV video URL.");
174
+ if (kind === "video" && mediaMetadata(new URL(url, location.origin).pathname)?.category !== "Video") throw new Error($t("media.chooseVideoUrl"));
174
175
  if (request === requestId) {
175
176
  if (inline) {
176
- await library(); resetFilters(); notice = "Media available in the repository library.";
177
+ await library(); resetFilters(); notice = $t("media.availableInLibrary");
177
178
  } else if (kind === "video") onvideo?.({ src: url, caption: alt.trim() || undefined, muted, controls });
178
179
  else onpick?.(url, alt, choice.stock);
179
180
  close();
180
181
  }
181
- } catch (e) { if (request === requestId) error = e instanceof Error ? e.message : "Photo import failed"; }
182
+ } catch (e) { if (request === requestId) error = e instanceof Error ? e.message : $t("media.photoImportFailed"); }
182
183
  finally { if (request === requestId) picking = false; }
183
184
  }
184
185
  function upload(file?: File) {
@@ -186,7 +187,7 @@
186
187
  error = "";
187
188
  const limit = maxFileSize;
188
189
  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;
190
+ error = $t(inline ? "media.chooseSupportedFile" : kind === "video" ? "media.chooseSupportedVideo" : "media.chooseSupportedImage", { limit: limit / 1024 / 1024 }); return;
190
191
  }
191
192
  const type = mediaMetadata(file.name)!.type;
192
193
  releaseFile(); objectURL = URL.createObjectURL(new Blob([file], { type: type === "text/plain" ? "text/plain; charset=utf-8" : type }));
@@ -214,134 +215,134 @@
214
215
  </script>
215
216
 
216
217
  {#if inline}
217
- <div class="titlebar"><h2>Media</h2><button bind:this={trigger} type="button" class="primary" onclick={launch}>Upload</button></div>
218
+ <div class="titlebar"><h2>{$t("media.heading")}</h2><button bind:this={trigger} type="button" class="primary" onclick={launch}>{$t("media.upload")}</button></div>
218
219
  <div class="media-library">
219
220
  {@render libraryBrowser()}
220
221
  {#if notice}<p role="status">{notice}</p>{/if}
221
222
  {#if error && !open}<p class="error" role="alert">{error}</p>{/if}
222
223
  </div>
223
224
  {:else if toolbar}
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>
225
+ <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>
225
226
  {:else}
226
227
  <div class="image-field">
227
- {#if value}<img src={value} alt="" class="preview" />{:else}<div class="preview empty">No image</div>{/if}
228
- <button bind:this={trigger} type="button" class="outlined" onclick={launch}>Choose…</button>
229
- {#if value && onpick}<button type="button" class="outlined" aria-label="Remove image" data-tooltip="Remove image" onclick={() => onpick?.("", "")}><Icon name="x" size={14} /></button>{/if}
228
+ {#if value}<img src={value} alt="" class="preview" />{:else}<div class="preview empty">{$t("media.noImage")}</div>{/if}
229
+ <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}
230
231
  </div>
231
232
  {/if}
232
233
 
233
234
  {#snippet libraryBrowser()}
234
- <p class="hint">Repository media: public/{folder || "…"}. {writable ? "Uploads write files immediately on confirmation; content still requires Save." : "Build-time snapshot. Uploads and stock imports require the local dev server; GitHub uploads are not connected."}</p>
235
+ <p class="hint">{$t(writable ? "media.repositoryWritable" : "media.repositorySnapshot", { folder: folder || "…" })}</p>
235
236
  <div class="media-tools">
236
- <div class="media-views" role="group" aria-label="Media view">
237
- <button type="button" class="outlined" data-tooltip="Grid view" aria-label="Grid view" aria-pressed={view === "grid"} onclick={() => setView("grid")}><Icon name="squaresFour" size={18} /></button>
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>
237
+ <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>
239
240
  </div>
240
241
  <div class="media-select">
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() }))]} />
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() }))]} />
242
243
  </div>
243
244
  <div class="media-select">
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" }) }))]} />
245
+ <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" }) }))]} />
245
246
  </div>
246
- <button type="button" class="outlined" onclick={() => { tab = "library"; void run(); }} disabled={loading || picking}>Refresh</button>
247
- {#if filter || format || month}<button type="button" class="outlined" onclick={resetFilters}>Reset filters</button>{/if}
248
- <input class="media-search" aria-label="Search media" type="search" bind:value={filter} placeholder="Search media…" />
247
+ <button type="button" class="outlined" onclick={() => { tab = "library"; void run(); }} disabled={loading || picking}>{$t("media.refresh")}</button>
248
+ {#if filter || format || month}<button type="button" class="outlined" onclick={resetFilters}>{$t("media.resetFilters")}</button>{/if}
249
+ <input class="media-search" aria-label={$t("media.searchMedia")} type="search" bind:value={filter} placeholder={$t("media.searchMediaPlaceholder")} />
249
250
  </div>
250
251
  {#if view === "grid"}<div class="grid">
251
252
  {#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>
253
+ {@const extension = mediaMetadata(assetFile(asset.url))?.extension.toUpperCase() ?? $t("media.fileExtension")}
254
+ <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 ? $t(`media.category.${mediaMetadata(assetFile(asset.url))!.category}`) : ""}</span></span>{/if}<span id={`${id}-extension-${index}`} class="media-extension">{extension}</span></button><figcaption>{asset.name}</figcaption></figure>
254
255
  {/each}
255
256
  </div>
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>
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}
257
+ {:else}<div class="table-card media-table"><table aria-label={$t("media.library")}><thead><tr><th scope="col">{$t("media.file")}</th><th scope="col">{$t("media.fileType")}</th><th scope="col">{$t("media.size")}</th><th scope="col">{$t("media.modifiedUtc")}</th></tr></thead><tbody>
258
+ {#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($locale, {timeZone: "UTC"}) : "—"}</td></tr>{/each}
258
259
  </tbody></table></div>{/if}
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}
260
+ {#if loading}<p role="status">{$t("media.loading")}</p>{:else if !filtered.length}<p role="status">{$t(assets.length ? "media.noMatches" : "media.emptyFolder")}</p>{/if}
260
261
  {/snippet}
261
262
 
262
263
  {#if open}
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(); }}>
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(); }}>
264
265
  <div class="rigel-modal image-picker">
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>
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>
266
267
  {#if selected}
267
268
  <div class="media-selection" class:media-editing={(inline && !!selected.asset) || metadataImport}>
268
269
  <div class="media-preview">
269
270
  {#if kind === "video" || selectedMetadata?.category === "Video"}
270
271
  <video src={selected.url} controls={kind === "video" ? controls : true} muted={kind === "video" ? muted : false} preload="metadata" playsinline></video>
271
272
  {: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
+ <iframe src={`${selected.file ? selected.url : `${selected.url}?preview=1`}${selectedMetadata.extension === "pdf" ? "#navpanes=0&view=FitH" : ""}`} title={$t(selectedMetadata.extension === "pdf" ? "media.pdfPreview" : "media.textPreview")} sandbox={selectedMetadata.extension === "txt" ? "" : undefined}></iframe>
273
274
  {:else if (!selected.file && !selected.asset) || selectedMetadata?.category === "Images"}
274
- <img src={selected.url} alt="Selected image preview" />
275
+ <img src={selected.url} alt={$t("media.selectedImagePreview")} />
275
276
  {: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
+ <div class="media-file-icon"><Icon name={mediaIcon(selected.file?.name ?? assetFile(selected.asset?.url ?? ""))} size={64} /><p>{selectedMetadata?.category ? $t(`media.category.${selectedMetadata.category}`) : ""} · {mediaSize(selected.file?.size ?? selected.asset?.size)}</p><p>{selectedMetadata?.type}</p></div>
277
278
  {/if}
278
279
  </div>
279
280
  <div class="media-details">
280
281
  {#if (inline && selected.asset) || metadataImport}
281
282
  <div class="media-meta">
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}
283
+ {#if inline && selected.asset}<label><span>{$t("media.displayName")}</span><input aria-label={$t("media.mediaDisplayName")} type="text" maxlength="120" bind:value={metaName} /></label>{/if}
284
+ {#if imageImport}<label><span>{$t("media.decorativeAlt")}</span><input aria-label={$t("media.imageAlt")} type="text" maxlength="1000" disabled={picking} bind:value={alt} /></label>{:else if selected.asset?.type?.startsWith("image/")}<label><span>{$t("media.alt")}</span><textarea aria-label={$t("media.mediaAlt")} rows="3" maxlength="1000" bind:value={metaAlt}></textarea></label>{/if}
285
+ <label><span>{$t("media.title")}</span><input aria-label={$t(imageImport ? "media.imageTitle" : "media.mediaTitle")} type="text" maxlength="200" disabled={picking} bind:value={metaTitle} /></label>
286
+ <label><span>{$t("media.description")}</span><textarea aria-label={$t(imageImport ? "media.imageDescription" : "media.mediaDescription")} rows="3" maxlength="1000" disabled={picking} bind:value={metaDescription}></textarea></label>
287
+ {#if selected.asset}<p class="hint">{$t("media.author", { author: selected.asset.author || $t("media.localUser") })}</p>{:else if selected.file}<p class="hint">{selected.file.name}</p>{/if}
287
288
  </div>
288
289
  {:else if selected.file}
289
290
  <p class="hint">{selected.file.name}</p>
290
291
  {:else if !selected.asset}
291
- <label><span>{kind === "video" ? "Video URL" : "Image URL"}</span><input type="text" readonly value={selected.url} /></label>
292
+ <label><span>{$t(kind === "video" ? "media.videoUrl" : "media.imageUrl")}</span><input type="text" readonly value={selected.url} /></label>
292
293
  {/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}
294
+ {#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}
295
296
  {#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>
297
+ <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
+ <label class="inline"><input type="checkbox" role="switch" class="switch" aria-label={$t("media.showControls")} bind:checked={controls} /><span>{$t("media.showControls")}</span></label>
298
299
  {/if}
299
300
  {#if selected.stock?.credit}<p class="hint">{selected.stock.credit}</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}
301
+ {#if needsImport}<p class="hint">{$t(selected.file ? "media.confirmUpload" : "media.confirmDownload")}</p>{/if}
301
302
  </div>
302
303
  <div class="media-actions" class:with-media-path={!!selected.asset}>
303
304
  {#if metaError || metaNotice}<p class="media-feedback" class:error={!!metaError} role={metaError ? "alert" : "status"}>{metaError || metaNotice}</p>{/if}
304
305
  {#if selected.asset}
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}
307
- <pre class="media-path" role="region" aria-label="Media file path" tabindex="0"><code>{selected.asset.url}</code></pre>
306
+ <a class="outlined media-download" href={selected.asset.url} download={assetFile(selected.asset.url)}>{$t("media.downloadFile")}</a>
307
+ {#if inline && writable}<button bind:this={deleteTrigger} type="button" class="danger-btn" aria-label={$t("media.deleteFile")} disabled={picking || savingMeta} onclick={() => { confirmDelete = true; }}>{$t("media.deleteFile")}</button>{/if}
308
+ <pre class="media-path" role="region" aria-label={$t("media.filePath")} tabindex="0"><code>{selected.asset.url}</code></pre>
308
309
  {/if}
309
310
  <div class="media-button-actions">
310
311
  {#if inline && selected.asset}
311
- <button type="button" class="outlined" disabled={picking || savingMeta} onclick={close}>Cancel</button>
312
- <button type="button" class="primary" disabled={picking || savingMeta} onclick={saveMetadata}>{savingMeta ? "Saving…" : "Save"}</button>
312
+ <button type="button" class="outlined" disabled={picking || savingMeta} onclick={close}>{$t("media.cancel")}</button>
313
+ <button type="button" class="primary" disabled={picking || savingMeta} onclick={saveMetadata}>{$t(savingMeta ? "media.saving" : "media.save")}</button>
313
314
  {:else}
314
- <button type="button" class="outlined" disabled={picking} onclick={() => { selected = null; releaseFile(); }}>Back</button>
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}
315
+ <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}
316
317
  {/if}</div>
317
318
  </div>
318
319
  </div>
319
320
  {:else}
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}
321
+ <div class="tabs" aria-label={$t(kind === "video" ? "media.videoSources" : "media.imageSources")}>
322
+ {#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}
322
323
  </div>
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}
324
+ {#if inline}<p class="hint">{$t("media.unsplashUploadsHint")}</p>{/if}
324
325
  {#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>
326
+ <p class="hint">{$t("media.providerLinkHint", { provider: tab === "youtube" ? "YouTube" : "Vimeo" })}</p>
327
+ <label><span>{$t("media.videoUrl")}</span><input type="text" bind:value={urlInput} placeholder={tab === "youtube" ? "https://www.youtube.com/watch?v=…" : "https://vimeo.com/…"} /></label>
328
+ <label><span>{$t("media.accessibleTitle")}</span><input type="text" maxlength="200" bind:value={videoTitle} /></label>
329
+ <label class="inline"><input type="checkbox" role="switch" class="switch" bind:checked={privacy} /><span>{$t("media.privacyEnhanced")}</span></label>
329
330
  <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>
331
+ <button type="button" class="outlined" onclick={close}>{$t("media.cancel")}</button>
332
+ <button type="button" class="primary" onclick={confirm}>{$t(editing ? "media.updateVideo" : "media.insert")}</button>
332
333
  </div>
333
334
  {:else if tab === "library"}
334
335
  {@render libraryBrowser()}
335
336
  {:else if tab === "upload"}
336
- <p class="hint">Choose a local file, then preview and confirm. Nothing is uploaded until confirmation.</p>
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}
337
+ <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}
338
339
  {:else if tab === "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>
340
- <button type="button" class="primary" onclick={run}>Use this URL</button>
340
+ <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
+ <button type="button" class="primary" onclick={run}>{$t("media.useUrl")}</button>
341
342
  {:else}
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>
343
- <div class="search-row"><input aria-label="Search photos" type="search" bind:value={query} placeholder="Search photos…" onkeydown={e => e.key === "Enter" && run()} /><button type="button" class="primary" onclick={run} disabled={loading || picking}>{loading ? "…" : "Search"}</button></div>
344
- {#if searched && !results.length && !error}<p class="hint" role="status">No photos found. Try another search.</p>{/if}
343
+ <p class="hint">{$t(tab === "picsum" ? "media.picsumHint" : tab === "unsplash" ? "media.unsplashHint" : "media.stockImportHint")}</p>
344
+ <div class="search-row"><input aria-label={$t("media.searchPhotos")} type="search" bind:value={query} placeholder={$t("media.searchPhotosPlaceholder")} onkeydown={e => e.key === "Enter" && run()} /><button type="button" class="primary" onclick={run} disabled={loading || picking}>{loading ? "…" : $t("media.search")}</button></div>
345
+ {#if searched && !results.length && !error}<p class="hint" role="status">{$t("media.noPhotos")}</p>{/if}
345
346
  <div class="grid">
346
347
  {#each results as r (r.url)}
347
348
  <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>
@@ -353,6 +354,6 @@
353
354
  </div>
354
355
  </dialog>
355
356
  {#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
+ <ActionDialog title={$t("media.deleteTitle", { name: selected.asset.name })} description={$t("media.deleteDescription")} confirmLabel={$t("media.deleteFile")} danger onconfirm={deleteFile} onclose={async () => { confirmDelete = false; await tick(); deleteTrigger?.focus(); }} />
357
358
  {/if}
358
359
  {/if}