@rigel-cms/rigel 0.1.3-beta.1 → 0.2.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/package.json +1 -1
- package/src/content.ts +3 -1
- package/src/editor/App.svelte +191 -55
- package/src/editor/lib/BlockEditor.svelte +18 -18
- package/src/editor/lib/CollectionPanel.svelte +6 -2
- package/src/editor/lib/ImagePicker.svelte +7 -7
- package/src/editor/lib/MarkdownEditor.svelte +7 -7
- package/src/editor/lib/PreviewFrame.svelte +64 -22
- package/src/editor/lib/PublishDialog.svelte +65 -0
- package/src/editor/lib/SchemaField.svelte +4 -4
- package/src/editor/lib/SchemaPanel.svelte +10 -5
- package/src/editor/lib/Select.svelte +3 -2
- package/src/editor/lib/SeoGuidance.svelte +1 -1
- package/src/editor/lib/Settings.svelte +27 -2
- package/src/editor/lib/SitePreview.svelte +2 -2
- package/src/editor/lib/TopBar.svelte +1 -1
- package/src/editor/lib/VisualEditor.svelte +8 -4
- package/src/editor/lib/github-auth.ts +4 -3
- package/src/editor/lib/github-backend.ts +218 -19
- package/src/editor/lib/slugify.ts +1 -10
- package/src/editor/lib/storage.ts +30 -2
- package/src/editor/lib/tooltip.ts +120 -0
- package/src/editor/styles.css +25 -1
- package/src/env.d.ts +1 -1
- package/src/preview.ts +9 -2
- package/src/routes/local-documents.ts +4 -4
- package/src/routes/local-post.ts +23 -5
- package/src/schema/paths.ts +3 -1
- package/src/schema/store.ts +21 -2
- package/src/slugs.ts +46 -0
- package/src/vite/manifest-plugin.ts +6 -2
|
@@ -509,10 +509,10 @@
|
|
|
509
509
|
|
|
510
510
|
{#snippet formatButtons()}
|
|
511
511
|
{#each marks.slice(0, 2) as mark}
|
|
512
|
-
<button type="button" aria-label={mark.label}
|
|
512
|
+
<button type="button" aria-label={mark.label} data-tooltip={mark.label} aria-pressed={activeMarks.includes(mark.type)} onpointerdown={event => event.preventDefault()} onclick={() => format(mark.type)}><Icon name={mark.icon} size={18} /></button>
|
|
513
513
|
{/each}
|
|
514
|
-
<button type="button" aria-label="Link"
|
|
515
|
-
<button type="button" aria-label="More formatting"
|
|
514
|
+
<button type="button" aria-label="Link" data-tooltip="Link" aria-expanded={linkOpen} aria-controls={`${id}-link`} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
|
|
515
|
+
<button type="button" aria-label="More formatting" data-tooltip="More formatting" aria-haspopup="menu" aria-expanded={formatsOpen} aria-controls={`${id}-formats`} popovertarget={`${id}-formats`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); formatTrigger = event.currentTarget; formatMenu.matches(':popover-open') ? closeMenu() : openMenu(event, formatMenu); }}><Icon name="caretDown" size={18} /></button>
|
|
516
516
|
{/snippet}
|
|
517
517
|
{#snippet tableControls()}
|
|
518
518
|
<div class="block-table-controls" role="group" aria-label="Table controls">
|
|
@@ -526,24 +526,24 @@
|
|
|
526
526
|
{#snippet toolbar(image: boolean)}
|
|
527
527
|
<div class="body-toolbar formatting-toolbar" class:image-toolbar={image} role="group" aria-label={image ? "Image editing" : "Body formatting"} style={inline || image ? floating : undefined} hidden={markdownView || ((inline || image) && !floating) || (image && !imageData)}>
|
|
528
528
|
{#if image}
|
|
529
|
-
<button type="button" aria-label="Edit alt text"
|
|
530
|
-
<button type="button" aria-label={imageData?.url ? "Edit image link" : "Add image link"}
|
|
529
|
+
<button type="button" aria-label="Edit alt text" data-tooltip="Edit alt text" onpointerdown={event => event.preventDefault()} onclick={openAlt}><Icon name="textAa" size={18} /></button>
|
|
530
|
+
<button type="button" aria-label={imageData?.url ? "Edit image link" : "Add image link"} data-tooltip={imageData?.url ? "Edit image link" : "Add image link"} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
|
|
531
531
|
{:else}
|
|
532
|
-
<Select ariaLabel="Block type"
|
|
532
|
+
<Select ariaLabel="Block type" data-tooltip={activeBlock?.label ?? "Block type"} icon={activeBlock?.icon ?? "paragraph"} value={imageData ? "" : blockType} options={textBlocks} disabled={!!imageData} onchange={next => { hoverKey = ""; changeBlock(String(next)); }} />
|
|
533
533
|
{#if !inline || !["image", "divider"].includes(blockType)}<span class="toolbar-separator" aria-hidden="true"></span>{@render formatButtons()}<span class="toolbar-separator" aria-hidden="true"></span>{/if}
|
|
534
534
|
{/if}
|
|
535
535
|
<ImagePicker toolbar value={image ? imageData?.src ?? "" : ""} altText={image ? imageData?.alt ?? "" : ""} triggerLabel={image ? "Replace image" : "Insert image"} onopen={() => { if (!imageJob) imageJob = { key: activeKey, replace: image }; linkOpen = false; altOpen = false; }} onpick={pickImage} onclose={() => { imageJob = null; refocus(); }} />
|
|
536
536
|
{#if !image}
|
|
537
|
-
<button type="button" aria-label="Table"
|
|
538
|
-
<button type="button" aria-label="Bulleted list"
|
|
539
|
-
<button type="button" aria-label="Numbered list"
|
|
537
|
+
<button type="button" aria-label="Table" data-tooltip="Table" aria-pressed={blockType === "table"} popovertarget={`${id}-table`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); tableMenu.matches(':popover-open') ? closeMenu() : openMenu(event, tableMenu); }}><Icon name="table" size={18} /></button>
|
|
538
|
+
<button type="button" aria-label="Bulleted list" data-tooltip="Bulleted list" aria-pressed={listType === "bullet"} disabled={["image", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("bullet")}><Icon name="listBullets" size={18} /></button>
|
|
539
|
+
<button type="button" aria-label="Numbered list" data-tooltip="Numbered list" aria-pressed={listType === "number"} disabled={["image", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("number")}><Icon name="listNumbers" size={18} /></button>
|
|
540
540
|
{#if !inline}
|
|
541
541
|
<button type="button" class="block-history" disabled={!canUndo} onclick={() => editor.dispatchCommand(L.UNDO_COMMAND, undefined)}>Undo</button>
|
|
542
542
|
<button type="button" class="block-history" disabled={!canRedo} onclick={() => editor.dispatchCommand(L.REDO_COMMAND, undefined)}>Redo</button>
|
|
543
|
-
<button type="button" class="block-add" aria-label="Add block"
|
|
543
|
+
<button type="button" class="block-add" aria-label="Add block" data-tooltip="Add block" onclick={addBlock}>Add block</button>
|
|
544
544
|
{/if}
|
|
545
545
|
{/if}
|
|
546
|
-
{#if inline || image}<span class="toolbar-separator" aria-hidden="true"></span><button type="button" aria-label="More actions"
|
|
546
|
+
{#if inline || image}<span class="toolbar-separator" aria-hidden="true"></span><button type="button" aria-label="More actions" data-tooltip="More actions" aria-haspopup="true" popovertarget={`${id}-actions`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); menu.matches(':popover-open') ? closeMenu() : openMenu(event); }}><Icon name="dotsThreeOutlineVertical" size={18} /></button>{/if}
|
|
547
547
|
</div>
|
|
548
548
|
{/snippet}
|
|
549
549
|
<div class="block-editor" bind:this={editorHost}>
|
|
@@ -556,9 +556,9 @@
|
|
|
556
556
|
<label for={`${id}-url`}>Link URL</label>
|
|
557
557
|
<div class="markdown-link-controls">
|
|
558
558
|
<input id={`${id}-url`} type="text" placeholder="https://…" bind:this={linkInput} bind:value={linkUrl} aria-invalid={!!linkError} />
|
|
559
|
-
<button type="button" aria-label="Apply link"
|
|
560
|
-
<button type="button" aria-label="Remove link"
|
|
561
|
-
<button type="button" aria-label="Close link"
|
|
559
|
+
<button type="button" aria-label="Apply link" data-tooltip="Apply link" onclick={() => applyLink()}><Icon name="check" size={16} /></button>
|
|
560
|
+
<button type="button" aria-label="Remove link" data-tooltip="Remove link" onclick={() => applyLink(true)}><Icon name="linkBreak" size={16} /></button>
|
|
561
|
+
<button type="button" aria-label="Close link" data-tooltip="Close link" onclick={() => { linkOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
|
|
562
562
|
</div>
|
|
563
563
|
{#if linkError}<p role="alert">{linkError}</p>{/if}
|
|
564
564
|
</div>
|
|
@@ -568,8 +568,8 @@
|
|
|
568
568
|
<label for={`${id}-alt`}>Alt text (leave empty if decorative)</label>
|
|
569
569
|
<div class="markdown-link-controls">
|
|
570
570
|
<input id={`${id}-alt`} type="text" aria-label="Image alt text" bind:this={altInput} bind:value={altText} />
|
|
571
|
-
<button type="button" aria-label="Apply alt text"
|
|
572
|
-
<button type="button" aria-label="Close alt text"
|
|
571
|
+
<button type="button" aria-label="Apply alt text" data-tooltip="Apply alt text" onclick={applyAlt}><Icon name="check" size={16} /></button>
|
|
572
|
+
<button type="button" aria-label="Close alt text" data-tooltip="Close alt text" onclick={() => { altOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
|
|
573
573
|
</div>
|
|
574
574
|
</div>
|
|
575
575
|
{/if}
|
|
@@ -578,9 +578,9 @@
|
|
|
578
578
|
<div class="block-markdown-view">{#if inline}<button type="button" class="outlined" onclick={async () => { markdownView = false; await tick(); editor.focus(); }}>Close Markdown view</button>{/if}<pre class="block-markdown" aria-label="Body as Markdown" tabindex="0">{articleMarkdown(value)}</pre></div>
|
|
579
579
|
{/if}
|
|
580
580
|
<div class="block-surface" bind:this={surface} hidden={markdownView} onpointermove={hover} role="group" aria-label="Article blocks">
|
|
581
|
-
{#if insertion && !markdownView}<button type="button" class="block-insert" style={`top:${insertion.top}px;left:${insertion.left}px`} aria-label="Insert paragraph here"
|
|
581
|
+
{#if insertion && !markdownView}<button type="button" class="block-insert" style={`top:${insertion.top}px;left:${insertion.left}px`} aria-label="Insert paragraph here" data-tooltip="Insert paragraph" onpointerdown={event => event.preventDefault()} onpointerleave={leaveInsertion} onblur={() => { insertion = null; }} onclick={insertParagraph}><Icon name="plus" size={16} /></button>{/if}
|
|
582
582
|
{#if !inline}<div class="block-content" contenteditable="true" role="textbox" aria-label="Body" aria-multiline="true" bind:this={root}></div>{/if}
|
|
583
|
-
{#if hoverKey || activeKey}<button bind:this={handle} type="button" class="block-handle" style={`top:${handleTop}px;${inline ? `left:${handleLeft}px` : ''}`} aria-label="Block options and drag handle"
|
|
583
|
+
{#if hoverKey || activeKey}<button bind:this={handle} type="button" class="block-handle" style={`top:${handleTop}px;${inline ? `left:${handleLeft}px` : ''}`} aria-label="Block options and drag handle" data-tooltip="Drag to reorder; click for block options" onpointerdown={startDrag} onpointermove={moveDrag} onpointerup={() => endDrag()} onpointercancel={() => endDrag(true)} onlostpointercapture={() => endDrag(true)} onclick={openMenu}><Icon name="dotsThreeOutlineVertical" size={16} /></button>{/if}
|
|
584
584
|
{#if drop}<div class="block-drop-indicator" style={`top:${drop.top}px;${inline ? `position:fixed;left:${root.getBoundingClientRect().left}px;width:${root.getBoundingClientRect().width}px;right:auto` : ''}`}></div>{/if}
|
|
585
585
|
<div bind:this={slashMenu} class="block-slash" id={`${id}-slash`} popover="manual" style={slashPosition} role="listbox" aria-label="Insert block" onkeydown={slashKeydown}>
|
|
586
586
|
{#if slash !== null}
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
import { referenceOptions } from "../../manifest/references";
|
|
14
14
|
import { isSeoField } from "../../manifest/seo";
|
|
15
15
|
import type { ContentDocument } from "../../schema/types";
|
|
16
|
+
import { slugify } from "../../slugs";
|
|
16
17
|
|
|
17
|
-
let { fields, values, body, selected, focusToken, devMode, documents = [], bodyInline = false, oneditbody = () => {}, onchange, onbody }: {
|
|
18
|
+
let { fields, values, storageSlug, body, selected, focusToken, devMode, documents = [], bodyInline = false, oneditbody = () => {}, onchange, onbody }: {
|
|
18
19
|
fields: CollectionField[];
|
|
19
20
|
values: Record<string, unknown>;
|
|
21
|
+
storageSlug: string;
|
|
20
22
|
body: ArticleBody;
|
|
21
23
|
bodyInline?: boolean;
|
|
22
24
|
oneditbody?: () => void;
|
|
@@ -49,7 +51,9 @@
|
|
|
49
51
|
<div id="properties-content" role="tabpanel" aria-labelledby="properties-tab-content" hidden={tab !== "content"}>
|
|
50
52
|
<section>
|
|
51
53
|
<h3>Article details</h3>
|
|
52
|
-
{
|
|
54
|
+
<label><span>URL slug</span><input type="text" value={String(values.slug ?? storageSlug)} oninput={(event) => onchange("slug", event.currentTarget.value)} onblur={() => { if (Object.hasOwn(values, "slug")) onchange("slug", slugify(String(values.slug))); }} /></label>
|
|
55
|
+
<p class="hint">Changes the URL, not the content filename. The website route must use the slug metadata.</p>
|
|
56
|
+
{#each fields.filter(field => field.key !== "slug" && !isSeoField(field.key)) as field (field.key)}
|
|
53
57
|
<div data-collection-field={field.key}>
|
|
54
58
|
{#if field.type === "reference"}
|
|
55
59
|
<ReferenceSelect label={field.label} value={values[field.key]} options={referenceOptions(field, documents)} multiple={field.multiple} onchange={(value) => onchange(field.key, value)} />
|
|
@@ -153,12 +153,12 @@
|
|
|
153
153
|
{#if error && !open}<p class="error" role="alert">{error}</p>{/if}
|
|
154
154
|
</div>
|
|
155
155
|
{:else if toolbar}
|
|
156
|
-
<button bind:this={trigger} data-image-trigger type="button" aria-label={triggerLabel}
|
|
156
|
+
<button bind:this={trigger} data-image-trigger type="button" aria-label={triggerLabel} data-tooltip={triggerLabel} onmousedown={event => event.preventDefault()} onclick={launch}><Icon name="image" size={18} /></button>
|
|
157
157
|
{:else}
|
|
158
158
|
<div class="image-field">
|
|
159
159
|
{#if value}<img src={value} alt="" class="preview" />{:else}<div class="preview empty">No image</div>{/if}
|
|
160
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"
|
|
161
|
+
{#if value && onpick}<button type="button" class="outlined" aria-label="Remove image" data-tooltip="Remove image" onclick={() => onpick?.("", "")}><Icon name="x" size={14} /></button>{/if}
|
|
162
162
|
</div>
|
|
163
163
|
{/if}
|
|
164
164
|
|
|
@@ -166,14 +166,14 @@
|
|
|
166
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
167
|
<div class="media-tools">
|
|
168
168
|
<div class="media-views" role="group" aria-label="Media view">
|
|
169
|
-
<button type="button" class="outlined"
|
|
170
|
-
<button type="button" class="outlined"
|
|
169
|
+
<button type="button" class="outlined" data-tooltip="Grid view" aria-label="Grid view" aria-pressed={view === "grid"} onclick={() => setView("grid")}><Icon name="squaresFour" size={18} /></button>
|
|
170
|
+
<button type="button" class="outlined" data-tooltip="List view" aria-label="List view" aria-pressed={view === "list"} onclick={() => setView("list")}><Icon name="listBullets" size={18} /></button>
|
|
171
171
|
</div>
|
|
172
172
|
<div class="media-select">
|
|
173
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
174
|
</div>
|
|
175
175
|
<div class="media-select">
|
|
176
|
-
<Select ariaLabel="Modified month"
|
|
176
|
+
<Select ariaLabel="Modified month" data-tooltip="Filesystem modified month (UTC), not upload date" value={month} onchange={next => { month = String(next); }} options={[{ value: "", label: "All modified dates" }, ...months.map(date => ({ value: date, label: new Date(date + "-01T00:00:00Z").toLocaleDateString(undefined, { month: "long", year: "numeric", timeZone: "UTC" }) }))]} />
|
|
177
177
|
</div>
|
|
178
178
|
<button type="button" class="outlined" onclick={() => { tab = "library"; void run(); }} disabled={loading || picking}>Refresh</button>
|
|
179
179
|
{#if filter || format || month}<button type="button" class="outlined" onclick={resetFilters}>Reset filters</button>{/if}
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
</div>
|
|
182
182
|
{#if view === "grid"}<div class="grid">
|
|
183
183
|
{#each filtered as asset}
|
|
184
|
-
<figure><button type="button" class="thumb"
|
|
184
|
+
<figure><button type="button" class="thumb" aria-label={asset.name} 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}<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
185
|
{/each}
|
|
186
186
|
</div>
|
|
187
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>
|
|
@@ -253,7 +253,7 @@
|
|
|
253
253
|
{#if searched && !results.length && !error}<p class="hint" role="status">No photos found. Try another search.</p>{/if}
|
|
254
254
|
<div class="grid">
|
|
255
255
|
{#each results as r (r.url)}
|
|
256
|
-
<figure><button type="button" class="thumb" disabled={picking} onclick={() => selected = {url: r.url, stock: r}}
|
|
256
|
+
<figure><button type="button" class="thumb" disabled={picking} onclick={() => selected = {url: r.url, stock: 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>
|
|
257
257
|
{/each}
|
|
258
258
|
</div>
|
|
259
259
|
{/if}
|
|
@@ -172,18 +172,18 @@
|
|
|
172
172
|
{#if warning}<p class="markdown-warning" role="status">{warning}</p>{/if}
|
|
173
173
|
<div class="markdown-visual" hidden={mode !== "visual"}>
|
|
174
174
|
<div class="body-toolbar formatting-toolbar" role="group" aria-label={`${label} formatting`}>
|
|
175
|
-
<Select ariaLabel="Text style"
|
|
175
|
+
<Select ariaLabel="Text style" data-tooltip="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
176
|
<div class="format-group" role="group" aria-label="Text formatting">
|
|
177
177
|
{#each marks as item}
|
|
178
|
-
<button type="button" aria-label={item.name}
|
|
178
|
+
<button type="button" aria-label={item.name} data-tooltip={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
179
|
{/each}
|
|
180
180
|
</div>
|
|
181
181
|
<div class="format-group" role="group" aria-label="Lists and quote">
|
|
182
182
|
{#each blocks as item}
|
|
183
|
-
<button type="button" aria-label={item.name}
|
|
183
|
+
<button type="button" aria-label={item.name} data-tooltip={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
184
|
{/each}
|
|
185
185
|
</div>
|
|
186
|
-
<button bind:this={linkButton} type="button" aria-label="Link"
|
|
186
|
+
<button bind:this={linkButton} type="button" aria-label="Link" data-tooltip="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
187
|
<ImagePicker toolbar onopen={() => { imageSelection = view?.state.selection.getBookmark(); linkOpen = false; }} onpick={insertImage} onclose={() => { imageSelection = undefined; }} />
|
|
188
188
|
</div>
|
|
189
189
|
{#if linkOpen}
|
|
@@ -191,9 +191,9 @@
|
|
|
191
191
|
<label for={`${id}-url`}>Link URL</label>
|
|
192
192
|
<div class="markdown-link-controls">
|
|
193
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"
|
|
195
|
-
<button type="button" aria-label="Remove link"
|
|
196
|
-
<button type="button" aria-label="Close link"
|
|
194
|
+
<button type="button" aria-label="Apply link" data-tooltip="Apply link" onclick={() => link()}><Icon name="check" size={16} /></button>
|
|
195
|
+
<button type="button" aria-label="Remove link" data-tooltip="Remove link" disabled={!activeMark("link")} onclick={() => link(true)}><Icon name="linkBreak" size={16} /></button>
|
|
196
|
+
<button type="button" aria-label="Close link" data-tooltip="Close link" onclick={() => { linkOpen = false; linkButton.focus(); }}><Icon name="x" size={16} /></button>
|
|
197
197
|
</div>
|
|
198
198
|
{#if linkError}<p id={`${id}-error`} role="alert">{linkError}</p>{/if}
|
|
199
199
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { untrack } from "svelte";
|
|
2
|
+
import { tick, untrack } from "svelte";
|
|
3
3
|
import { fetchPreview } from "./preview-frame";
|
|
4
4
|
|
|
5
5
|
let { url, title, frame = $bindable(), reloadToken = 0, onload, onloading, onerror }: {
|
|
@@ -11,35 +11,77 @@
|
|
|
11
11
|
onloading: () => void;
|
|
12
12
|
onerror: (message: string) => void;
|
|
13
13
|
} = $props();
|
|
14
|
-
let source = $state<{ src?: string; srcdoc?: string } | null>(null);
|
|
14
|
+
let source = $state<{ src?: string; srcdoc?: string; ready: (event: Event) => void; fail: () => void } | null>(null);
|
|
15
|
+
let loading = $state(true);
|
|
16
|
+
let revealed = $state(false);
|
|
15
17
|
|
|
16
18
|
$effect(() => {
|
|
17
19
|
const target = new URL(url, location.href);
|
|
18
20
|
void reloadToken;
|
|
21
|
+
const started = performance.now();
|
|
22
|
+
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const { signal } = controller;
|
|
19
25
|
source = null;
|
|
26
|
+
loading = true;
|
|
27
|
+
revealed = false;
|
|
20
28
|
untrack(onloading);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
function fail(message = "The prepared page could not be loaded. Check its URL and framing headers.") {
|
|
30
|
+
if (signal.aborted) return;
|
|
31
|
+
controller.abort();
|
|
32
|
+
clearTimeout(timeout);
|
|
33
|
+
loading = false;
|
|
34
|
+
onerror(message);
|
|
35
|
+
}
|
|
36
|
+
const timeout = setTimeout(() => fail("The preview took too long to load. Check its URL and try again."), 20000);
|
|
37
|
+
function wait(ms: number) {
|
|
38
|
+
return new Promise<void>(resolve => {
|
|
39
|
+
const finish = () => { clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); };
|
|
40
|
+
const timer = setTimeout(finish, ms);
|
|
41
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function ready(event: Event) {
|
|
45
|
+
const iframe = event.currentTarget as HTMLIFrameElement;
|
|
46
|
+
const doc = iframe.contentDocument;
|
|
47
|
+
if (signal.aborted) return;
|
|
48
|
+
if (!doc || (target.origin === location.origin && new URL(doc.URL).pathname.replace(/\/$/, "") !== target.pathname.replace(/\/$/, ""))) {
|
|
49
|
+
fail();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
if (doc.fonts) await Promise.race([doc.fonts.ready.catch(() => {}), wait(200)]);
|
|
54
|
+
if (signal.aborted) return;
|
|
55
|
+
if (!reducedMotion) await wait(Math.max(0, 500 - (performance.now() - started)));
|
|
56
|
+
if (signal.aborted || iframe !== frame) return;
|
|
57
|
+
onload();
|
|
58
|
+
await tick();
|
|
59
|
+
if (signal.aborted || iframe !== frame) return;
|
|
60
|
+
clearTimeout(timeout);
|
|
61
|
+
loading = false;
|
|
62
|
+
revealed = true;
|
|
63
|
+
} catch (cause) {
|
|
64
|
+
fail(cause instanceof Error ? cause.message : "Preview unavailable");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const handlers = { ready, fail: () => fail() };
|
|
68
|
+
if (target.origin === location.origin) source = { src: target.href, ...handlers };
|
|
69
|
+
else fetchPreview(target, signal).then(srcdoc => {
|
|
70
|
+
if (!signal.aborted) source = { srcdoc, ...handlers };
|
|
25
71
|
}).catch(cause => {
|
|
26
|
-
|
|
72
|
+
fail(cause instanceof Error ? cause.message : "Preview unavailable");
|
|
27
73
|
});
|
|
28
|
-
return () => controller.abort();
|
|
74
|
+
return () => { controller.abort(); clearTimeout(timeout); };
|
|
29
75
|
});
|
|
30
|
-
|
|
31
|
-
function ready() {
|
|
32
|
-
const doc = frame?.contentDocument;
|
|
33
|
-
if (!doc || (source?.src && new URL(doc.URL).pathname.replace(/\/$/, "") !== new URL(source.src).pathname.replace(/\/$/, ""))) {
|
|
34
|
-
onerror("The prepared page could not be loaded. Check its URL and framing headers.");
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
onload();
|
|
38
|
-
}
|
|
39
76
|
</script>
|
|
40
77
|
|
|
41
|
-
{
|
|
42
|
-
{#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{/
|
|
78
|
+
<div class="preview-frame" aria-busy={loading}>
|
|
79
|
+
{#if source}
|
|
80
|
+
{#key source}
|
|
81
|
+
<iframe bind:this={frame} {title} sandbox="allow-same-origin" referrerpolicy="no-referrer" src={source.src} srcdoc={source.srcdoc} onload={source.ready} onerror={source.fail} class:revealed inert={!revealed} aria-hidden={!revealed}></iframe>
|
|
82
|
+
{/key}
|
|
83
|
+
{/if}
|
|
84
|
+
{#if loading}
|
|
85
|
+
<div class="preview-loading" role="status"><span class="preview-spinner" aria-hidden="true"></span>Loading preview…</div>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Icon from "./Icon.svelte";
|
|
3
|
+
import type { PublishReview } from "./github-backend";
|
|
4
|
+
let { review, loading, busy, error, message, onrefresh, onconfirm, onclose }: {
|
|
5
|
+
review: PublishReview | null; loading: boolean; busy: boolean; error: string; message: string;
|
|
6
|
+
onrefresh: () => void; onconfirm: () => void; onclose: () => void;
|
|
7
|
+
} = $props();
|
|
8
|
+
let dialog: HTMLDialogElement;
|
|
9
|
+
let cancel: HTMLButtonElement;
|
|
10
|
+
$effect(() => {
|
|
11
|
+
dialog.showModal();
|
|
12
|
+
cancel.focus();
|
|
13
|
+
return () => dialog.close();
|
|
14
|
+
});
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<dialog bind:this={dialog} class="rigel rigel-modal-backdrop publish-dialog" aria-labelledby="publish-title" aria-describedby="publish-description" oncancel={(event) => { event.preventDefault(); if (!busy) onclose(); }}>
|
|
18
|
+
<form class="rigel-modal" onsubmit={(event) => { event.preventDefault(); onconfirm(); }} aria-busy={loading || busy}>
|
|
19
|
+
<div class="publish-head"><h2 id="publish-title">Publish saved changes</h2><button type="button" class="icon-btn" aria-label="Close publish review" disabled={busy} onclick={onclose}><Icon name="x" size={16} /></button></div>
|
|
20
|
+
<p id="publish-description">Publish includes <strong>all saved changes on the drafts branch</strong>, including files outside CMS content folders. Unsaved edits are never included or saved automatically.</p>
|
|
21
|
+
{#if loading}<p role="status">Loading the complete file review…</p>{/if}
|
|
22
|
+
{#if review}
|
|
23
|
+
<p><strong>{review.repo}</strong><br /><span class="publish-branch">{review.branch}</span> to <span class="publish-branch">{review.publishBranch}</span>, as one squash commit.</p>
|
|
24
|
+
{#if review.needsSync}
|
|
25
|
+
<p>The publishing branch has changes not in drafts, including earlier squash publishes. First, explicitly sync it into drafts using GitHub’s normal merge. This does not publish. Conflicts stop the sync. Then review the updated files again before publishing.</p>
|
|
26
|
+
<p>These are the current tree differences, not the final publish list:</p>
|
|
27
|
+
{/if}
|
|
28
|
+
<p id="publish-file-count"><strong>{review.files.length} {review.files.length === 1 ? "file" : "files"}</strong></p>
|
|
29
|
+
{#if review.files.length}
|
|
30
|
+
<ul class="publish-files" tabindex="0" aria-label="All changed files" aria-describedby="publish-file-count">
|
|
31
|
+
{#each review.files as file (file.filename)}
|
|
32
|
+
<li><span class="publish-file-status">{file.status}</span><span>{file.filename}{#if file.previous_filename}<small>Previously: {file.previous_filename}</small>{/if}</span></li>
|
|
33
|
+
{/each}
|
|
34
|
+
</ul>
|
|
35
|
+
{:else}<p>No saved changes to publish. The branch trees are identical.</p>{/if}
|
|
36
|
+
<p class="hint">Both branch revisions are checked again before publishing. GitHub pins the draft revision, but cannot atomically lock the target revision; a target update in the final instant can still race. Required checks, reviews and branch protections apply.</p>
|
|
37
|
+
{/if}
|
|
38
|
+
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
|
39
|
+
{#if message}<p role="status">{message}</p>{/if}
|
|
40
|
+
<div class="publish-actions">
|
|
41
|
+
<button type="button" bind:this={cancel} class="outlined" disabled={busy} onclick={onclose}>{message ? "Close" : "Cancel"}</button>
|
|
42
|
+
{#if !message}<button type="button" class="outlined" disabled={loading || busy} onclick={onrefresh}>Review again</button>{/if}
|
|
43
|
+
{#if review && !message}<button id="confirm-publish" type="submit" class="primary" disabled={loading || busy || !review.files.length}>{busy ? "Working…" : review.needsSync ? "Sync drafts first" : "Confirm publish"}</button>{/if}
|
|
44
|
+
</div>
|
|
45
|
+
</form>
|
|
46
|
+
</dialog>
|
|
47
|
+
|
|
48
|
+
<style>
|
|
49
|
+
.publish-dialog { box-sizing: border-box; width: 100%; height: 100%; max-width: none; max-height: none; margin: 0; padding: 1rem; border: none; color: var(--text); }
|
|
50
|
+
.publish-dialog:not([open]) { display: none; }
|
|
51
|
+
.publish-dialog::backdrop { background: transparent; }
|
|
52
|
+
.publish-dialog .rigel-modal { box-sizing: border-box; width: min(42rem, 100%); max-height: calc(100dvh - 2rem); overflow-y: auto; overflow-wrap: anywhere; }
|
|
53
|
+
.publish-head, .publish-actions { display: flex; align-items: center; gap: 0.75rem; }
|
|
54
|
+
.publish-head { justify-content: space-between; }
|
|
55
|
+
.publish-actions { justify-content: flex-end; flex-wrap: wrap; margin-top: 1.5rem; }
|
|
56
|
+
.publish-dialog button { min-height: 44px; }
|
|
57
|
+
.publish-head .icon-btn { min-width: 44px; }
|
|
58
|
+
.publish-branch { font-weight: 600; }
|
|
59
|
+
.publish-files { list-style: none; margin: 0; padding: 0; max-height: min(35dvh, 20rem); overflow-y: auto; border: 1px solid var(--border); }
|
|
60
|
+
.publish-files li { display: grid; grid-template-columns: 5rem minmax(0, 1fr); gap: 0.75rem; padding: 0.625rem; border-bottom: 1px solid var(--border); font-size: 0.8125rem; }
|
|
61
|
+
.publish-files li:last-child { border-bottom: none; }
|
|
62
|
+
.publish-files small { display: block; margin-top: 0.25rem; }
|
|
63
|
+
.publish-file-status { text-transform: capitalize; }
|
|
64
|
+
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
65
|
+
</style>
|
|
@@ -127,10 +127,10 @@
|
|
|
127
127
|
<span>Item {index + 1}</span>
|
|
128
128
|
<span class="schema-key">{(item && typeof item === "object" ? (item as Record<string, unknown>)._key : "") ?? ""}</span>
|
|
129
129
|
<div class="schema-item-actions">
|
|
130
|
-
<button type="button" aria-label={`Move ${field.label} item ${index + 1} up`}
|
|
131
|
-
<button type="button" aria-label={`Move ${field.label} item ${index + 1} down`}
|
|
132
|
-
<button type="button" aria-label={`Duplicate ${field.label} item ${index + 1}`}
|
|
133
|
-
<button type="button" aria-label={`Remove ${field.label} item ${index + 1}`}
|
|
130
|
+
<button type="button" aria-label={`Move ${field.label} item ${index + 1} up`} data-tooltip="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`} data-tooltip="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}`} data-tooltip="Duplicate" onclick={() => onvalue(duplicateItem(itemList, index))}>⧉</button>
|
|
133
|
+
<button type="button" aria-label={`Remove ${field.label} item ${index + 1}`} data-tooltip="Remove" onclick={() => onvalue(itemList.filter((_, position) => position !== index))}>×</button>
|
|
134
134
|
</div>
|
|
135
135
|
</div>
|
|
136
136
|
{#if itemSchema.length}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { resolvePath } from "../../schema/paths";
|
|
20
20
|
import { isSeoField } from "../../manifest/seo";
|
|
21
21
|
import type { ContentDocument, DocumentDef, Schema } from "../../schema/types";
|
|
22
|
+
import { slugify } from "../../slugs";
|
|
22
23
|
|
|
23
24
|
let {
|
|
24
25
|
schema, docType, values, docId, selected, creating, pending, documents, focusToken, devMode, onchange, onselect, oneditdocument,
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
const selectedField = $derived(selected?.docId === docId ? fieldForPath(schema, docType, values, selected.path) : null);
|
|
47
48
|
const selectedSection = $derived(selected?.sectionKey ? sectionTypeDef(schema, sections.find((section) => section._key === selected?.sectionKey)?._type) : undefined);
|
|
48
49
|
const selectedValue = $derived(selected && selectedField ? resolvePath(values, selected.path).value : undefined);
|
|
49
|
-
const docFields = $derived(docType.fields.filter((field) => field.key !== "sections"));
|
|
50
|
+
const docFields = $derived(docType.fields.filter((field) => field.key !== "sections" && (docType.shared || field.key !== "slug")));
|
|
50
51
|
const seoFields = $derived(docType.shared ? [] : docFields.filter(field => isSeoField(field.key)));
|
|
51
52
|
|
|
52
53
|
$effect(() => {
|
|
@@ -97,6 +98,10 @@
|
|
|
97
98
|
{#if seoFields.length}<PropertiesTabs bind:tab />{/if}
|
|
98
99
|
<section class="document-properties" hidden={tab !== "content"}>
|
|
99
100
|
<h3>{docType.shared ? "Content" : "Page properties"}</h3>
|
|
101
|
+
{#if !docType.shared}
|
|
102
|
+
<label><span>URL slug</span><input type="text" value={String(values.slug ?? docId.slice(docType.name.length + 1))} oninput={(event) => onchange({ ...values, slug: event.currentTarget.value })} onblur={() => { if (Object.hasOwn(values, "slug")) onchange({ ...values, slug: slugify(String(values.slug)) }); }} /></label>
|
|
103
|
+
<p class="hint">Changes the URL, not the document identifier or filename. The website route must use the slug metadata.</p>
|
|
104
|
+
{/if}
|
|
100
105
|
{#each docFields.filter(field => docType.shared || !isSeoField(field.key)) as field (field.key)}
|
|
101
106
|
<div data-schema-path={field.key}>
|
|
102
107
|
<SchemaField {schema} {documents} {devMode} {field} base={field.key} value={values[field.key]} onvalue={(next) => onchange(replaceAt(values, field.key, next))} />
|
|
@@ -119,10 +124,10 @@
|
|
|
119
124
|
{#if devMode}<span class="schema-key">{section._key}</span>{/if}
|
|
120
125
|
</button>
|
|
121
126
|
<div class="structure-actions">
|
|
122
|
-
<button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} up`}
|
|
123
|
-
<button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} down`}
|
|
124
|
-
<button type="button" aria-label={`Duplicate ${sectionLabel(schema, section._type)}`}
|
|
125
|
-
<button type="button" aria-label={`Remove ${sectionLabel(schema, section._type)}`}
|
|
127
|
+
<button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} up`} data-tooltip="Move up" disabled={index === 0} onclick={() => setSections(moveItem(sections, index, -1) as Record<string, unknown>[])}>↑</button>
|
|
128
|
+
<button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} down`} data-tooltip="Move down" disabled={index === sections.length - 1} onclick={() => setSections(moveItem(sections, index, 1) as Record<string, unknown>[])}>↓</button>
|
|
129
|
+
<button type="button" aria-label={`Duplicate ${sectionLabel(schema, section._type)}`} data-tooltip="Duplicate" onclick={() => setSections(duplicateItem(sections, index) as Record<string, unknown>[])}>⧉</button>
|
|
130
|
+
<button type="button" aria-label={`Remove ${sectionLabel(schema, section._type)}`} data-tooltip="Remove" onclick={() => removeSection(index)}>×</button>
|
|
126
131
|
</div>
|
|
127
132
|
</li>
|
|
128
133
|
{/each}
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
import Icon from "./Icon.svelte";
|
|
4
4
|
import type { ICONS } from "./icons";
|
|
5
5
|
|
|
6
|
-
let { label = "", ariaLabel = label, title, icon, value = "", options, multiple = false, disabled = false, onchange = () => {} }: {
|
|
6
|
+
let { label = "", ariaLabel = label, title, "data-tooltip": tooltip = title, icon, value = "", options, multiple = false, disabled = false, onchange = () => {} }: {
|
|
7
7
|
label?: string;
|
|
8
8
|
ariaLabel?: string;
|
|
9
9
|
title?: string;
|
|
10
|
+
"data-tooltip"?: string;
|
|
10
11
|
icon?: keyof typeof ICONS;
|
|
11
12
|
value?: string | string[];
|
|
12
13
|
options: { value: string; label: string; icon?: keyof typeof ICONS; disabled?: boolean }[];
|
|
@@ -120,7 +121,7 @@
|
|
|
120
121
|
|
|
121
122
|
<div class="custom-select" onfocusout={() => { if (open) requestAnimationFrame(() => { if (trigger.ownerDocument.activeElement?.tagName === "IFRAME") close(); }); }}>
|
|
122
123
|
{#if label}<span class="select-label" id={`${id}-label`}>{label}</span>{/if}
|
|
123
|
-
<button bind:this={trigger} type="button" class="select-trigger" class:icon-only={!!icon} role="combobox" aria-label={label ? undefined : ariaLabel} aria-labelledby={label ? `${id}-label` : undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined} popovertarget={`${id}-list`} {
|
|
124
|
+
<button bind:this={trigger} type="button" class="select-trigger" class:icon-only={!!icon} role="combobox" aria-label={label ? undefined : ariaLabel} aria-labelledby={label ? `${id}-label` : undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined} popovertarget={`${id}-list`} data-tooltip={tooltip} {disabled} onclick={event => { event.preventDefault(); menu.matches(":popover-open") ? close() : void reveal(); }} onkeydown={onkey}>
|
|
124
125
|
{#if icon}<Icon name={icon} size={18} />{:else}<span>{text || (multiple ? `Select ${label.toLowerCase() || "options"}…` : "Choose…")}</span><Icon name="caretDown" size={12} />{/if}
|
|
125
126
|
</button>
|
|
126
127
|
<select hidden aria-hidden="true" tabindex="-1" aria-label={ariaLabel} {multiple} {disabled} {value} onchange={event => { value = multiple ? Array.from(event.currentTarget.selectedOptions, option => option.value) : event.currentTarget.value; onchange(value); }}>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
{#if field === "metaTitle" || field === "description"}
|
|
10
10
|
<div class="seo-length" data-seo-length={field} data-tone={tone}>
|
|
11
|
-
<div class="seo-length-track" role="meter" aria-label={`${limits.label} length`} aria-valuemin={0} aria-valuemax={Math.max(limits.max, count)} aria-valuenow={count} aria-valuetext={`${count} characters. ${status}. Recommended ${limits.min} to ${limits.max}.`}
|
|
11
|
+
<div class="seo-length-track" role="meter" aria-label={`${limits.label} length`} aria-valuemin={0} aria-valuemax={Math.max(limits.max, count)} aria-valuenow={count} aria-valuetext={`${count} characters. ${status}. Recommended ${limits.min} to ${limits.max}.`} data-tooltip={status}>
|
|
12
12
|
<span class="seo-length-fill" style:width={`${Math.min(count / limits.max * 100, 100)}%`}></span>
|
|
13
13
|
</div>
|
|
14
14
|
<div class="seo-length-summary hint">
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getKeys, setKeys, STOCK_PROVIDERS } from "./images";
|
|
3
3
|
import type { RigelSettings } from "./settings";
|
|
4
|
+
import type { Manifest } from "../../manifest/types";
|
|
5
|
+
import type { GitHubAccess } from "./github-auth";
|
|
4
6
|
import Icon from "./Icon.svelte";
|
|
5
7
|
|
|
6
|
-
let { s = $bindable(), error, saving, onerase, onclose }: {
|
|
8
|
+
let { s = $bindable(), github, user, error, saving, onerase, onclose }: {
|
|
7
9
|
s: RigelSettings;
|
|
10
|
+
github?: Manifest["github"];
|
|
11
|
+
user?: GitHubAccess["user"];
|
|
8
12
|
error: string;
|
|
9
13
|
saving: boolean;
|
|
10
14
|
onerase: () => void;
|
|
11
15
|
onclose: () => void;
|
|
12
16
|
} = $props();
|
|
13
17
|
|
|
14
|
-
type PaneId = "appearance" | "editor" | "media" | "language" | "advanced";
|
|
18
|
+
type PaneId = "appearance" | "editor" | "connection" | "media" | "language" | "advanced";
|
|
15
19
|
let active: PaneId = $state("appearance");
|
|
16
20
|
|
|
17
21
|
const providerIds = Object.keys(STOCK_PROVIDERS) as (keyof typeof STOCK_PROVIDERS)[];
|
|
@@ -23,6 +27,7 @@
|
|
|
23
27
|
const panes: { id: PaneId; label: string }[] = [
|
|
24
28
|
{ id: "appearance", label: "Appearance" },
|
|
25
29
|
{ id: "editor", label: "Editor" },
|
|
30
|
+
{ id: "connection", label: "Connection" },
|
|
26
31
|
{ id: "media", label: "Media" },
|
|
27
32
|
{ id: "language", label: "Language" },
|
|
28
33
|
{ id: "advanced", label: "Advanced" },
|
|
@@ -31,6 +36,7 @@
|
|
|
31
36
|
const PANE_ICONS = {
|
|
32
37
|
appearance: "palette",
|
|
33
38
|
editor: "textB",
|
|
39
|
+
connection: "user",
|
|
34
40
|
media: "image",
|
|
35
41
|
language: "translate",
|
|
36
42
|
advanced: "wrench",
|
|
@@ -103,6 +109,25 @@
|
|
|
103
109
|
<label class="radio"><input type="radio" bind:group={s.userPref.editorExperience} value="inline" /><span>Inline preview</span></label>
|
|
104
110
|
</fieldset>
|
|
105
111
|
<p class="hint">Inline preview lets you click an article body and write directly on the page. Other fields stay in the sidebar. This preference saves in this browser; content still requires Save.</p>
|
|
112
|
+
{:else if active === "connection"}
|
|
113
|
+
<h3>Connection</h3>
|
|
114
|
+
{#if github}
|
|
115
|
+
<dl class="connection-details">
|
|
116
|
+
<dt>GitHub account</dt>
|
|
117
|
+
<dd>{#if user}{user.name ?? user.login}<small>@{user.login}</small>{:else}No GitHub account connected.{/if}</dd>
|
|
118
|
+
<dt>Repository</dt>
|
|
119
|
+
<dd>{github.repo}</dd>
|
|
120
|
+
<dt>Editing branch</dt>
|
|
121
|
+
<dd>{github.branch}</dd>
|
|
122
|
+
{#if github.publishBranch}
|
|
123
|
+
<dt>Publishing branch</dt>
|
|
124
|
+
<dd>{github.publishBranch}</dd>
|
|
125
|
+
{/if}
|
|
126
|
+
</dl>
|
|
127
|
+
{:else}
|
|
128
|
+
<p>Local mode</p>
|
|
129
|
+
<p class="hint">No GitHub account connected. Content saves to local files when you press Save.</p>
|
|
130
|
+
{/if}
|
|
106
131
|
{:else if active === "media"}
|
|
107
132
|
|
|
108
133
|
<section class="media-settings-section">
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import PreviewFrame from "./PreviewFrame.svelte";
|
|
6
6
|
let { target, config, values, body, isNew = false, devMode = false }: { target: PreviewTarget; config: PreviewConfig; values: Record<string, unknown>; body: "" | import("../../article-body").ArticleBody; isNew?: boolean; devMode?: boolean } = $props();
|
|
7
7
|
|
|
8
|
-
const
|
|
8
|
+
const websitePath = $derived(previewUrl(config, target, "website"));
|
|
9
9
|
const key = $derived(`${target.kind}:${target.collection ?? ""}:${target.slug}`);
|
|
10
10
|
let frame: HTMLIFrameElement | undefined = $state();
|
|
11
11
|
let loaded = $state(false);
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
<div class="site-preview" aria-label="Website preview">
|
|
40
40
|
<div class="preview-heading">
|
|
41
41
|
<strong>Website preview</strong>
|
|
42
|
-
{#if
|
|
42
|
+
{#if websitePath}<a class="icon-btn visual-live" href={websitePath} target="_blank" rel="noopener noreferrer" aria-label="Preview on website" data-tooltip="Preview on website"><Icon name="external" size={18} /></a>{/if}
|
|
43
43
|
</div>
|
|
44
44
|
{#if error}<p role="status" class="preview-error">{error}</p>{/if}
|
|
45
45
|
{#if path}
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
|
|
113
113
|
<svelte:window onkeydown={onKeydown} />
|
|
114
114
|
|
|
115
|
-
<button class="search-trigger" aria-label="Search content"
|
|
115
|
+
<button class="search-trigger" aria-label="Search content" data-tooltip="Search content (⌘K)" onclick={() => { active = -1; open = true; }}>
|
|
116
116
|
<SidebarIcon name="magnifying-glass" />
|
|
117
117
|
<span class="sidebar-label">Search…</span>
|
|
118
118
|
<kbd>⌘K</kbd>
|