@rigel-cms/rigel 0.0.0-stage → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +203 -0
- package/README.md +8 -2
- package/package.json +78 -4
- package/src/article-body.ts +60 -0
- package/src/article-render.ts +87 -0
- package/src/config.ts +97 -0
- package/src/content.ts +5 -0
- package/src/editor/App.svelte +972 -0
- package/src/editor/assets/rigelcms-icon.png +0 -0
- package/src/editor/assets/sidebar/LICENSE +21 -0
- package/src/editor/assets/sidebar/book-open.svg +1 -0
- package/src/editor/assets/sidebar/calendar.svg +1 -0
- package/src/editor/assets/sidebar/chart-bar.svg +1 -0
- package/src/editor/assets/sidebar/file-text.svg +1 -0
- package/src/editor/assets/sidebar/files.svg +1 -0
- package/src/editor/assets/sidebar/folder.svg +1 -0
- package/src/editor/assets/sidebar/folders.svg +1 -0
- package/src/editor/assets/sidebar/gear.svg +1 -0
- package/src/editor/assets/sidebar/globe.svg +1 -0
- package/src/editor/assets/sidebar/heart.svg +1 -0
- package/src/editor/assets/sidebar/house.svg +1 -0
- package/src/editor/assets/sidebar/images.svg +1 -0
- package/src/editor/assets/sidebar/layout.svg +1 -0
- package/src/editor/assets/sidebar/list.svg +1 -0
- package/src/editor/assets/sidebar/magnifying-glass.svg +1 -0
- package/src/editor/assets/sidebar/megaphone.svg +1 -0
- package/src/editor/assets/sidebar/newspaper.svg +1 -0
- package/src/editor/assets/sidebar/pencil-simple.svg +1 -0
- package/src/editor/assets/sidebar/shopping-bag.svg +1 -0
- package/src/editor/assets/sidebar/sidebar-simple.svg +1 -0
- package/src/editor/assets/sidebar/square-half.svg +1 -0
- package/src/editor/assets/sidebar/squares-four.svg +1 -0
- package/src/editor/assets/sidebar/tag.svg +1 -0
- package/src/editor/assets/sidebar/user.svg +1 -0
- package/src/editor/assets/sidebar/users.svg +1 -0
- package/src/editor/blocks/bulleted-list.ts +2 -0
- package/src/editor/blocks/code.ts +1 -0
- package/src/editor/blocks/divider.ts +14 -0
- package/src/editor/blocks/h1.ts +2 -0
- package/src/editor/blocks/h2.ts +2 -0
- package/src/editor/blocks/h3.ts +2 -0
- package/src/editor/blocks/h4.ts +2 -0
- package/src/editor/blocks/h5.ts +2 -0
- package/src/editor/blocks/h6.ts +2 -0
- package/src/editor/blocks/image.ts +34 -0
- package/src/editor/blocks/index.ts +40 -0
- package/src/editor/blocks/numbered-list.ts +2 -0
- package/src/editor/blocks/paragraph.ts +1 -0
- package/src/editor/blocks/quote.ts +1 -0
- package/src/editor/blocks/table.ts +5 -0
- package/src/editor/lib/ActionDialog.svelte +51 -0
- package/src/editor/lib/BlockEditor.svelte +614 -0
- package/src/editor/lib/CollectionPanel.svelte +103 -0
- package/src/editor/lib/DatePicker.svelte +106 -0
- package/src/editor/lib/Field.svelte +80 -0
- package/src/editor/lib/Icon.svelte +34 -0
- package/src/editor/lib/ImagePicker.svelte +264 -0
- package/src/editor/lib/MarkdownEditor.svelte +206 -0
- package/src/editor/lib/MenuEditor.svelte +209 -0
- package/src/editor/lib/PropertiesTabs.svelte +15 -0
- package/src/editor/lib/ReferenceSelect.svelte +17 -0
- package/src/editor/lib/SchemaField.svelte +156 -0
- package/src/editor/lib/SchemaPanel.svelte +189 -0
- package/src/editor/lib/Select.svelte +138 -0
- package/src/editor/lib/SeoGuidance.svelte +21 -0
- package/src/editor/lib/Settings.svelte +183 -0
- package/src/editor/lib/Shortcuts.svelte +29 -0
- package/src/editor/lib/SidebarIcon.svelte +8 -0
- package/src/editor/lib/SitePreview.svelte +50 -0
- package/src/editor/lib/TopBar.svelte +165 -0
- package/src/editor/lib/UnsavedChanges.svelte +38 -0
- package/src/editor/lib/VisualEditor.svelte +494 -0
- package/src/editor/lib/calendar.ts +55 -0
- package/src/editor/lib/github-auth.ts +117 -0
- package/src/editor/lib/github-backend.ts +263 -0
- package/src/editor/lib/icons.ts +111 -0
- package/src/editor/lib/images.ts +195 -0
- package/src/editor/lib/markdown.ts +71 -0
- package/src/editor/lib/media-library.ts +39 -0
- package/src/editor/lib/menu-tree.ts +37 -0
- package/src/editor/lib/rich-markdown.ts +30 -0
- package/src/editor/lib/schema-edit.ts +110 -0
- package/src/editor/lib/settings.ts +103 -0
- package/src/editor/lib/slugify.ts +10 -0
- package/src/editor/lib/storage.ts +17 -0
- package/src/editor/lib/store.svelte.ts +33 -0
- package/src/editor/main.ts +9 -0
- package/src/editor/styles.css +1280 -0
- package/src/env.d.ts +22 -0
- package/src/index.ts +128 -0
- package/src/manifest/frontmatter.ts +56 -0
- package/src/manifest/loader.ts +14 -0
- package/src/manifest/menu.ts +46 -0
- package/src/manifest/navigation.ts +94 -0
- package/src/manifest/references.ts +39 -0
- package/src/manifest/seo.ts +15 -0
- package/src/manifest/types.ts +75 -0
- package/src/media-types.ts +21 -0
- package/src/preview-urls.ts +3 -0
- package/src/preview.ts +167 -0
- package/src/routes/local-api.ts +42 -0
- package/src/routes/local-documents.ts +83 -0
- package/src/routes/local-media.ts +124 -0
- package/src/routes/local-menu.ts +81 -0
- package/src/routes/local-post.ts +80 -0
- package/src/routes/manifest.json.ts +15 -0
- package/src/routes/media-files.ts +126 -0
- package/src/routes/media-validation.ts +37 -0
- package/src/routes/media.json.ts +12 -0
- package/src/routes/rigel-page.astro +25 -0
- package/src/routes/serialize.ts +139 -0
- package/src/schema/docs.ts +33 -0
- package/src/schema/paths.ts +136 -0
- package/src/schema/store.ts +141 -0
- package/src/schema/types.ts +81 -0
- package/src/schema/validate.ts +327 -0
- package/src/vite/manifest-plugin.ts +180 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from "svelte";
|
|
3
|
+
import type { CollectionField } from "../../manifest/types";
|
|
4
|
+
import type { SelectedField } from "./schema-edit";
|
|
5
|
+
import ImagePicker from "./ImagePicker.svelte";
|
|
6
|
+
import MarkdownEditor from "./MarkdownEditor.svelte";
|
|
7
|
+
import DatePicker from "./DatePicker.svelte";
|
|
8
|
+
import BlockEditor from "./BlockEditor.svelte";
|
|
9
|
+
import type { ArticleBody } from "../../article-body";
|
|
10
|
+
import PropertiesTabs from "./PropertiesTabs.svelte";
|
|
11
|
+
import SeoGuidance from "./SeoGuidance.svelte";
|
|
12
|
+
import ReferenceSelect from "./ReferenceSelect.svelte";
|
|
13
|
+
import { referenceOptions } from "../../manifest/references";
|
|
14
|
+
import { isSeoField } from "../../manifest/seo";
|
|
15
|
+
import type { ContentDocument } from "../../schema/types";
|
|
16
|
+
|
|
17
|
+
let { fields, values, body, selected, focusToken, devMode, documents = [], bodyInline = false, oneditbody = () => {}, onchange, onbody }: {
|
|
18
|
+
fields: CollectionField[];
|
|
19
|
+
values: Record<string, unknown>;
|
|
20
|
+
body: ArticleBody;
|
|
21
|
+
bodyInline?: boolean;
|
|
22
|
+
oneditbody?: () => void;
|
|
23
|
+
selected: SelectedField | null;
|
|
24
|
+
focusToken: number;
|
|
25
|
+
devMode: boolean;
|
|
26
|
+
documents?: ContentDocument[];
|
|
27
|
+
onchange: (key: string, value: unknown) => void;
|
|
28
|
+
onbody: (body: ArticleBody) => void;
|
|
29
|
+
} = $props();
|
|
30
|
+
let host: HTMLDivElement;
|
|
31
|
+
let tab = $state<"content" | "seo">("content");
|
|
32
|
+
|
|
33
|
+
$effect(() => {
|
|
34
|
+
void focusToken;
|
|
35
|
+
const path = selected?.path;
|
|
36
|
+
if (!path || !focusToken) return;
|
|
37
|
+
tab = "content";
|
|
38
|
+
void tick().then(() => {
|
|
39
|
+
const field = host?.querySelector(`[data-collection-field="${CSS.escape(path)}"]`);
|
|
40
|
+
const control = (path === "$body" ? field?.querySelector<HTMLElement>('[contenteditable="true"]') : field?.querySelector<HTMLElement>('button[role="combobox"], input, textarea, [contenteditable="true"]')) ?? field?.querySelector<HTMLElement>('button');
|
|
41
|
+
control?.focus();
|
|
42
|
+
control?.scrollIntoView({ block: "nearest" });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<div class="schema-panel collection-panel" bind:this={host}>
|
|
48
|
+
<PropertiesTabs bind:tab />
|
|
49
|
+
<div id="properties-content" role="tabpanel" aria-labelledby="properties-tab-content" hidden={tab !== "content"}>
|
|
50
|
+
<section>
|
|
51
|
+
<h3>Article details</h3>
|
|
52
|
+
{#each fields.filter(field => !isSeoField(field.key)) as field (field.key)}
|
|
53
|
+
<div data-collection-field={field.key}>
|
|
54
|
+
{#if field.type === "reference"}
|
|
55
|
+
<ReferenceSelect label={field.label} value={values[field.key]} options={referenceOptions(field, documents)} multiple={field.multiple} onchange={(value) => onchange(field.key, value)} />
|
|
56
|
+
{:else if field.type === "boolean"}
|
|
57
|
+
<label class="inline">
|
|
58
|
+
<input type="checkbox" role="switch" class="switch" checked={Boolean(values[field.key])} onchange={(event) => onchange(field.key, event.currentTarget.checked)} />
|
|
59
|
+
<span>{field.label}{devMode ? ` (${field.key})` : ""}</span>
|
|
60
|
+
</label>
|
|
61
|
+
{:else if field.type === "markdown"}
|
|
62
|
+
<MarkdownEditor label={field.label} value={String(values[field.key] ?? "")} oninput={(value) => onchange(field.key, value)} />
|
|
63
|
+
{:else if field.type === "image"}
|
|
64
|
+
<label><span>{field.label}{devMode ? ` (${field.key})` : ""}</span><ImagePicker value={String(values[field.key] ?? "")} onpick={(value) => onchange(field.key, value)} /></label>
|
|
65
|
+
{:else if field.type === "tags"}
|
|
66
|
+
<label>
|
|
67
|
+
<span>{field.label}{devMode ? ` (${field.key})` : ""} (comma-separated)</span>
|
|
68
|
+
<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))} />
|
|
69
|
+
</label>
|
|
70
|
+
{:else if field.type === "date"}
|
|
71
|
+
<DatePicker label={`${field.label}${devMode ? ` (${field.key})` : ""}`} value={String(values[field.key] ?? "")} onchange={(value) => onchange(field.key, value)} />
|
|
72
|
+
{:else if field.type === "text"}
|
|
73
|
+
<label><span>{field.label}{devMode ? ` (${field.key})` : ""}</span><textarea value={String(values[field.key] ?? "")} oninput={(event) => onchange(field.key, event.currentTarget.value)}></textarea></label>
|
|
74
|
+
{:else}
|
|
75
|
+
<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>
|
|
76
|
+
{/if}
|
|
77
|
+
</div>
|
|
78
|
+
{/each}
|
|
79
|
+
</section>
|
|
80
|
+
<section data-collection-field="$body">
|
|
81
|
+
{#if bodyInline}
|
|
82
|
+
<button class="outlined" onclick={oneditbody}>Edit article body in preview</button>
|
|
83
|
+
<p class="hint">Click the article body in the preview to edit. Content changes require Save.</p>
|
|
84
|
+
{:else}<BlockEditor value={body} oninput={onbody} />{/if}
|
|
85
|
+
</section>
|
|
86
|
+
</div>
|
|
87
|
+
<div id="properties-seo" role="tabpanel" aria-labelledby="properties-tab-seo" hidden={tab !== "seo"}>
|
|
88
|
+
<section class="seo-fields">
|
|
89
|
+
<h3>SEO</h3>
|
|
90
|
+
{#each fields.filter(field => isSeoField(field.key)) as field (field.key)}
|
|
91
|
+
<div data-collection-field={field.key}>
|
|
92
|
+
<label>
|
|
93
|
+
<span>{field.label}</span>
|
|
94
|
+
{#if field.type === "image"}<ImagePicker value={String(values[field.key] ?? "")} onpick={(value) => onchange(field.key, value)} />
|
|
95
|
+
{:else if field.type === "text"}<textarea rows="3" value={String(values[field.key] ?? "")} oninput={(event) => onchange(field.key, event.currentTarget.value)}></textarea>
|
|
96
|
+
{:else}<input type="text" value={String(values[field.key] ?? "")} oninput={(event) => onchange(field.key, event.currentTarget.value)} />{/if}
|
|
97
|
+
</label>
|
|
98
|
+
<SeoGuidance field={field.key} value={values[field.key]} />
|
|
99
|
+
</div>
|
|
100
|
+
{/each}
|
|
101
|
+
</section>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from "svelte";
|
|
3
|
+
import Icon from "./Icon.svelte";
|
|
4
|
+
import Select from "./Select.svelte";
|
|
5
|
+
import { formatDate, months, monthDays, moveDay, moveMonth, parseDate, todayDate } from "./calendar";
|
|
6
|
+
|
|
7
|
+
let { label, value = "", required = false, onchange }: { label: string; value?: string; required?: boolean; onchange: (value: string) => void } = $props();
|
|
8
|
+
const id = $props.id();
|
|
9
|
+
const monthOptions = months.map((label, index) => ({ label, value: String(index + 1) }));
|
|
10
|
+
let host: HTMLDivElement;
|
|
11
|
+
let trigger: HTMLButtonElement;
|
|
12
|
+
let menu: HTMLDivElement;
|
|
13
|
+
let open = $state(false);
|
|
14
|
+
let focused = $state(todayDate());
|
|
15
|
+
let today = $state(todayDate());
|
|
16
|
+
let placement = $state("");
|
|
17
|
+
const year = $derived(Number(focused.slice(0, 4)));
|
|
18
|
+
const month = $derived(Number(focused.slice(5, 7)));
|
|
19
|
+
const cells = $derived(monthDays(focused));
|
|
20
|
+
const weeks = $derived(Array.from({ length: 6 }, (_, row) => cells.slice(row * 7, row * 7 + 7)));
|
|
21
|
+
const heading = $derived(`${months[month - 1]} ${year}`);
|
|
22
|
+
|
|
23
|
+
function position() {
|
|
24
|
+
if (!open || !trigger || !menu) return;
|
|
25
|
+
const view = trigger.ownerDocument.defaultView!, rect = trigger.getBoundingClientRect();
|
|
26
|
+
const width = Math.min(336, view.innerWidth - 16), height = Math.min(menu.scrollHeight + 2, view.innerHeight - 16);
|
|
27
|
+
const below = view.innerHeight - rect.bottom - 8, above = rect.top - 8;
|
|
28
|
+
const top = below >= height || below >= above ? rect.bottom + 4 : rect.top - height - 4;
|
|
29
|
+
placement = `width:${width}px;left:${Math.max(8, Math.min(rect.left, view.innerWidth - width - 8))}px;top:${Math.max(8, Math.min(top, view.innerHeight - height - 8))}px;max-height:${view.innerHeight - 16}px`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function close(restoreFocus = false) {
|
|
33
|
+
if (menu?.matches(":popover-open")) menu.hidePopover();
|
|
34
|
+
open = false;
|
|
35
|
+
if (restoreFocus) trigger.focus({ preventScroll: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function focusDay() {
|
|
39
|
+
await tick();
|
|
40
|
+
menu.querySelector<HTMLButtonElement>(`[data-date="${focused}"]`)?.focus({ preventScroll: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function reveal() {
|
|
44
|
+
today = todayDate();
|
|
45
|
+
focused = parseDate(value) ? value : today;
|
|
46
|
+
trigger.scrollIntoView({ block: "nearest" });
|
|
47
|
+
open = true;
|
|
48
|
+
menu.showPopover();
|
|
49
|
+
await tick();
|
|
50
|
+
position();
|
|
51
|
+
await focusDay();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function choose(next: string) {
|
|
55
|
+
if ((next && !parseDate(next)) || (!next && required)) return;
|
|
56
|
+
onchange(next);
|
|
57
|
+
close(true);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setYear(event: Event) {
|
|
61
|
+
const input = event.currentTarget as HTMLInputElement, next = Number(input.value);
|
|
62
|
+
if (Number.isInteger(next) && next >= 1 && next <= 9999) focused = moveMonth(focused, (next - year) * 12);
|
|
63
|
+
input.value = String(Number(focused.slice(0, 4)));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function gridKey(event: KeyboardEvent) {
|
|
67
|
+
const date = parseDate(focused)!;
|
|
68
|
+
const offsets: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7, Home: -date.getUTCDay(), End: 6 - date.getUTCDay() };
|
|
69
|
+
if (event.key in offsets) focused = moveDay(focused, offsets[event.key]);
|
|
70
|
+
else if (event.key === "PageUp" || event.key === "PageDown") focused = moveMonth(focused, (event.key === "PageUp" ? -1 : 1) * (event.shiftKey ? 12 : 1));
|
|
71
|
+
else return;
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
await focusDay();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
$effect(() => {
|
|
77
|
+
if (!open) return;
|
|
78
|
+
const view = trigger.ownerDocument.defaultView!;
|
|
79
|
+
const observer = new ResizeObserver(position);
|
|
80
|
+
observer.observe(trigger); observer.observe(menu);
|
|
81
|
+
const scroll = (event: Event) => { if (!(event.target as Node)?.nodeType || !menu.contains(event.target as Node)) position(); };
|
|
82
|
+
view.addEventListener("resize", position); view.addEventListener("scroll", scroll, true);
|
|
83
|
+
return () => { observer.disconnect(); view.removeEventListener("resize", position); view.removeEventListener("scroll", scroll, true); };
|
|
84
|
+
});
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<div class="date-picker" bind:this={host} onfocusout={() => { if (open) requestAnimationFrame(() => { if (!host?.contains(trigger.ownerDocument.activeElement)) close(); }); }}>
|
|
88
|
+
<span class="select-label" id={`${id}-label`}>{label}</span>
|
|
89
|
+
<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} />
|
|
91
|
+
</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); } }}>
|
|
93
|
+
<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>
|
|
98
|
+
</div>
|
|
99
|
+
<span class="sr-only" id={`${id}-month`} aria-live="polite">{heading}</span>
|
|
100
|
+
<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>
|
|
103
|
+
</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>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CopyField } from "../manifest/types";
|
|
3
|
+
import Field from "./Field.svelte";
|
|
4
|
+
import ImagePicker from "./ImagePicker.svelte";
|
|
5
|
+
import MarkdownEditor from "./MarkdownEditor.svelte";
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
field,
|
|
9
|
+
value,
|
|
10
|
+
oninput,
|
|
11
|
+
devMode = false,
|
|
12
|
+
}: {
|
|
13
|
+
field: CopyField;
|
|
14
|
+
value: unknown;
|
|
15
|
+
oninput: (v: unknown) => void;
|
|
16
|
+
devMode?: boolean;
|
|
17
|
+
} = $props();
|
|
18
|
+
|
|
19
|
+
const str = $derived(typeof value === "string" ? value : "");
|
|
20
|
+
const label = $derived(`${field.label}${devMode ? ` (${field.key})` : ""}`);
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
{#if field.type === "string"}
|
|
24
|
+
<label>
|
|
25
|
+
<span>{label}</span>
|
|
26
|
+
<input
|
|
27
|
+
type="text"
|
|
28
|
+
value={str}
|
|
29
|
+
placeholder={field.placeholder ?? ""}
|
|
30
|
+
oninput={(e) => oninput(e.currentTarget.value)}
|
|
31
|
+
/>
|
|
32
|
+
</label>
|
|
33
|
+
{:else if field.type === "text"}
|
|
34
|
+
<label>
|
|
35
|
+
<span>{label}</span>
|
|
36
|
+
<textarea rows="3" oninput={(e) => oninput(e.currentTarget.value)}>{str}</textarea>
|
|
37
|
+
</label>
|
|
38
|
+
{:else if field.type === "markdown"}
|
|
39
|
+
<MarkdownEditor value={str} {label} {oninput} />
|
|
40
|
+
{:else if field.type === "image"}
|
|
41
|
+
<label>
|
|
42
|
+
<span>{label}</span>
|
|
43
|
+
<ImagePicker value={str} onpick={(url) => oninput(url)} />
|
|
44
|
+
</label>
|
|
45
|
+
{:else if field.type === "list"}
|
|
46
|
+
{@const items = Array.isArray(value) ? (value as Record<string, unknown>[]) : []}
|
|
47
|
+
<div class="field-label">{label}</div>
|
|
48
|
+
{#each items as item, i (i)}
|
|
49
|
+
<div class="list-item">
|
|
50
|
+
{#each field.item.fields as sub (sub.key)}
|
|
51
|
+
<Field
|
|
52
|
+
field={sub}
|
|
53
|
+
{devMode}
|
|
54
|
+
value={item[sub.key]}
|
|
55
|
+
oninput={(v: unknown) => {
|
|
56
|
+
const next = items.slice();
|
|
57
|
+
next[i] = { ...item, [sub.key]: v };
|
|
58
|
+
oninput(next);
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
{/each}
|
|
62
|
+
<button
|
|
63
|
+
class="remove"
|
|
64
|
+
onclick={() => oninput(items.filter((_, j) => j !== i))}
|
|
65
|
+
>
|
|
66
|
+
Remove
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
{/each}
|
|
70
|
+
<button
|
|
71
|
+
class="add"
|
|
72
|
+
onclick={() => {
|
|
73
|
+
const blank: Record<string, unknown> = {};
|
|
74
|
+
for (const sub of field.item.fields) blank[sub.key] = "";
|
|
75
|
+
oninput([...items, blank]);
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
Add item
|
|
79
|
+
</button>
|
|
80
|
+
{/if}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ICONS } from "./icons";
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
name,
|
|
6
|
+
size = 16,
|
|
7
|
+
weight = "regular",
|
|
8
|
+
}: {
|
|
9
|
+
name: keyof typeof ICONS;
|
|
10
|
+
size?: number;
|
|
11
|
+
weight?: "light" | "regular" | "bold";
|
|
12
|
+
} = $props();
|
|
13
|
+
|
|
14
|
+
const icon = $derived(ICONS[name]);
|
|
15
|
+
const d = $derived(icon?.d ?? "");
|
|
16
|
+
const viewBox = $derived(icon?.viewBox ?? "0 0 24 24");
|
|
17
|
+
const strokeWidth = $derived(weight === "light" ? 1.2 : weight === "bold" ? 2.2 : 1.8);
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<svg
|
|
21
|
+
class="rigel-icon"
|
|
22
|
+
viewBox={viewBox}
|
|
23
|
+
width={size}
|
|
24
|
+
height={size}
|
|
25
|
+
fill="currentColor"
|
|
26
|
+
stroke="none"
|
|
27
|
+
stroke-width={strokeWidth}
|
|
28
|
+
stroke-linecap="round"
|
|
29
|
+
stroke-linejoin="round"
|
|
30
|
+
aria-hidden="true"
|
|
31
|
+
style="display:inline-block;vertical-align:middle;flex-shrink:0"
|
|
32
|
+
>
|
|
33
|
+
<path {d} />
|
|
34
|
+
</svg>
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from "svelte";
|
|
3
|
+
import Icon from "./Icon.svelte";
|
|
4
|
+
import Select from "./Select.svelte";
|
|
5
|
+
import { getSettings, setSettings } from "./settings";
|
|
6
|
+
import { MEDIA_CATEGORIES, mediaMetadata, mediaAccept, safeMediaName } from "../../media-types";
|
|
7
|
+
import { filterMedia, modifiedMonth, mediaSize, assetFile, type MediaAsset } from "./media-library";
|
|
8
|
+
import { searchImages, importStockImage, importImageUrl, uploadMedia, safeImageUrl, STOCK_PROVIDERS, type StockResult, type StockSource } from "./images";
|
|
9
|
+
|
|
10
|
+
let { value = "", altText = "", triggerLabel = "Insert image", onpick, inline = false, toolbar = false, onopen, onclose }: {
|
|
11
|
+
value?: string; onpick?: (url: string, alt: string, credit?: StockResult) => void;
|
|
12
|
+
altText?: string; triggerLabel?: string;
|
|
13
|
+
inline?: boolean; toolbar?: boolean; onopen?: () => void; onclose?: () => void;
|
|
14
|
+
} = $props();
|
|
15
|
+
let open = $state(false);
|
|
16
|
+
let tab = $state<"library" | "upload" | "url" | StockSource>("library");
|
|
17
|
+
const tabs = ["library", "upload", "url", "picsum", ...Object.keys(STOCK_PROVIDERS)] as (typeof tab)[];
|
|
18
|
+
let query = $state("");
|
|
19
|
+
let filter = $state("");
|
|
20
|
+
let format = $state("");
|
|
21
|
+
let month = $state("");
|
|
22
|
+
let view = $state<"grid" | "list">(getSettings().userPref.mediaView);
|
|
23
|
+
function setView(next: "grid" | "list") { view = next; setSettings({ userPref: { previewSplit: getSettings().userPref.previewSplit, mediaView: next } }); }
|
|
24
|
+
let librarySelection = $state("");
|
|
25
|
+
let results = $state<StockResult[]>([]);
|
|
26
|
+
let assets = $state<MediaAsset[]>([]);
|
|
27
|
+
let writable = $state(false);
|
|
28
|
+
let folder = $state("");
|
|
29
|
+
let loading = $state(false);
|
|
30
|
+
let picking = $state(false);
|
|
31
|
+
let searched = $state(false);
|
|
32
|
+
let requestId = 0;
|
|
33
|
+
let error = $state("");
|
|
34
|
+
let notice = $state("");
|
|
35
|
+
let urlInput = $state("");
|
|
36
|
+
let alt = $state("");
|
|
37
|
+
let selected = $state<{url: string; stock?: StockResult; file?: File; asset?: MediaAsset} | null>(null);
|
|
38
|
+
let metaName = $state("");
|
|
39
|
+
let metaTitle = $state("");
|
|
40
|
+
let metaDescription = $state("");
|
|
41
|
+
let metaAlt = $state("");
|
|
42
|
+
let savingMeta = $state(false);
|
|
43
|
+
let metaError = $state("");
|
|
44
|
+
let metaNotice = $state("");
|
|
45
|
+
let trigger: HTMLButtonElement;
|
|
46
|
+
let dialog: HTMLDialogElement;
|
|
47
|
+
let cancel: HTMLButtonElement;
|
|
48
|
+
let objectURL = "";
|
|
49
|
+
const filtered = $derived(filterMedia(assets, filter, format, month));
|
|
50
|
+
const months = $derived([...new Set(assets.map(a => modifiedMonth(a.modified)).filter(Boolean))].sort().reverse());
|
|
51
|
+
function resetFilters() { filter = ""; format = ""; month = ""; }
|
|
52
|
+
function selectAsset(asset: MediaAsset) {
|
|
53
|
+
librarySelection = asset.url; selected = {url: asset.url, asset};
|
|
54
|
+
metaName = asset.name; metaTitle = asset.title ?? ""; metaDescription = asset.description ?? ""; metaAlt = asset.alt ?? "";
|
|
55
|
+
alt = value ? altText : asset.alt ?? "";
|
|
56
|
+
metaError = ""; metaNotice = "";
|
|
57
|
+
if (inline) { tab = "library"; open = true; }
|
|
58
|
+
}
|
|
59
|
+
async function saveMetadata() {
|
|
60
|
+
if (!selected?.asset || savingMeta) return;
|
|
61
|
+
const url = selected.asset.url;
|
|
62
|
+
savingMeta = true; metaError = ""; metaNotice = "";
|
|
63
|
+
try {
|
|
64
|
+
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 }) });
|
|
65
|
+
if (!response.ok) { metaError = `Metadata save failed (${response.status})`; return; }
|
|
66
|
+
await library();
|
|
67
|
+
selected = { url, asset: assets.find(a => a.url === url) };
|
|
68
|
+
metaNotice = "Metadata saved.";
|
|
69
|
+
} catch { metaError = "Metadata save failed"; }
|
|
70
|
+
finally { savingMeta = false; }
|
|
71
|
+
}
|
|
72
|
+
const needsImport = $derived(!!selected?.file || (!!selected && ((inline && tab === "url") || !selected.url.startsWith("/")) && selected.stock?.source !== "unsplash"));
|
|
73
|
+
|
|
74
|
+
async function library() {
|
|
75
|
+
const response = await fetch("/rigel/media.json", { cache: "no-store" });
|
|
76
|
+
if (!response.ok) throw new Error("Media listing unavailable. Start the local dev server or rebuild the site.");
|
|
77
|
+
const data = await response.json();
|
|
78
|
+
assets = (data.assets ?? data.images).filter((asset: MediaAsset) => inline || asset.type?.startsWith("image/"));
|
|
79
|
+
writable = data.writable === true;
|
|
80
|
+
folder = data.folder;
|
|
81
|
+
}
|
|
82
|
+
onMount(() => { if (inline) void run(); });
|
|
83
|
+
onDestroy(() => { requestId++; releaseFile(); });
|
|
84
|
+
$effect(() => {
|
|
85
|
+
if (open && dialog) { dialog.showModal(); cancel?.focus(); }
|
|
86
|
+
});
|
|
87
|
+
function releaseFile() { if (objectURL) URL.revokeObjectURL(objectURL); objectURL = ""; }
|
|
88
|
+
async function run() {
|
|
89
|
+
if (picking) return;
|
|
90
|
+
const request = ++requestId;
|
|
91
|
+
error = "";
|
|
92
|
+
if (tab === "url") {
|
|
93
|
+
if (!safeImageUrl(urlInput.trim())) { error = "Enter an image URL starting with https:// or /"; return; }
|
|
94
|
+
const metadata = mediaMetadata(new URL(urlInput.trim(), location.origin).pathname);
|
|
95
|
+
if (metadata && metadata.category !== "Images") { error = "Choose an image URL, not a document or other media file."; return; }
|
|
96
|
+
selected = { url: urlInput.trim() };
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
loading = true; results = []; searched = false;
|
|
100
|
+
try {
|
|
101
|
+
if (tab === "library" || tab === "upload") await library();
|
|
102
|
+
else {
|
|
103
|
+
const found = await searchImages(tab, query);
|
|
104
|
+
if (request === requestId) results = found;
|
|
105
|
+
}
|
|
106
|
+
if (request === requestId) searched = true;
|
|
107
|
+
} catch (e) { if (request === requestId) error = e instanceof Error ? e.message : "Search failed"; }
|
|
108
|
+
finally { if (request === requestId) loading = false; }
|
|
109
|
+
}
|
|
110
|
+
async function confirm() {
|
|
111
|
+
if (!selected || picking) return;
|
|
112
|
+
const request = ++requestId;
|
|
113
|
+
const choice = selected;
|
|
114
|
+
picking = true; error = "";
|
|
115
|
+
try {
|
|
116
|
+
const url = choice.file ? await uploadMedia(choice.file) : choice.stock ? await importStockImage(choice.stock) : needsImport ? await importImageUrl(choice.url) : choice.url;
|
|
117
|
+
if (!safeImageUrl(url)) throw new Error("Unsafe image URL");
|
|
118
|
+
if (request === requestId) {
|
|
119
|
+
if (inline) {
|
|
120
|
+
await library(); resetFilters(); notice = "Media available in the repository library.";
|
|
121
|
+
} else onpick?.(url, alt, choice.stock);
|
|
122
|
+
close();
|
|
123
|
+
}
|
|
124
|
+
} catch (e) { if (request === requestId) error = e instanceof Error ? e.message : "Photo import failed"; }
|
|
125
|
+
finally { if (request === requestId) picking = false; }
|
|
126
|
+
}
|
|
127
|
+
function upload(file?: File) {
|
|
128
|
+
if (!file || picking) return;
|
|
129
|
+
error = "";
|
|
130
|
+
if (!safeMediaName(file.name) || (!inline && mediaMetadata(file.name)?.category !== "Images") || file.size > 10 * 1024 * 1024) {
|
|
131
|
+
error = "Choose a supported " + (inline ? "file" : "image") + " up to 10 MB with a safe filename."; return;
|
|
132
|
+
}
|
|
133
|
+
releaseFile(); objectURL = URL.createObjectURL(file);
|
|
134
|
+
selected = { url: objectURL, file };
|
|
135
|
+
}
|
|
136
|
+
function close() {
|
|
137
|
+
requestId++; dialog?.close(); open = false; loading = false; picking = false; selected = null; error = "";
|
|
138
|
+
releaseFile(); onclose?.(); trigger?.focus();
|
|
139
|
+
}
|
|
140
|
+
function changeTab(next: typeof tab) {
|
|
141
|
+
alt = altText;
|
|
142
|
+
requestId++; releaseFile(); tab = next; results = []; selected = null; error = ""; searched = false; loading = false;
|
|
143
|
+
if (tab !== "url") void run();
|
|
144
|
+
}
|
|
145
|
+
function launch() { onopen?.(); open = true; urlInput = value; alt = altText; changeTab(inline ? "upload" : "library"); }
|
|
146
|
+
</script>
|
|
147
|
+
|
|
148
|
+
{#if inline}
|
|
149
|
+
<div class="titlebar"><h2>Media</h2><button bind:this={trigger} type="button" class="primary" onclick={launch}>Upload</button></div>
|
|
150
|
+
<div class="media-library">
|
|
151
|
+
{@render libraryBrowser()}
|
|
152
|
+
{#if notice}<p role="status">{notice}</p>{/if}
|
|
153
|
+
{#if error && !open}<p class="error" role="alert">{error}</p>{/if}
|
|
154
|
+
</div>
|
|
155
|
+
{:else if toolbar}
|
|
156
|
+
<button bind:this={trigger} data-image-trigger type="button" aria-label={triggerLabel} title={triggerLabel} onmousedown={event => event.preventDefault()} onclick={launch}><Icon name="image" size={18} /></button>
|
|
157
|
+
{:else}
|
|
158
|
+
<div class="image-field">
|
|
159
|
+
{#if value}<img src={value} alt="" class="preview" />{:else}<div class="preview empty">No image</div>{/if}
|
|
160
|
+
<button bind:this={trigger} type="button" class="outlined" onclick={launch}>Choose…</button>
|
|
161
|
+
{#if value && onpick}<button type="button" class="outlined" aria-label="Remove image" title="Remove image" onclick={() => onpick?.("", "")}><Icon name="x" size={14} /></button>{/if}
|
|
162
|
+
</div>
|
|
163
|
+
{/if}
|
|
164
|
+
|
|
165
|
+
{#snippet libraryBrowser()}
|
|
166
|
+
<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>
|
|
167
|
+
<div class="media-tools">
|
|
168
|
+
<div class="media-views" role="group" aria-label="Media view">
|
|
169
|
+
<button type="button" class="outlined" title="Grid view" aria-label="Grid view" aria-pressed={view === "grid"} onclick={() => setView("grid")}><Icon name="squaresFour" size={18} /></button>
|
|
170
|
+
<button type="button" class="outlined" title="List view" aria-label="List view" aria-pressed={view === "list"} onclick={() => setView("list")}><Icon name="listBullets" size={18} /></button>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="media-select">
|
|
173
|
+
<Select ariaLabel={inline ? "Media type" : "Image format"} value={format} onchange={next => { format = String(next); }} options={inline ? [{ value: "", label: "All media items" }, ...MEDIA_CATEGORIES.map(category => ({ value: category, label: category }))] : [{ value: "", label: "All images" }, ...["jpeg", "png", "webp", "gif", "avif"].map(format => ({ value: `image/${format}`, label: format === "jpeg" ? "JPEG" : format === "webp" ? "WebP" : format.toUpperCase() }))]} />
|
|
174
|
+
</div>
|
|
175
|
+
<div class="media-select">
|
|
176
|
+
<Select ariaLabel="Modified month" title="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" }) }))]} />
|
|
177
|
+
</div>
|
|
178
|
+
<button type="button" class="outlined" onclick={() => { tab = "library"; void run(); }} disabled={loading || picking}>Refresh</button>
|
|
179
|
+
{#if filter || format || month}<button type="button" class="outlined" onclick={resetFilters}>Reset filters</button>{/if}
|
|
180
|
+
<input class="media-search" aria-label="Search media" type="search" bind:value={filter} placeholder="Search media…" />
|
|
181
|
+
</div>
|
|
182
|
+
{#if view === "grid"}<div class="grid">
|
|
183
|
+
{#each filtered as asset}
|
|
184
|
+
<figure><button type="button" class="thumb" title={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}<span class="media-file-icon"><Icon name="listBullets" size={40} /><span>{mediaMetadata(asset.name)?.category} · {mediaMetadata(asset.name)?.extension.toUpperCase()}</span></span>{/if}</button><figcaption>{asset.name}</figcaption></figure>
|
|
185
|
+
{/each}
|
|
186
|
+
</div>
|
|
187
|
+
{:else}<div class="table-card media-table"><table aria-label="Media library"><thead><tr><th scope="col">File</th><th scope="col">Type</th><th scope="col">Size</th><th scope="col">Modified (UTC)</th></tr></thead><tbody>
|
|
188
|
+
{#each filtered as asset}<tr><td><button type="button" class="table-link media-file" aria-pressed={librarySelection === asset.url} onclick={() => selectAsset(asset)}>{#if asset.type?.startsWith("image/")}<img src={asset.url} alt="" loading="lazy" />{:else}<Icon name="listBullets" size={28} />{/if}<span>{asset.name}</span></button></td><td>{asset.type?.replace("image/", "").toUpperCase() || "—"}</td><td>{mediaSize(asset.size)}</td><td>{modifiedMonth(asset.modified) ? new Date(asset.modified!).toLocaleDateString(undefined, {timeZone: "UTC"}) : "—"}</td></tr>{/each}
|
|
189
|
+
</tbody></table></div>{/if}
|
|
190
|
+
{#if loading}<p role="status">Loading media…</p>{:else if !filtered.length}<p role="status">{assets.length ? "No media match these filters." : "No media in this folder yet."}</p>{/if}
|
|
191
|
+
{/snippet}
|
|
192
|
+
|
|
193
|
+
{#if open}
|
|
194
|
+
<dialog bind:this={dialog} class="rigel-modal-backdrop rigel image-dialog" aria-label={inline ? "Upload media" : "Choose image"} oncancel={e => { e.preventDefault(); if (!picking) close(); }} onclick={e => { if (e.target === dialog && !picking) close(); }}>
|
|
195
|
+
<div class="rigel-modal image-picker">
|
|
196
|
+
<div class="media-head"><h2>{selected ? "Preview media" : inline ? "Upload media" : "Choose image"}</h2><button bind:this={cancel} type="button" class="close" aria-label="Close" disabled={picking} onclick={close}><Icon name="x" size={14} weight="bold" /></button></div>
|
|
197
|
+
{#if selected}
|
|
198
|
+
<div class="media-selection" class:media-editing={inline && !!selected.asset}>
|
|
199
|
+
<div class="media-preview">
|
|
200
|
+
{#if (!selected.file && !selected.asset) || mediaMetadata(selected.file?.name ?? (selected.asset ? assetFile(selected.asset.url) : ""))?.category === "Images"}<img src={selected.url} alt="Selected image preview" />{:else}<div class="media-file-icon"><Icon name="listBullets" size={64} /><p>{mediaMetadata(selected.file?.name ?? (selected.asset ? assetFile(selected.asset.url) : ""))?.category} · {mediaSize(selected.file?.size ?? selected.asset?.size)}</p><p>{selected.asset?.type ?? mediaMetadata(selected.file?.name ?? "")?.type}</p></div>{/if}
|
|
201
|
+
</div>
|
|
202
|
+
<div class="media-details">
|
|
203
|
+
{#if inline && selected.asset}
|
|
204
|
+
<div class="media-meta">
|
|
205
|
+
<label>Display name<input aria-label="Media display name" type="text" maxlength="120" bind:value={metaName} /></label>
|
|
206
|
+
{#if selected.asset.type?.startsWith("image/")}<label>Alt text<textarea aria-label="Media alt text" rows="3" maxlength="1000" bind:value={metaAlt}></textarea></label>{/if}
|
|
207
|
+
<label>Title<input aria-label="Media title" type="text" maxlength="200" bind:value={metaTitle} /></label>
|
|
208
|
+
<label>Description<textarea aria-label="Media description" rows="3" maxlength="1000" bind:value={metaDescription}></textarea></label>
|
|
209
|
+
<p class="hint">Author: {selected.asset.author || "Local user"}</p>
|
|
210
|
+
{#if metaError}<p class="error" role="alert">{metaError}</p>{/if}
|
|
211
|
+
{#if metaNotice}<p role="status">{metaNotice}</p>{/if}
|
|
212
|
+
</div>
|
|
213
|
+
{:else if selected.file}
|
|
214
|
+
<p class="hint">{selected.file.name}</p>
|
|
215
|
+
{:else if !selected.asset}
|
|
216
|
+
<label>Image URL<input type="text" readonly value={selected.url} /></label>
|
|
217
|
+
{/if}
|
|
218
|
+
{#if !inline}<label>Alt text (describe the image; leave empty if decorative)<input type="text" bind:value={alt} /></label>{/if}
|
|
219
|
+
{#if selected.stock?.credit}<p class="hint">{selected.stock.credit}</p>{/if}
|
|
220
|
+
{#if needsImport}<p class="hint">Confirmation downloads this file into the repository media folder. Content still requires Save.</p>{/if}
|
|
221
|
+
</div>
|
|
222
|
+
<div class="media-actions" class:with-media-path={!!selected.asset}>
|
|
223
|
+
{#if selected.asset}
|
|
224
|
+
<a class="outlined media-download" href={selected.asset.url} download={assetFile(selected.asset.url)}>Download file</a>
|
|
225
|
+
<pre class="media-path" role="region" aria-label="Media file path" tabindex="0"><code>{selected.asset.url}</code></pre>
|
|
226
|
+
{/if}
|
|
227
|
+
<div class="media-button-actions">
|
|
228
|
+
{#if inline && selected.asset}
|
|
229
|
+
<button type="button" class="outlined" disabled={picking || savingMeta} onclick={close}>Cancel</button>
|
|
230
|
+
<button type="button" class="primary" disabled={picking || savingMeta} onclick={saveMetadata}>{savingMeta ? "Saving…" : "Save"}</button>
|
|
231
|
+
{:else}
|
|
232
|
+
<button type="button" class="outlined" disabled={picking} onclick={() => { selected = null; releaseFile(); }}>Back</button>
|
|
233
|
+
{#if inline && tab === "library"}<button type="button" class="primary" onclick={close}>Done</button>{:else}<button type="button" class="primary" disabled={picking || (needsImport && !writable)} onclick={confirm}>{picking ? "Importing photo…" : !inline ? "Insert image" : selected.file && mediaMetadata(selected.file.name)?.category !== "Images" ? "Upload file" : "Import image"}</button>{/if}
|
|
234
|
+
{/if}</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
{:else}
|
|
238
|
+
<div class="tabs" aria-label="Image sources">
|
|
239
|
+
{#each tabs.filter(t => !inline || (t !== "library" && t !== "unsplash")) as t}<button type="button" class:active={tab === t} aria-pressed={tab === t} disabled={picking} onclick={() => changeTab(t)}>{t}</button>{/each}
|
|
240
|
+
</div>
|
|
241
|
+
{#if inline}<p class="hint">Unsplash API photos require hotlinking and are available only when inserting images into content, not as repository uploads.</p>{/if}
|
|
242
|
+
{#if tab === "library"}
|
|
243
|
+
{@render libraryBrowser()}
|
|
244
|
+
{:else if tab === "upload"}
|
|
245
|
+
<p class="hint">Choose a local file, then preview and confirm. Nothing is uploaded until confirmation.</p>
|
|
246
|
+
{#if writable}<label>{inline ? "Upload media (images, audio, video, documents, spreadsheets, archives; 10 MB)" : "Upload image (JPEG, PNG, WebP, GIF, AVIF; 10 MB)"}<input type="file" accept={mediaAccept(!inline)} onchange={e => upload(e.currentTarget.files?.[0])} /></label>{:else}<p role="status">Local uploads require the local dev server. This library is read-only.</p>{/if}
|
|
247
|
+
{:else if tab === "url"}
|
|
248
|
+
<label>Image URL<input type="url" bind:value={urlInput} placeholder="https://… or /images/photo.jpg" /></label>
|
|
249
|
+
<button type="button" class="primary" onclick={run}>Use this URL</button>
|
|
250
|
+
{:else}
|
|
251
|
+
<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>
|
|
252
|
+
<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>
|
|
253
|
+
{#if searched && !results.length && !error}<p class="hint" role="status">No photos found. Try another search.</p>{/if}
|
|
254
|
+
<div class="grid">
|
|
255
|
+
{#each results as r (r.url)}
|
|
256
|
+
<figure><button type="button" class="thumb" disabled={picking} onclick={() => selected = {url: r.url, stock: r}} title={r.credit}><img src={r.thumb} alt={r.credit} loading="lazy" /></button><figcaption>{#if r.creditURL?.startsWith("https://")}<a href={r.creditURL} target="_blank" rel="noopener noreferrer">{r.credit}</a>{:else}{r.credit}{/if}</figcaption></figure>
|
|
257
|
+
{/each}
|
|
258
|
+
</div>
|
|
259
|
+
{/if}
|
|
260
|
+
{/if}
|
|
261
|
+
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
|
262
|
+
</div>
|
|
263
|
+
</dialog>
|
|
264
|
+
{/if}
|