@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.
- package/README.md +4 -0
- package/docs/extensions.md +194 -0
- package/package.json +15 -3
- package/src/article-body.ts +49 -37
- package/src/article-render.ts +25 -2
- package/src/article.ts +44 -0
- package/src/config.ts +12 -2
- package/src/editor/App.svelte +98 -97
- package/src/editor/blocks/extension.ts +29 -0
- package/src/editor/blocks/index.ts +17 -15
- package/src/editor/blocks/video.ts +2 -1
- package/src/editor/i18n/README.md +23 -0
- package/src/editor/i18n/de/connection.json +136 -0
- package/src/editor/i18n/de/defaults.json +6 -0
- package/src/editor/i18n/de/fields.json +130 -0
- package/src/editor/i18n/de/local.json +49 -0
- package/src/editor/i18n/de/media.json +166 -0
- package/src/editor/i18n/de/richtext.json +116 -0
- package/src/editor/i18n/de/shell.json +181 -0
- package/src/editor/i18n/de/system.json +14 -0
- package/src/editor/i18n/de/validation.json +82 -0
- package/src/editor/i18n/en/connection.json +136 -0
- package/src/editor/i18n/en/defaults.json +6 -0
- package/src/editor/i18n/en/fields.json +130 -0
- package/src/editor/i18n/en/local.json +49 -0
- package/src/editor/i18n/en/media.json +166 -0
- package/src/editor/i18n/en/richtext.json +116 -0
- package/src/editor/i18n/en/shell.json +181 -0
- package/src/editor/i18n/en/system.json +14 -0
- package/src/editor/i18n/en/validation.json +82 -0
- package/src/editor/i18n/es/connection.json +136 -0
- package/src/editor/i18n/es/defaults.json +6 -0
- package/src/editor/i18n/es/fields.json +130 -0
- package/src/editor/i18n/es/local.json +49 -0
- package/src/editor/i18n/es/media.json +166 -0
- package/src/editor/i18n/es/richtext.json +116 -0
- package/src/editor/i18n/es/shell.json +181 -0
- package/src/editor/i18n/es/system.json +14 -0
- package/src/editor/i18n/es/validation.json +82 -0
- package/src/editor/i18n/format.ts +26 -0
- package/src/editor/i18n/fr/connection.json +136 -0
- package/src/editor/i18n/fr/defaults.json +6 -0
- package/src/editor/i18n/fr/fields.json +130 -0
- package/src/editor/i18n/fr/local.json +49 -0
- package/src/editor/i18n/fr/media.json +166 -0
- package/src/editor/i18n/fr/richtext.json +116 -0
- package/src/editor/i18n/fr/shell.json +181 -0
- package/src/editor/i18n/fr/system.json +14 -0
- package/src/editor/i18n/fr/validation.json +82 -0
- package/src/editor/i18n/index.ts +18 -0
- package/src/editor/i18n/it/connection.json +136 -0
- package/src/editor/i18n/it/defaults.json +6 -0
- package/src/editor/i18n/it/fields.json +130 -0
- package/src/editor/i18n/it/local.json +49 -0
- package/src/editor/i18n/it/media.json +166 -0
- package/src/editor/i18n/it/richtext.json +116 -0
- package/src/editor/i18n/it/shell.json +181 -0
- package/src/editor/i18n/it/system.json +14 -0
- package/src/editor/i18n/it/validation.json +82 -0
- package/src/editor/i18n/ja/connection.json +136 -0
- package/src/editor/i18n/ja/defaults.json +6 -0
- package/src/editor/i18n/ja/fields.json +130 -0
- package/src/editor/i18n/ja/local.json +49 -0
- package/src/editor/i18n/ja/media.json +166 -0
- package/src/editor/i18n/ja/richtext.json +116 -0
- package/src/editor/i18n/ja/shell.json +181 -0
- package/src/editor/i18n/ja/system.json +14 -0
- package/src/editor/i18n/ja/validation.json +82 -0
- package/src/editor/i18n/locales.ts +13 -0
- package/src/editor/i18n/nl/connection.json +136 -0
- package/src/editor/i18n/nl/defaults.json +6 -0
- package/src/editor/i18n/nl/fields.json +130 -0
- package/src/editor/i18n/nl/local.json +49 -0
- package/src/editor/i18n/nl/media.json +166 -0
- package/src/editor/i18n/nl/richtext.json +116 -0
- package/src/editor/i18n/nl/shell.json +181 -0
- package/src/editor/i18n/nl/system.json +14 -0
- package/src/editor/i18n/nl/validation.json +82 -0
- package/src/editor/i18n/ru/connection.json +138 -0
- package/src/editor/i18n/ru/defaults.json +6 -0
- package/src/editor/i18n/ru/fields.json +132 -0
- package/src/editor/i18n/ru/local.json +49 -0
- package/src/editor/i18n/ru/media.json +166 -0
- package/src/editor/i18n/ru/richtext.json +116 -0
- package/src/editor/i18n/ru/shell.json +181 -0
- package/src/editor/i18n/ru/system.json +14 -0
- package/src/editor/i18n/ru/validation.json +82 -0
- package/src/editor/i18n/server.ts +13 -0
- package/src/editor/i18n/zh/connection.json +136 -0
- package/src/editor/i18n/zh/defaults.json +6 -0
- package/src/editor/i18n/zh/fields.json +130 -0
- package/src/editor/i18n/zh/local.json +49 -0
- package/src/editor/i18n/zh/media.json +166 -0
- package/src/editor/i18n/zh/richtext.json +116 -0
- package/src/editor/i18n/zh/shell.json +181 -0
- package/src/editor/i18n/zh/system.json +14 -0
- package/src/editor/i18n/zh/validation.json +82 -0
- package/src/editor/lib/ActionDialog.svelte +5 -4
- package/src/editor/lib/BlockEditor.svelte +305 -88
- package/src/editor/lib/CollectionPanel.svelte +17 -15
- package/src/editor/lib/DatePicker.svelte +15 -12
- package/src/editor/lib/ExtensionMount.svelte +12 -0
- package/src/editor/lib/Field.svelte +3 -2
- package/src/editor/lib/ImagePicker.svelte +82 -81
- package/src/editor/lib/MarkdownEditor.svelte +25 -24
- package/src/editor/lib/MediaPicker.svelte +6 -5
- package/src/editor/lib/MenuEditor.svelte +31 -30
- package/src/editor/lib/PreviewFrame.svelte +11 -6
- package/src/editor/lib/PropertiesTabs.svelte +3 -2
- package/src/editor/lib/PublishDialog.svelte +15 -14
- package/src/editor/lib/ReferenceSelect.svelte +3 -2
- package/src/editor/lib/SchemaField.svelte +15 -13
- package/src/editor/lib/SchemaPanel.svelte +20 -19
- package/src/editor/lib/Select.svelte +3 -2
- package/src/editor/lib/SeoGuidance.svelte +6 -5
- package/src/editor/lib/Settings.svelte +54 -54
- package/src/editor/lib/Shortcuts.svelte +7 -6
- package/src/editor/lib/SitePreview.svelte +10 -9
- package/src/editor/lib/TopBar.svelte +18 -17
- package/src/editor/lib/UnsavedChanges.svelte +7 -6
- package/src/editor/lib/VisualEditor.svelte +14 -13
- package/src/editor/lib/calendar.ts +10 -4
- package/src/editor/lib/embed-preview.ts +23 -5
- package/src/editor/lib/extension-preview.ts +121 -0
- package/src/editor/lib/github-auth.ts +23 -22
- package/src/editor/lib/github-backend.ts +96 -91
- package/src/editor/lib/images.ts +33 -31
- package/src/editor/lib/menu-tree.ts +5 -4
- package/src/editor/lib/preview-frame.ts +4 -3
- package/src/editor/lib/schema-edit.ts +3 -2
- package/src/editor/lib/settings.ts +5 -3
- package/src/editor/lib/slash-command.ts +1 -0
- package/src/editor/lib/tooltip.ts +1 -1
- package/src/editor/main.ts +9 -0
- package/src/editor/styles.css +6 -3
- package/src/env.d.ts +11 -1
- package/src/extensions.ts +160 -0
- package/src/manifest/collections.ts +6 -5
- package/src/manifest/menu.ts +12 -10
- package/src/manifest/navigation.ts +9 -8
- package/src/manifest/references.ts +8 -7
- package/src/manifest/seo.ts +4 -3
- package/src/manifest/types.ts +1 -0
- package/src/preview.ts +22 -2
- package/src/routes/local-api.ts +5 -3
- package/src/routes/local-documents.ts +20 -16
- package/src/routes/local-media.ts +21 -16
- package/src/routes/local-menu.ts +9 -7
- package/src/routes/local-post.ts +18 -12
- package/src/routes/media-files.ts +10 -8
- package/src/routes/rigel-page.astro +2 -1
- package/src/schema/store.ts +22 -20
- package/src/schema/types.ts +1 -0
- package/src/schema/validate.ts +73 -69
- package/src/slugs.ts +11 -10
- 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>
|
|
55
|
-
<label><span>
|
|
56
|
-
<p class="hint">
|
|
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
|
|
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>{
|
|
66
|
+
<span>{label}{devMode ? ` (${field.key})` : ""}</span>
|
|
65
67
|
</label>
|
|
66
68
|
{:else if field.type === "markdown"}
|
|
67
|
-
<MarkdownEditor
|
|
69
|
+
<MarkdownEditor {label} value={String(values[field.key] ?? "")} oninput={(value) => onchange(field.key, value)} />
|
|
68
70
|
{:else if field.type === "image"}
|
|
69
|
-
<label><span>{
|
|
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>{
|
|
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={`${
|
|
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>{
|
|
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>{
|
|
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}>
|
|
88
|
-
<p class="hint">{bodyLoading ? "
|
|
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>
|
|
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,
|
|
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
|
|
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(
|
|
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) || "
|
|
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={
|
|
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="
|
|
95
|
-
<Select ariaLabel="
|
|
96
|
-
<input type="number" aria-label="
|
|
97
|
-
<button type="button" aria-label="
|
|
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
|
|
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())}>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 = "
|
|
91
|
-
} catch { metaError = "
|
|
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(
|
|
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 = "
|
|
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("
|
|
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 =
|
|
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 = "
|
|
138
|
-
if (kind === "image" && metadata && metadata.category !== "Images") { error = "
|
|
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 : "
|
|
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 =
|
|
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("
|
|
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(
|
|
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("
|
|
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 = "
|
|
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 : "
|
|
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 =
|
|
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>
|
|
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">
|
|
228
|
-
<button bind:this={trigger} type="button" class="outlined" onclick={launch}>
|
|
229
|
-
{#if value && onpick}<button type="button" class="outlined" aria-label="
|
|
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">
|
|
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="
|
|
237
|
-
<button type="button" class="outlined" data-tooltip="
|
|
238
|
-
<button type="button" class="outlined" data-tooltip="
|
|
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 ? "
|
|
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="
|
|
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}>
|
|
247
|
-
{#if filter || format || month}<button type="button" class="outlined" onclick={resetFilters}>
|
|
248
|
-
<input class="media-search" aria-label="
|
|
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() ?? "
|
|
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="
|
|
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(
|
|
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">
|
|
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 ? "
|
|
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 ?
|
|
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" ? "
|
|
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="
|
|
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>
|
|
283
|
-
{#if imageImport}<label><span>
|
|
284
|
-
<label><span>
|
|
285
|
-
<label><span>
|
|
286
|
-
{#if selected.asset}<p class="hint">
|
|
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" ? "
|
|
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>
|
|
294
|
-
{#if !inline && !imageImport}<label><span>{kind === "video" ? "
|
|
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="
|
|
297
|
-
<label class="inline"><input type="checkbox" role="switch" class="switch" aria-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">
|
|
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)}>
|
|
306
|
-
{#if inline && writable}<button bind:this={deleteTrigger} type="button" class="danger-btn" aria-label="
|
|
307
|
-
<pre class="media-path" role="region" aria-label="
|
|
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}>
|
|
312
|
-
<button type="button" class="primary" disabled={picking || savingMeta} onclick={saveMetadata}>{savingMeta ? "
|
|
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(); }}>
|
|
315
|
-
{#if inline && tab === "library"}<button type="button" class="primary" onclick={close}>
|
|
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" ? "
|
|
321
|
-
{#each tabs.filter(
|
|
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">
|
|
324
|
+
{#if inline}<p class="hint">{$t("media.unsplashUploadsHint")}</p>{/if}
|
|
324
325
|
{#if providerTab}
|
|
325
|
-
<p class="hint">
|
|
326
|
-
<label><span>
|
|
327
|
-
<label><span>
|
|
328
|
-
<label class="inline"><input type="checkbox" role="switch" class="switch" bind:checked={privacy} /><span>
|
|
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}>
|
|
331
|
-
<button type="button" class="primary" onclick={confirm}>{editing ? "
|
|
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">
|
|
337
|
-
{#if writable}<label><span>{kind === "video" ? "
|
|
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" ? "
|
|
340
|
-
<button type="button" class="primary" onclick={run}>
|
|
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" ? "
|
|
343
|
-
<div class="search-row"><input aria-label="
|
|
344
|
-
{#if searched && !results.length && !error}<p class="hint" role="status">
|
|
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={
|
|
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}
|