@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,206 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy, tick } from "svelte";
|
|
3
|
+
import { EditorState, TextSelection } from "prosemirror-state";
|
|
4
|
+
import { EditorView } from "prosemirror-view";
|
|
5
|
+
import { exampleSetup } from "prosemirror-example-setup";
|
|
6
|
+
import { lift, setBlockType, toggleMark, wrapIn } from "prosemirror-commands";
|
|
7
|
+
import { liftListItem, wrapInList } from "prosemirror-schema-list";
|
|
8
|
+
import type { Command } from "prosemirror-state";
|
|
9
|
+
import Icon from "./Icon.svelte";
|
|
10
|
+
import ImagePicker from "./ImagePicker.svelte";
|
|
11
|
+
import Select from "./Select.svelte";
|
|
12
|
+
import { safeImageUrl, type StockResult } from "./images";
|
|
13
|
+
import type { SelectionBookmark } from "prosemirror-state";
|
|
14
|
+
import { parseVisualMarkdown, serializeMarkdown, safeMarkdownUrl, schema } from "./rich-markdown";
|
|
15
|
+
|
|
16
|
+
let { value, label, oninput }: { value: string; label: string; oninput: (value: string) => void } = $props();
|
|
17
|
+
const id = $props.id();
|
|
18
|
+
let host: HTMLDivElement;
|
|
19
|
+
let mode = $state<"visual" | "source">("visual");
|
|
20
|
+
let warning = $state("");
|
|
21
|
+
let linkUrl = $state("");
|
|
22
|
+
let linkError = $state("");
|
|
23
|
+
let linkOpen = $state(false);
|
|
24
|
+
let linkInput: HTMLInputElement;
|
|
25
|
+
let linkButton: HTMLButtonElement;
|
|
26
|
+
let state = $state.raw<EditorState>();
|
|
27
|
+
let view: EditorView | undefined;
|
|
28
|
+
let lastValue: string | undefined;
|
|
29
|
+
let imageSelection: SelectionBookmark | undefined;
|
|
30
|
+
function insertImage(url: string, alt: string, credit?: StockResult) {
|
|
31
|
+
if (!view || !imageSelection || !safeImageUrl(url)) return;
|
|
32
|
+
let tr = view.state.tr.setSelection(imageSelection.resolve(view.state.doc));
|
|
33
|
+
tr = tr.replaceSelectionWith(schema.nodes.image.create({ src: url, alt }));
|
|
34
|
+
if (credit?.creditURL && safeImageUrl(credit.creditURL)) {
|
|
35
|
+
tr = tr.insert(tr.selection.to, schema.text(` (${credit.credit})`, [schema.marks.link.create({ href: credit.creditURL })]));
|
|
36
|
+
}
|
|
37
|
+
view.dispatch(tr.scrollIntoView());
|
|
38
|
+
imageSelection = undefined;
|
|
39
|
+
view.focus();
|
|
40
|
+
}
|
|
41
|
+
const marks = [
|
|
42
|
+
{ name: "Bold", icon: "textB", type: "strong" },
|
|
43
|
+
{ name: "Italic", icon: "textItalic", type: "em" },
|
|
44
|
+
{ name: "Code", icon: "code", type: "code" },
|
|
45
|
+
] as const;
|
|
46
|
+
const blocks = [
|
|
47
|
+
{ name: "Bullet list", icon: "listBullets", type: "bullet_list" },
|
|
48
|
+
{ name: "Numbered list", icon: "listNumbers", type: "ordered_list" },
|
|
49
|
+
{ name: "Quote", icon: "quotes", type: "blockquote" },
|
|
50
|
+
] as const;
|
|
51
|
+
const format = $derived(state?.selection.$from.parent.type.name === "heading" ? `h${state.selection.$from.parent.attrs.level}` : state?.selection.$from.parent.type.name ?? "paragraph");
|
|
52
|
+
|
|
53
|
+
function emit(next: string) {
|
|
54
|
+
lastValue = next;
|
|
55
|
+
oninput(next);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function load(source: string) {
|
|
59
|
+
const doc = parseVisualMarkdown(source);
|
|
60
|
+
warning = doc ? "" : "This Markdown contains formatting the visual editor cannot safely preserve. Edit the original source below; nothing has been converted.";
|
|
61
|
+
if (!doc) { mode = "source"; return false; }
|
|
62
|
+
state = EditorState.create({ doc, plugins: exampleSetup({ schema, menuBar: false }) });
|
|
63
|
+
view?.updateState(state);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
$effect(() => {
|
|
68
|
+
if (!host) return;
|
|
69
|
+
if (!view) {
|
|
70
|
+
view = new EditorView(host, {
|
|
71
|
+
state: EditorState.create({ schema }),
|
|
72
|
+
attributes: { role: "textbox", "aria-label": label, "aria-multiline": "true" },
|
|
73
|
+
dispatchTransaction(transaction) {
|
|
74
|
+
state = view!.state.apply(transaction);
|
|
75
|
+
view!.updateState(state);
|
|
76
|
+
if (transaction.docChanged) emit(serializeMarkdown(state.doc));
|
|
77
|
+
},
|
|
78
|
+
handlePaste(editor, event) {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
const text = event.clipboardData?.getData("text/plain") ?? "";
|
|
81
|
+
if (text) editor.dispatch(editor.state.tr.insertText(text));
|
|
82
|
+
return true;
|
|
83
|
+
},
|
|
84
|
+
handleDrop(_editor, _event, _slice, moved) { return !moved; },
|
|
85
|
+
handleClickOn(_editor, _pos, node) { return node.type.name === "image"; },
|
|
86
|
+
handleDOMEvents: { click: (_editor, event) => { if ((event.target as Element).closest("a")) { event.preventDefault(); return true; } return false; } },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (value !== lastValue) {
|
|
90
|
+
lastValue = value;
|
|
91
|
+
load(value);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
onDestroy(() => view?.destroy());
|
|
95
|
+
|
|
96
|
+
function activeMark(type: string) {
|
|
97
|
+
if (!state) return false;
|
|
98
|
+
const { from, to, empty, $from: anchor } = state.selection;
|
|
99
|
+
return empty ? !!schema.marks[type].isInSet(state.storedMarks ?? anchor.marks()) : state.doc.rangeHasMark(from, to, schema.marks[type]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function activeBlock(type: string) {
|
|
103
|
+
if (!state) return false;
|
|
104
|
+
const { $from: anchor } = state.selection;
|
|
105
|
+
for (let depth = anchor.depth; depth > 0; depth--) if (anchor.node(depth).type.name === type) return true;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function blockCommand(type: string): Command {
|
|
110
|
+
if (type === "blockquote") return activeBlock(type) ? lift : wrapIn(schema.nodes[type]);
|
|
111
|
+
return activeBlock(type) ? liftListItem(schema.nodes.list_item) : wrapInList(schema.nodes[type]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatCommand(type: string) {
|
|
115
|
+
return type.startsWith("h") ? setBlockType(schema.nodes.heading, { level: Number(type.slice(1)) }) : setBlockType(schema.nodes[type]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function command(action: Command) {
|
|
119
|
+
if (!view) return;
|
|
120
|
+
action(view.state, view.dispatch, view);
|
|
121
|
+
view.focus();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function visual() {
|
|
125
|
+
if (mode === "visual") return;
|
|
126
|
+
if (load(value)) { mode = "visual"; view?.focus(); }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function openLink() {
|
|
130
|
+
const mark = state && schema.marks.link.isInSet(state.selection.$from.marks());
|
|
131
|
+
if (view && mark && view.state.selection.empty) {
|
|
132
|
+
const anchor = view.state.selection.$from;
|
|
133
|
+
let from = anchor.pos;
|
|
134
|
+
let to = anchor.pos;
|
|
135
|
+
view.state.doc.nodesBetween(anchor.start(), anchor.end(), (node, pos) => {
|
|
136
|
+
if (node.isText && mark.isInSet(node.marks) && pos <= to && pos + node.nodeSize >= from) {
|
|
137
|
+
from = Math.min(from, pos);
|
|
138
|
+
to = Math.max(to, pos + node.nodeSize);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, from, to)));
|
|
142
|
+
}
|
|
143
|
+
linkUrl = mark?.attrs.href ?? "";
|
|
144
|
+
linkError = "";
|
|
145
|
+
linkOpen = !linkOpen;
|
|
146
|
+
if (linkOpen) { await tick(); linkInput?.focus(); }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function link(remove = false) {
|
|
150
|
+
if (!view) return;
|
|
151
|
+
if (!remove && (!safeMarkdownUrl(linkUrl) || !linkUrl)) { linkError = "Enter a safe http, https, mailto or relative URL."; return; }
|
|
152
|
+
const { from, to, empty } = view.state.selection;
|
|
153
|
+
let tr = view.state.tr;
|
|
154
|
+
if (empty) tr = remove ? tr.removeStoredMark(schema.marks.link) : tr.addStoredMark(schema.marks.link.create({ href: linkUrl }));
|
|
155
|
+
else tr = remove ? tr.removeMark(from, to, schema.marks.link) : tr.addMark(from, to, schema.marks.link.create({ href: linkUrl }));
|
|
156
|
+
view.dispatch(tr);
|
|
157
|
+
linkOpen = false;
|
|
158
|
+
view.focus();
|
|
159
|
+
}
|
|
160
|
+
</script>
|
|
161
|
+
|
|
162
|
+
<svelte:window onpointerdown={event => { if (linkOpen && !document.getElementById(`${id}-link`)?.contains(event.target as Node) && !linkButton?.contains(event.target as Node)) linkOpen = false; }} />
|
|
163
|
+
|
|
164
|
+
<div class="markdown-editor body-editor">
|
|
165
|
+
<div class="markdown-heading">
|
|
166
|
+
<div class="field-label" id={`${id}-label`}>{label}</div>
|
|
167
|
+
<div class="markdown-modes" role="group" aria-label={`${label} editing mode`}>
|
|
168
|
+
<button type="button" aria-pressed={mode === "visual"} onclick={visual}>Visual</button>
|
|
169
|
+
<button type="button" aria-pressed={mode === "source"} onclick={() => { mode = "source"; linkOpen = false; }}>Markdown</button>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
{#if warning}<p class="markdown-warning" role="status">{warning}</p>{/if}
|
|
173
|
+
<div class="markdown-visual" hidden={mode !== "visual"}>
|
|
174
|
+
<div class="body-toolbar formatting-toolbar" role="group" aria-label={`${label} formatting`}>
|
|
175
|
+
<Select ariaLabel="Text style" title="Text style" value={format} options={[{ value: "paragraph", label: "Paragraph" }, ...[1, 2, 3, 4, 5, 6].map(level => ({ value: `h${level}`, label: `Heading ${level}` })), { value: "code_block", label: "Code block" }].map(option => ({ ...option, disabled: !!state && format !== option.value && !formatCommand(option.value)(state) }))} onchange={next => command(formatCommand(String(next)))} />
|
|
176
|
+
<div class="format-group" role="group" aria-label="Text formatting">
|
|
177
|
+
{#each marks as item}
|
|
178
|
+
<button type="button" aria-label={item.name} title={item.name} aria-pressed={activeMark(item.type)} disabled={!state || !toggleMark(schema.marks[item.type])(state)} onmousedown={event => event.preventDefault()} onclick={() => command(toggleMark(schema.marks[item.type]))}><Icon name={item.icon} size={18} /></button>
|
|
179
|
+
{/each}
|
|
180
|
+
</div>
|
|
181
|
+
<div class="format-group" role="group" aria-label="Lists and quote">
|
|
182
|
+
{#each blocks as item}
|
|
183
|
+
<button type="button" aria-label={item.name} title={item.name} aria-pressed={activeBlock(item.type)} disabled={!state || !blockCommand(item.type)(state)} onmousedown={event => event.preventDefault()} onclick={() => command(blockCommand(item.type))}><Icon name={item.icon} size={18} /></button>
|
|
184
|
+
{/each}
|
|
185
|
+
</div>
|
|
186
|
+
<button bind:this={linkButton} type="button" aria-label="Link" title="Link" aria-pressed={activeMark("link")} aria-expanded={linkOpen} aria-controls={`${id}-link`} disabled={!state || !toggleMark(schema.marks.link)(state)} onmousedown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
|
|
187
|
+
<ImagePicker toolbar onopen={() => { imageSelection = view?.state.selection.getBookmark(); linkOpen = false; }} onpick={insertImage} onclose={() => { imageSelection = undefined; }} />
|
|
188
|
+
</div>
|
|
189
|
+
{#if linkOpen}
|
|
190
|
+
<div class="markdown-link" id={`${id}-link`} role="group" aria-label="Edit link" onkeydown={event => { if (event.key === "Escape") { event.stopPropagation(); linkOpen = false; linkButton.focus(); } if (event.key === "Enter") { event.preventDefault(); link(); } }}>
|
|
191
|
+
<label for={`${id}-url`}>Link URL</label>
|
|
192
|
+
<div class="markdown-link-controls">
|
|
193
|
+
<input id={`${id}-url`} type="text" placeholder="https://…" bind:this={linkInput} bind:value={linkUrl} aria-invalid={!!linkError} aria-describedby={linkError ? `${id}-error` : undefined} />
|
|
194
|
+
<button type="button" aria-label="Apply link" title="Apply link" onclick={() => link()}><Icon name="check" size={16} /></button>
|
|
195
|
+
<button type="button" aria-label="Remove link" title="Remove link" disabled={!activeMark("link")} onclick={() => link(true)}><Icon name="linkBreak" size={16} /></button>
|
|
196
|
+
<button type="button" aria-label="Close link" title="Close link" onclick={() => { linkOpen = false; linkButton.focus(); }}><Icon name="x" size={16} /></button>
|
|
197
|
+
</div>
|
|
198
|
+
{#if linkError}<p id={`${id}-error`} role="alert">{linkError}</p>{/if}
|
|
199
|
+
</div>
|
|
200
|
+
{/if}
|
|
201
|
+
<div class="md-preview rich-text" bind:this={host}></div>
|
|
202
|
+
</div>
|
|
203
|
+
{#if mode === "source"}
|
|
204
|
+
<textarea class="mono" rows="14" aria-label={`${label} Markdown source`} value={value} oninput={(event) => { warning = ""; emit(event.currentTarget.value); }}></textarea>
|
|
205
|
+
{/if}
|
|
206
|
+
</div>
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from "svelte";
|
|
3
|
+
import type { MenuItem } from "../../manifest/menu";
|
|
4
|
+
import { editMenuItem, menuRows, moveMenuItem } from "./menu-tree";
|
|
5
|
+
import ActionDialog from "./ActionDialog.svelte";
|
|
6
|
+
import Icon from "./Icon.svelte";
|
|
7
|
+
|
|
8
|
+
let { items, maxDepth, oninput }: { items: MenuItem[]; maxDepth: number; oninput: (items: MenuItem[]) => void } = $props();
|
|
9
|
+
let dragging = $state<string | null>(null);
|
|
10
|
+
let target = $state("");
|
|
11
|
+
let message = $state("");
|
|
12
|
+
let removing = $state<MenuItem | null>(null);
|
|
13
|
+
const rows = $derived(menuRows(items));
|
|
14
|
+
let selectedId = $state<string | null>(null);
|
|
15
|
+
let host: HTMLElement;
|
|
16
|
+
const chain = $derived.by(() => {
|
|
17
|
+
const selected: ReturnType<typeof menuRows> = [];
|
|
18
|
+
let row = rows.find(row => row.item.id === selectedId);
|
|
19
|
+
while (row) {
|
|
20
|
+
selected.unshift(row);
|
|
21
|
+
row = rows.find(parent => parent.item.id === row?.parentId);
|
|
22
|
+
}
|
|
23
|
+
return selected;
|
|
24
|
+
});
|
|
25
|
+
const panes = $derived([null, ...chain]);
|
|
26
|
+
let pointer: { id: string; pointerId: number; x: number; y: number; handle: HTMLButtonElement } | null = null;
|
|
27
|
+
|
|
28
|
+
function cancelDrag() {
|
|
29
|
+
const active = pointer;
|
|
30
|
+
pointer = null;
|
|
31
|
+
if (active?.handle.hasPointerCapture(active.pointerId)) active.handle.releasePointerCapture(active.pointerId);
|
|
32
|
+
dragging = null;
|
|
33
|
+
target = "";
|
|
34
|
+
}
|
|
35
|
+
function startDrag(e: PointerEvent, id: string) {
|
|
36
|
+
if (e.button !== 0 || !e.isPrimary) return;
|
|
37
|
+
const handle = e.currentTarget as HTMLButtonElement;
|
|
38
|
+
pointer = { id, pointerId: e.pointerId, x: e.clientX, y: e.clientY, handle };
|
|
39
|
+
handle.setPointerCapture(e.pointerId);
|
|
40
|
+
}
|
|
41
|
+
function dropAt(x: number, y: number) {
|
|
42
|
+
const list = document.elementFromPoint(x, y)?.closest<HTMLElement>('.menu-list');
|
|
43
|
+
const source = pointer?.handle.closest('.menu-item');
|
|
44
|
+
if (!list || source?.parentElement !== list) return null;
|
|
45
|
+
const siblings = [...list.querySelectorAll<HTMLElement>(':scope > .menu-item')];
|
|
46
|
+
const next = siblings.find(row => y < row.getBoundingClientRect().top + row.getBoundingClientRect().height / 2);
|
|
47
|
+
const row = next ?? siblings.at(-1);
|
|
48
|
+
if (!row || row === source) return null;
|
|
49
|
+
return { id: row.dataset.menuId!, position: next ? 'before' as const : 'after' as const };
|
|
50
|
+
}
|
|
51
|
+
function dragPointer(e: PointerEvent) {
|
|
52
|
+
if (!pointer || e.pointerId !== pointer.pointerId) return;
|
|
53
|
+
if (!dragging && Math.hypot(e.clientX - pointer.x, e.clientY - pointer.y) < 5) return;
|
|
54
|
+
dragging = pointer.id;
|
|
55
|
+
const drop = dropAt(e.clientX, e.clientY);
|
|
56
|
+
target = drop ? drop.id + drop.position : '';
|
|
57
|
+
if (e.clientY < 48) window.scrollBy(0, -16);
|
|
58
|
+
else if (e.clientY > window.innerHeight - 48) window.scrollBy(0, 16);
|
|
59
|
+
}
|
|
60
|
+
function finishDrag(e: PointerEvent) {
|
|
61
|
+
if (!pointer || e.pointerId !== pointer.pointerId) return;
|
|
62
|
+
const id = dragging;
|
|
63
|
+
const drop = dropAt(e.clientX, e.clientY);
|
|
64
|
+
cancelDrag();
|
|
65
|
+
if (id && drop) move(id, drop.id, drop.position);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function move(id: string, targetId: string | null, position: "before" | "after" | "inside") {
|
|
69
|
+
try {
|
|
70
|
+
oninput(moveMenuItem(items, id, targetId, position, maxDepth));
|
|
71
|
+
message = "Menu item moved";
|
|
72
|
+
} catch (error) { message = error instanceof Error ? error.message : "Cannot move item"; }
|
|
73
|
+
dragging = null;
|
|
74
|
+
target = "";
|
|
75
|
+
}
|
|
76
|
+
async function open(id: string | null) {
|
|
77
|
+
selectedId = id;
|
|
78
|
+
await tick();
|
|
79
|
+
const pane = host.querySelector<HTMLElement>('.menu-pane:last-child');
|
|
80
|
+
pane?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
81
|
+
pane?.querySelector<HTMLElement>('input, .menu-add')?.focus({ preventScroll: true });
|
|
82
|
+
}
|
|
83
|
+
function add(parentId: string | null = null) {
|
|
84
|
+
const parent = rows.find((row) => row.item.id === parentId);
|
|
85
|
+
if (parent && parent.depth >= maxDepth) { message = `Maximum ${maxDepth} levels`; return; }
|
|
86
|
+
const item: MenuItem = { id: crypto.randomUUID(), label: "New item", url: "/", children: [] };
|
|
87
|
+
oninput(parentId ? editMenuItem(items, parentId, (p) => ({ ...p, children: [...p.children, item] })) : [...items, item]);
|
|
88
|
+
message = "Menu item added";
|
|
89
|
+
void open(item.id);
|
|
90
|
+
}
|
|
91
|
+
function remove(item: MenuItem) {
|
|
92
|
+
removing = item;
|
|
93
|
+
}
|
|
94
|
+
function change(id: string, field: "label" | "url", value: string) {
|
|
95
|
+
oninput(editMenuItem(items, id, (item) => ({ ...item, [field]: value })));
|
|
96
|
+
}
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<svelte:window onpointermove={dragPointer} onpointerup={finishDrag} onpointercancel={cancelDrag} onkeydown={(event) => { if (event.key === 'Escape' && pointer) { cancelDrag(); message = 'Move cancelled'; } }} />
|
|
100
|
+
<section class="menu-editor" aria-label="Menu items" bind:this={host}>
|
|
101
|
+
<nav class="menu-path" aria-label="Menu path">
|
|
102
|
+
<button type="button" data-menu-path="" onclick={() => open(null)}>Menu items</button>
|
|
103
|
+
{#each chain as row}
|
|
104
|
+
<Icon name="caretRight" size={12} />
|
|
105
|
+
<button type="button" data-menu-path={row.item.id} aria-current={row.item.id === selectedId ? 'step' : undefined} onclick={() => open(row.item.id)}>{row.item.label || "Untitled item"}</button>
|
|
106
|
+
{/each}
|
|
107
|
+
</nav>
|
|
108
|
+
<p class="menu-message" role="status" aria-live="polite">{message}</p>
|
|
109
|
+
<div class="menu-panes">
|
|
110
|
+
{#each panes as parent, index (parent?.item.id ?? '')}
|
|
111
|
+
<section class="menu-pane" class:active={index === panes.length - 1} data-menu-parent={parent?.item.id ?? ''} aria-label={parent ? `${parent.item.label || 'Untitled item'} details` : 'Menu items'}>
|
|
112
|
+
<div class="menu-pane-head">
|
|
113
|
+
<h3>{parent?.item.label || (parent ? "Untitled item" : "Menu items")}</h3>
|
|
114
|
+
{#if parent}
|
|
115
|
+
<details class="menu-actions">
|
|
116
|
+
<summary aria-label={`Actions for ${parent.item.label}`}><Icon name="dotsThreeOutlineVertical" /></summary>
|
|
117
|
+
<div>
|
|
118
|
+
<button type="button" disabled={!parent.previous} onclick={() => parent.previous && move(parent.item.id, parent.previous, 'before')}>Up</button>
|
|
119
|
+
<button type="button" disabled={!parent.next} onclick={() => parent.next && move(parent.item.id, parent.next, 'after')}>Down</button>
|
|
120
|
+
<button type="button" disabled={!parent.previous || parent.depth + Math.max(...menuRows([parent.item]).map(row => row.depth)) > maxDepth} onclick={() => parent.previous && move(parent.item.id, parent.previous, 'inside')}>Indent</button>
|
|
121
|
+
<button type="button" disabled={!parent.parentId} onclick={() => parent.parentId && move(parent.item.id, parent.parentId, 'after')}>Outdent</button>
|
|
122
|
+
<button type="button" class="menu-remove" onclick={() => remove(parent.item)}>Remove</button>
|
|
123
|
+
</div>
|
|
124
|
+
</details>
|
|
125
|
+
{/if}
|
|
126
|
+
</div>
|
|
127
|
+
{#if parent}
|
|
128
|
+
<div class="menu-fields">
|
|
129
|
+
<label><span>Label</span><input type="text" aria-label={`Label for ${parent.item.id}`} value={parent.item.label} oninput={event => change(parent.item.id, 'label', event.currentTarget.value)} /></label>
|
|
130
|
+
<label><span>URL</span><input type="text" aria-label={`URL for ${parent.item.id}`} value={parent.item.url} placeholder="/about or https://example.com" oninput={event => change(parent.item.id, 'url', event.currentTarget.value)} /></label>
|
|
131
|
+
<label class="inline"><input type="checkbox" role="switch" class="switch" checked={parent.item.newTab ?? false} onchange={event => oninput(editMenuItem(items, parent.item.id, item => ({ ...item, newTab: event.currentTarget.checked })))} /><span>Open in new tab</span></label>
|
|
132
|
+
</div>
|
|
133
|
+
<h4>Items</h4>
|
|
134
|
+
{/if}
|
|
135
|
+
<div class="menu-list">
|
|
136
|
+
{#each (parent ? parent.item.children : items) as item (item.id)}
|
|
137
|
+
<div class="menu-item" class:is-dragging={dragging === item.id} class:drop-before={target === item.id + 'before'} class:drop-after={target === item.id + 'after'} class:selected={chain[index]?.item.id === item.id} data-menu-id={item.id} data-parent-id={parent?.item.id ?? ''} data-depth={index + 1}>
|
|
138
|
+
<button type="button" class="menu-drag" aria-label={`Drag ${item.label}`} onpointerdown={event => startDrag(event, item.id)} onlostpointercapture={cancelDrag}><Icon name="listBullets" /></button>
|
|
139
|
+
<button type="button" class="menu-open" aria-label={`Open ${item.label || 'Untitled item'}`} aria-expanded={chain[index]?.item.id === item.id} onclick={() => open(item.id)}>
|
|
140
|
+
<span><span class="menu-label">{item.label || "Untitled item"}</span><span class="menu-url">{item.url}</span></span>
|
|
141
|
+
{#if item.children.length}<span class="menu-count">{item.children.length}</span>{/if}
|
|
142
|
+
<Icon name="caretRight" size={12} />
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
{:else}
|
|
146
|
+
<p class="menu-empty">{parent ? "No items in this group." : "No menu items yet."}</p>
|
|
147
|
+
{/each}
|
|
148
|
+
</div>
|
|
149
|
+
<button type="button" class="menu-add" disabled={!!parent && parent.depth >= maxDepth} onclick={() => add(parent?.item.id ?? null)}><Icon name="plus" />{parent ? "Add child" : "Add item"}</button>
|
|
150
|
+
{#if parent && parent.depth >= maxDepth}<p class="hint">Maximum {maxDepth} levels.</p>{/if}
|
|
151
|
+
</section>
|
|
152
|
+
{/each}
|
|
153
|
+
</div>
|
|
154
|
+
</section>
|
|
155
|
+
{#if removing}
|
|
156
|
+
<ActionDialog title={`Remove “${removing.label}”?`} description="This removes the item and all its children from the draft. Press Save to persist the change." confirmLabel="Remove" danger onconfirm={() => {
|
|
157
|
+
if (removing) {
|
|
158
|
+
const parentId = rows.find(row => row.item.id === removing?.id)?.parentId ?? null;
|
|
159
|
+
oninput(editMenuItem(items, removing.id, () => null));
|
|
160
|
+
void open(parentId);
|
|
161
|
+
}
|
|
162
|
+
message = "Menu item removed";
|
|
163
|
+
}} onclose={() => { removing = null; }} />
|
|
164
|
+
{/if}
|
|
165
|
+
|
|
166
|
+
<style>
|
|
167
|
+
.menu-editor { padding: 0; border: 0; background: transparent; box-shadow: none; min-width: 0; }
|
|
168
|
+
.menu-path { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 1rem; }
|
|
169
|
+
.menu-message, .menu-empty, .menu-url, .menu-count { color: var(--text-2); font-size: 0.8125rem; }
|
|
170
|
+
.menu-message:empty { display: none; }
|
|
171
|
+
.menu-panes { display: flex; overflow-x: auto; align-items: stretch; border: 1px solid var(--border-control); }
|
|
172
|
+
.menu-pane { flex: 1 0 280px; min-width: 0; margin: 0; padding: 1rem; border: 0; border-right: 1px solid var(--border-subtle); box-shadow: none; }
|
|
173
|
+
.menu-pane:last-child { border-right: 0; }
|
|
174
|
+
.menu-pane-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 1rem; }
|
|
175
|
+
.menu-pane-head h3 { margin: 0; overflow-wrap: anywhere; }
|
|
176
|
+
.menu-fields { display: grid; gap: 1rem; margin-bottom: 1.5rem; }
|
|
177
|
+
.menu-fields label { min-width: 0; margin: 0; }
|
|
178
|
+
.menu-pane h4 { margin: 0 0 0.75rem; font-size: 0.8125rem; color: var(--text-2); }
|
|
179
|
+
.menu-list { display: grid; gap: 0.5rem; }
|
|
180
|
+
.menu-item { display: flex; position: relative; min-width: 0; border: 1px solid var(--border-control); }
|
|
181
|
+
.menu-item.selected { border-color: var(--accent); background: var(--accent-soft); }
|
|
182
|
+
.menu-item.is-dragging { border-style: dashed; }
|
|
183
|
+
.menu-editor button, .menu-actions summary { padding: 0.5rem; min-height: 36px; border: 0; border-radius: 0; background: transparent; color: var(--text); font: inherit; font-size: 0.8125rem; cursor: pointer; }
|
|
184
|
+
.menu-editor button:hover:not(:disabled), .menu-actions summary:hover { background: var(--surface-raised); }
|
|
185
|
+
.menu-editor button:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
186
|
+
.menu-editor .menu-open { flex: 1; display: flex; align-items: center; gap: 0.5rem; text-align: left; min-width: 0; min-height: 56px; padding-inline: 0.25rem 0.75rem; }
|
|
187
|
+
.menu-open > span:first-child { flex: 1; min-width: 0; }
|
|
188
|
+
.menu-label, .menu-url { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
189
|
+
.menu-label { margin-bottom: 0.25rem; }
|
|
190
|
+
.menu-drag { cursor: grab !important; touch-action: none; user-select: none; flex-shrink: 0; }
|
|
191
|
+
.menu-drag:active { cursor: grabbing !important; }
|
|
192
|
+
.menu-editor .menu-add { display: flex; align-items: center; justify-content: center; gap: 0.5rem; width: 100%; margin-top: 0.75rem; border: 1px solid var(--border-control); }
|
|
193
|
+
.menu-actions { position: relative; flex-shrink: 0; }
|
|
194
|
+
.menu-actions summary { display: flex; align-items: center; list-style: none; }
|
|
195
|
+
.menu-actions summary::-webkit-details-marker { display: none; }
|
|
196
|
+
.menu-actions > div { position: absolute; right: 0; top: 100%; z-index: 3; display: grid; min-width: 140px; padding: 0.25rem; border: 1px solid var(--border-control); background: var(--panel); box-shadow: var(--shadow-sm); }
|
|
197
|
+
.menu-actions button { text-align: left; }
|
|
198
|
+
.menu-actions .menu-remove { color: var(--danger); }
|
|
199
|
+
.menu-item.drop-before::before, .menu-item.drop-after::after { content: ""; position: absolute; left: -1px; right: -1px; height: 3px; background: var(--accent); pointer-events: none; }
|
|
200
|
+
.menu-item.drop-before::before { top: -6px; }
|
|
201
|
+
.menu-item.drop-after::after { bottom: -6px; }
|
|
202
|
+
@media (max-width: 700px) {
|
|
203
|
+
.menu-panes { display: block; overflow: visible; }
|
|
204
|
+
.menu-pane { display: none; border: 0; }
|
|
205
|
+
.menu-pane.active { display: block; }
|
|
206
|
+
.menu-path { gap: 0; }
|
|
207
|
+
.menu-editor button, .menu-actions summary { min-height: 44px; }
|
|
208
|
+
}
|
|
209
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { tab = $bindable("content") }: { tab?: "content" | "seo" } = $props();
|
|
3
|
+
function onkey(event: KeyboardEvent) {
|
|
4
|
+
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
5
|
+
event.preventDefault();
|
|
6
|
+
tab = event.key === "Home" ? "content" : event.key === "End" ? "seo" : tab === "content" ? "seo" : "content";
|
|
7
|
+
(event.currentTarget as HTMLElement).parentElement?.querySelector<HTMLButtonElement>(`[data-tab="${tab}"]`)?.focus();
|
|
8
|
+
}
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<div class="panel-tabs" role="tablist" aria-label="Properties">
|
|
12
|
+
{#each ["content", "seo"] as value}
|
|
13
|
+
<button type="button" role="tab" id={`properties-tab-${value}`} aria-controls={`properties-${value}`} aria-selected={tab === value} tabindex={tab === value ? 0 : -1} data-tab={value} onclick={() => (tab = value as "content" | "seo")} onkeydown={onkey}>{value === "content" ? "Content" : "SEO"}</button>
|
|
14
|
+
{/each}
|
|
15
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ReferenceOption } from "../../manifest/references";
|
|
3
|
+
import Select from "./Select.svelte";
|
|
4
|
+
|
|
5
|
+
let { label, value, options, multiple = false, onchange }: {
|
|
6
|
+
label: string;
|
|
7
|
+
value: unknown;
|
|
8
|
+
options: ReferenceOption[];
|
|
9
|
+
multiple?: boolean;
|
|
10
|
+
onchange: (value: string | string[]) => void;
|
|
11
|
+
} = $props();
|
|
12
|
+
const selected = $derived((Array.isArray(value) ? value : [value]).filter((id): id is string => typeof id === "string" && !!id));
|
|
13
|
+
const missing = $derived(selected.filter(id => !options.some(option => option.value === id)));
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<Select {label} {multiple} value={multiple ? selected : selected[0] ?? ""} options={[...(!multiple ? [{ value: "", label: `Select ${label.toLowerCase()}…` }] : []), ...options, ...missing.map(id => ({ value: id, label: `Unavailable (${id})` }))]} {onchange} />
|
|
17
|
+
{#if !options.length}<p class="hint">No entries have been declared yet.</p>{/if}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import ImagePicker from "./ImagePicker.svelte";
|
|
3
|
+
import MarkdownEditor from "./MarkdownEditor.svelte";
|
|
4
|
+
import SchemaField from "./SchemaField.svelte";
|
|
5
|
+
import Select from "./Select.svelte";
|
|
6
|
+
import Icon from "./Icon.svelte";
|
|
7
|
+
import DatePicker from "./DatePicker.svelte";
|
|
8
|
+
import {
|
|
9
|
+
blankItem,
|
|
10
|
+
duplicateItem,
|
|
11
|
+
itemFields,
|
|
12
|
+
moveItem,
|
|
13
|
+
objectFields,
|
|
14
|
+
referenceOptions,
|
|
15
|
+
} from "./schema-edit";
|
|
16
|
+
import { newKey } from "../../schema/paths";
|
|
17
|
+
import type { ContentDocument, FieldDef, Schema } from "../../schema/types";
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
schema, field, value, base, documents, devMode = false, onvalue,
|
|
21
|
+
}: {
|
|
22
|
+
schema: Schema;
|
|
23
|
+
field: FieldDef;
|
|
24
|
+
value: unknown;
|
|
25
|
+
base: string;
|
|
26
|
+
documents: ContentDocument[];
|
|
27
|
+
devMode?: boolean;
|
|
28
|
+
onvalue: (next: unknown) => void;
|
|
29
|
+
} = $props();
|
|
30
|
+
|
|
31
|
+
const id = $props.id();
|
|
32
|
+
let revealed = $state(false);
|
|
33
|
+
const color = $derived(field.type === "color" && typeof value === "string" ? normalizeColor(value) : "");
|
|
34
|
+
const itemList = $derived(field.type === "array" && Array.isArray(value) ? (value as unknown[]) : []);
|
|
35
|
+
const itemSchema = $derived(field.type === "array" ? itemFields(schema, field) : []);
|
|
36
|
+
const objectSchema = $derived(field.type === "object" ? objectFields(schema, field) : []);
|
|
37
|
+
const options = $derived(field.type === "reference" ? referenceOptions(documents, field.to ?? []) : []);
|
|
38
|
+
const label = $derived(`${field.label}${field.required ? " *" : ""}${devMode ? ` (${field.key})` : ""}`);
|
|
39
|
+
|
|
40
|
+
function normalizeColor(input: string): string {
|
|
41
|
+
const hex = input.trim().replace(/^#/, "");
|
|
42
|
+
if (!/^(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) return input;
|
|
43
|
+
return `#${(hex.length === 3 ? [...hex].map(character => character.repeat(2)).join("") : hex).toLowerCase()}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function setItem(index: number, item: unknown) {
|
|
47
|
+
const next = [...itemList];
|
|
48
|
+
next[index] = item;
|
|
49
|
+
onvalue(next);
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<div data-schema-path={base}>
|
|
54
|
+
{#if field.type === "string" || field.type === "url"}
|
|
55
|
+
<label>
|
|
56
|
+
<span>{label}</span>
|
|
57
|
+
<input type={(field.type as string) === "url" ? "url" : "text"} value={typeof value === "string" ? value : ""} placeholder={field.placeholder ?? ""} oninput={(event) => onvalue(event.currentTarget.value)} />
|
|
58
|
+
</label>
|
|
59
|
+
{:else if field.type === "color"}
|
|
60
|
+
<div class="schema-field">
|
|
61
|
+
<label for={id}><span>{label}</span></label>
|
|
62
|
+
<div class="schema-color">
|
|
63
|
+
<input type="color" aria-label={`${field.label} picker`} value={/^#[0-9a-f]{6}$/.test(color) ? color : "#000000"} oninput={(event) => onvalue(event.currentTarget.value)} />
|
|
64
|
+
<input id={id} type="text" aria-label={`${field.label} hex value`} value={typeof value === "string" ? value : ""} placeholder="#000000" spellcheck={false} oninput={(event) => onvalue(event.currentTarget.value)} onblur={(event) => onvalue(normalizeColor(event.currentTarget.value))} />
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
{:else if field.type === "code"}
|
|
68
|
+
<label>
|
|
69
|
+
<span>{label}</span>
|
|
70
|
+
<textarea class="schema-code" rows="8" spellcheck={false} value={typeof value === "string" ? value : ""} oninput={(event) => onvalue(event.currentTarget.value)}></textarea>
|
|
71
|
+
</label>
|
|
72
|
+
{:else if field.type === "password"}
|
|
73
|
+
<div class="schema-field">
|
|
74
|
+
<label for={id}><span>{label}</span></label>
|
|
75
|
+
<div class="secret-input">
|
|
76
|
+
<input id={id} type={revealed ? "text" : "password"} value={typeof value === "string" ? value : ""} placeholder={field.placeholder ?? ""} autocomplete="off" spellcheck={false} oninput={(event) => onvalue(event.currentTarget.value)} />
|
|
77
|
+
<button type="button" aria-label={`${revealed ? "Hide" : "Show"} ${field.label}`} aria-controls={id} aria-pressed={revealed} onclick={() => revealed = !revealed}><Icon name={revealed ? "eyeSlash" : "eye"} size={18} /></button>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
{:else if field.type === "text"}
|
|
81
|
+
<label>
|
|
82
|
+
<span>{label}</span>
|
|
83
|
+
<textarea rows="3" value={typeof value === "string" ? value : ""} oninput={(event) => onvalue(event.currentTarget.value)}></textarea>
|
|
84
|
+
</label>
|
|
85
|
+
{:else if field.type === "markdown"}
|
|
86
|
+
<MarkdownEditor label={field.label} value={typeof value === "string" ? value : ""} oninput={(next) => onvalue(next)} />
|
|
87
|
+
{:else if field.type === "image"}
|
|
88
|
+
<div class="schema-field">
|
|
89
|
+
<span class="schema-label">{label}</span>
|
|
90
|
+
<ImagePicker value={typeof value === "string" ? value : ""} onpick={(url) => onvalue(url)} />
|
|
91
|
+
</div>
|
|
92
|
+
{:else if field.type === "boolean"}
|
|
93
|
+
<label class="inline">
|
|
94
|
+
<input type="checkbox" role="switch" class="switch" checked={value === true} onchange={(event) => onvalue(event.currentTarget.checked)} />
|
|
95
|
+
<span>{label}</span>
|
|
96
|
+
</label>
|
|
97
|
+
{:else if field.type === "number"}
|
|
98
|
+
<label>
|
|
99
|
+
<span>{label}</span>
|
|
100
|
+
<input type="number" value={typeof value === "number" ? value : ""} oninput={(event) => onvalue(event.currentTarget.value === "" ? 0 : Number(event.currentTarget.value))} />
|
|
101
|
+
</label>
|
|
102
|
+
{:else if field.type === "date"}
|
|
103
|
+
<DatePicker {label} value={typeof value === "string" ? value : ""} required={field.required} onchange={next => onvalue(next || undefined)} />
|
|
104
|
+
{:else if field.type === "reference"}
|
|
105
|
+
<Select {label} value={value && typeof value === "object" ? String((value as Record<string, unknown>)._ref ?? "") : ""} options={[{ value: "", label: "Choose…" }, ...options.map(option => ({ value: option.id, label: String(option.data.title ?? option.data.name ?? option.slug) }))]} onchange={next => onvalue(next ? { _ref: String(next) } : undefined)} />
|
|
106
|
+
{:else if field.type === "object"}
|
|
107
|
+
<fieldset class="schema-object">
|
|
108
|
+
<legend>{label}</legend>
|
|
109
|
+
{#each objectSchema as sub (sub.key)}
|
|
110
|
+
<SchemaField
|
|
111
|
+
{schema}
|
|
112
|
+
{documents}
|
|
113
|
+
{devMode}
|
|
114
|
+
field={sub}
|
|
115
|
+
base={`${base}.${sub.key}`}
|
|
116
|
+
value={value && typeof value === "object" ? (value as Record<string, unknown>)[sub.key] : undefined}
|
|
117
|
+
onvalue={(next) => onvalue({ ...(value && typeof value === "object" ? value : {}), [sub.key]: next })}
|
|
118
|
+
/>
|
|
119
|
+
{/each}
|
|
120
|
+
</fieldset>
|
|
121
|
+
{:else if field.type === "array"}
|
|
122
|
+
<div class="schema-array">
|
|
123
|
+
<div class="schema-label">{label}</div>
|
|
124
|
+
{#each itemList as item, index (`${base}.${item && typeof item === "object" ? (item as Record<string, unknown>)._key ?? index : index}`)}
|
|
125
|
+
<div class="schema-item" data-schema-path={`${base}.${item && typeof item === "object" ? (item as Record<string, unknown>)._key ?? index : index}`}>
|
|
126
|
+
<div class="schema-item-head">
|
|
127
|
+
<span>Item {index + 1}</span>
|
|
128
|
+
<span class="schema-key">{(item && typeof item === "object" ? (item as Record<string, unknown>)._key : "") ?? ""}</span>
|
|
129
|
+
<div class="schema-item-actions">
|
|
130
|
+
<button type="button" aria-label={`Move ${field.label} item ${index + 1} up`} title="Move up" disabled={index === 0} onclick={() => onvalue(moveItem(itemList, index, -1))}>↑</button>
|
|
131
|
+
<button type="button" aria-label={`Move ${field.label} item ${index + 1} down`} title="Move down" disabled={index === itemList.length - 1} onclick={() => onvalue(moveItem(itemList, index, 1))}>↓</button>
|
|
132
|
+
<button type="button" aria-label={`Duplicate ${field.label} item ${index + 1}`} title="Duplicate" onclick={() => onvalue(duplicateItem(itemList, index))}>⧉</button>
|
|
133
|
+
<button type="button" aria-label={`Remove ${field.label} item ${index + 1}`} title="Remove" onclick={() => onvalue(itemList.filter((_, position) => position !== index))}>×</button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
{#if itemSchema.length}
|
|
137
|
+
{#each itemSchema as sub (sub.key)}
|
|
138
|
+
<SchemaField
|
|
139
|
+
{schema}
|
|
140
|
+
{documents}
|
|
141
|
+
{devMode}
|
|
142
|
+
field={sub}
|
|
143
|
+
base={`${base}.${(item as Record<string, unknown>)._key}.${sub.key}`}
|
|
144
|
+
value={item && typeof item === "object" ? (item as Record<string, unknown>)[sub.key] : undefined}
|
|
145
|
+
onvalue={(next) => setItem(index, { ...(item as Record<string, unknown>), [sub.key]: next })}
|
|
146
|
+
/>
|
|
147
|
+
{/each}
|
|
148
|
+
{:else}
|
|
149
|
+
<input type="text" aria-label={`${field.label} item ${index + 1}`} value={typeof item === "string" ? item : ""} oninput={(event) => setItem(index, event.currentTarget.value)} />
|
|
150
|
+
{/if}
|
|
151
|
+
</div>
|
|
152
|
+
{/each}
|
|
153
|
+
<button type="button" class="outlined" onclick={() => onvalue([...itemList, itemSchema.length ? blankItem(itemSchema) : field.items?.some((item) => item.type === "reference") ? { _key: newKey(), _ref: "" } : ""])}>Add item</button>
|
|
154
|
+
</div>
|
|
155
|
+
{/if}
|
|
156
|
+
</div>
|