@rigel-cms/rigel 0.1.3-beta.1 → 0.1.4-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/editor/App.svelte +85 -39
- package/src/editor/lib/BlockEditor.svelte +18 -18
- package/src/editor/lib/ImagePicker.svelte +7 -7
- package/src/editor/lib/MarkdownEditor.svelte +7 -7
- package/src/editor/lib/SchemaField.svelte +4 -4
- package/src/editor/lib/SchemaPanel.svelte +4 -4
- 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/slugify.ts +7 -2
- package/src/editor/lib/storage.ts +30 -2
- package/src/editor/lib/tooltip.ts +120 -0
- package/src/editor/styles.css +10 -0
- package/src/preview.ts +2 -2
- package/src/routes/local-post.ts +8 -2
package/package.json
CHANGED
package/src/editor/App.svelte
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { onMount } from "svelte";
|
|
3
|
+
import { tooltipRoot } from "./lib/tooltip";
|
|
4
|
+
onMount(() => tooltipRoot(document.body).destroy);
|
|
2
5
|
import type {
|
|
3
6
|
Manifest,
|
|
4
7
|
ManifestCollection,
|
|
@@ -21,7 +24,7 @@
|
|
|
21
24
|
import SidebarIcon from "./lib/SidebarIcon.svelte";
|
|
22
25
|
import logo from "./assets/rigelcms-icon.png?url";
|
|
23
26
|
import { getSettings, setSettings } from "./lib/settings";
|
|
24
|
-
import { eraseData } from "./lib/storage";
|
|
27
|
+
import { eraseData, readSession, writeSession, clearSession } from "./lib/storage";
|
|
25
28
|
import MenuEditor from "./lib/MenuEditor.svelte";
|
|
26
29
|
import type { MenuItem } from "../manifest/menu";
|
|
27
30
|
import UnsavedChanges from "./lib/UnsavedChanges.svelte";
|
|
@@ -40,6 +43,7 @@
|
|
|
40
43
|
let ready = $state(false);
|
|
41
44
|
let blueprint: Manifest | null = null;
|
|
42
45
|
let gitContext: GitContext | null = null;
|
|
46
|
+
let authRevision = 0;
|
|
43
47
|
let connected = $state(false);
|
|
44
48
|
let connectionBusy = $state(false);
|
|
45
49
|
let connectionError = $state("");
|
|
@@ -76,6 +80,14 @@
|
|
|
76
80
|
blueprint = data;
|
|
77
81
|
manifest = data;
|
|
78
82
|
ready = true;
|
|
83
|
+
if (data.backend === "github" && data.github) {
|
|
84
|
+
const session = readSession(data.github);
|
|
85
|
+
if (session) {
|
|
86
|
+
gitContext = { token: session.accessToken, repo: data.github.repo, branch: data.github.branch };
|
|
87
|
+
sessionExpiresAt = session.expiresAt;
|
|
88
|
+
await recheckGitHub();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
79
91
|
} catch (error) {
|
|
80
92
|
loadError = error instanceof Error ? error.message : "Could not load the editor configuration.";
|
|
81
93
|
}
|
|
@@ -85,7 +97,7 @@
|
|
|
85
97
|
if (connectionBusy) throw new Error("Wait for GitHub access verification before saving.");
|
|
86
98
|
if (!gitContext || !connected || githubAccess?.status !== "ready") throw new Error("Connect GitHub and verify repository access before saving.");
|
|
87
99
|
if (Date.now() >= sessionExpiresAt - 30_000) {
|
|
88
|
-
|
|
100
|
+
invalidateSession();
|
|
89
101
|
throw new Error("Your GitHub session expired. Reconnect GitHub, then retry. Your unsaved edits are still here.");
|
|
90
102
|
}
|
|
91
103
|
return gitContext;
|
|
@@ -93,27 +105,54 @@
|
|
|
93
105
|
|
|
94
106
|
async function verifyGitHub() {
|
|
95
107
|
const config = blueprint?.github;
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
const context = gitContext;
|
|
109
|
+
if (!config || !context) return;
|
|
110
|
+
try {
|
|
111
|
+
if (Date.now() >= sessionExpiresAt - 30_000) {
|
|
112
|
+
invalidateSession();
|
|
113
|
+
throw new Error("Your GitHub session expired. Sign in again to check repository access.");
|
|
114
|
+
}
|
|
115
|
+
const access = await checkGitHubAccess(context.token, config.repo, config.appSlug);
|
|
116
|
+
if (gitContext !== context) return;
|
|
117
|
+
githubAccess = access;
|
|
118
|
+
if (access.status !== "ready") return;
|
|
119
|
+
if (!connected && blueprint) {
|
|
120
|
+
const remote = await loadManifestRemote(context, blueprint);
|
|
121
|
+
if (gitContext !== context) return;
|
|
122
|
+
manifest = remote;
|
|
123
|
+
connected = true;
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (gitContext === context) handleAuthError(error);
|
|
127
|
+
throw error;
|
|
104
128
|
}
|
|
105
129
|
}
|
|
106
130
|
|
|
131
|
+
function invalidateSession() {
|
|
132
|
+
authRevision += 1;
|
|
133
|
+
clearSession();
|
|
134
|
+
gitContext = null;
|
|
135
|
+
sessionExpiresAt = 0;
|
|
136
|
+
githubAccess = null;
|
|
137
|
+
tokenExpired = true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function handleAuthError(error: unknown) {
|
|
141
|
+
if (error instanceof Error && "status" in error && error.status === 401) invalidateSession();
|
|
142
|
+
}
|
|
143
|
+
|
|
107
144
|
async function connectGitHub() {
|
|
108
145
|
if (connectionBusy || saveState === "saving" || !manifest.github) return;
|
|
109
146
|
connectionBusy = true;
|
|
110
147
|
connectionError = "";
|
|
148
|
+
const revision = ++authRevision;
|
|
111
149
|
try {
|
|
112
150
|
const session = await startGitHubLogin(manifest.github.authOrigin);
|
|
113
|
-
if (
|
|
114
|
-
|
|
151
|
+
if (revision !== authRevision) return;
|
|
152
|
+
gitContext = { token: session.accessToken, repo: manifest.github.repo, branch: manifest.github.branch };
|
|
115
153
|
githubAccess = null;
|
|
116
154
|
sessionExpiresAt = session.expiresAt;
|
|
155
|
+
writeSession(manifest.github, session);
|
|
117
156
|
await verifyGitHub();
|
|
118
157
|
} catch (error) {
|
|
119
158
|
connectionError = error instanceof Error ? error.message : "Could not connect GitHub. Try signing in again.";
|
|
@@ -135,9 +174,7 @@
|
|
|
135
174
|
if (saveState === "saving" || connectionBusy) return;
|
|
136
175
|
userOpen = false;
|
|
137
176
|
requestNavigation(() => {
|
|
138
|
-
|
|
139
|
-
sessionExpiresAt = 0;
|
|
140
|
-
githubAccess = null;
|
|
177
|
+
invalidateSession();
|
|
141
178
|
connected = false;
|
|
142
179
|
connectionError = "";
|
|
143
180
|
settingsOpen = false;
|
|
@@ -158,9 +195,9 @@
|
|
|
158
195
|
|
|
159
196
|
$effect(() => {
|
|
160
197
|
const expiry = sessionExpiresAt;
|
|
161
|
-
tokenExpired =
|
|
198
|
+
tokenExpired = !expiry || Date.now() >= expiry - 30_000;
|
|
162
199
|
if (!expiry) return;
|
|
163
|
-
const timer = setTimeout(
|
|
200
|
+
const timer = setTimeout(invalidateSession, Math.max(0, expiry - Date.now() - 30_000));
|
|
164
201
|
return () => clearTimeout(timer);
|
|
165
202
|
});
|
|
166
203
|
|
|
@@ -186,7 +223,7 @@
|
|
|
186
223
|
? `/rigel/api/local/documents/${encodeURIComponent(docSel.id)}`
|
|
187
224
|
: m ? `/rigel/api/local/menus/${m.slug}` : po && c ? `/rigel/api/local/${c.name}/${encodeURIComponent(po.slug)}` : "/rigel/api/local/save";
|
|
188
225
|
const payload = docSel ? { data: values } : m ? { slug: m.slug, title: values.title, items: values.items } : po ? { frontmatter: values, body } : { slug: p?.slug, values };
|
|
189
|
-
const res = await fetch(url, { method: docSel ? (creatingDoc ? "POST" : "PUT") : m ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
|
226
|
+
const res = await fetch(url, { method: docSel ? (creatingDoc ? "POST" : "PUT") : m ? "PUT" : "POST", headers: { "Content-Type": "application/json", ...(po && creating ? { "If-None-Match": "*" } : {}) }, body: JSON.stringify(payload) });
|
|
190
227
|
if (!res.ok) throw new Error((await res.text()) || `Save failed (${res.status})`);
|
|
191
228
|
}
|
|
192
229
|
if (docSel) {
|
|
@@ -205,6 +242,7 @@
|
|
|
205
242
|
saveState = dirty ? "idle" : "saved";
|
|
206
243
|
saveError = "";
|
|
207
244
|
} catch (error) {
|
|
245
|
+
handleAuthError(error);
|
|
208
246
|
saveState = "error";
|
|
209
247
|
saveError = error instanceof Error ? error.message : "Could not save changes";
|
|
210
248
|
return false;
|
|
@@ -220,10 +258,10 @@
|
|
|
220
258
|
|
|
221
259
|
let settings = $state(getSettings());
|
|
222
260
|
let settingsError = $state("");
|
|
223
|
-
const userName = $derived(githubMode ? githubAccess?.user.login ?? "GitHub user" : "Local user");
|
|
261
|
+
const userName = $derived(githubMode ? githubAccess?.user.name ?? githubAccess?.user.login ?? "GitHub user" : "Local user");
|
|
224
262
|
const userAvatar = $derived(githubMode && /^https:\/\/avatars\.githubusercontent\.com\//.test(githubAccess?.user.avatar_url ?? "") ? githubAccess!.user.avatar_url : "");
|
|
225
263
|
let failedAvatar = $state("");
|
|
226
|
-
const
|
|
264
|
+
const userLogin = $derived(githubMode && githubAccess ? `@${githubAccess.user.login}` : "Local mode");
|
|
227
265
|
let content: HTMLDivElement;
|
|
228
266
|
let contentWidth = $state(0);
|
|
229
267
|
let resizing = $state(false);
|
|
@@ -273,7 +311,7 @@
|
|
|
273
311
|
function eraseBrowserData() {
|
|
274
312
|
actionDialog = {
|
|
275
313
|
title: "Erase browser data?", confirmLabel: "Erase data", danger: true,
|
|
276
|
-
description: "This removes Rigel settings, API keys and unsaved drafts from this browser. Saved repository files will not change. The editor will reload.",
|
|
314
|
+
description: "This removes Rigel settings, API keys, GitHub login and unsaved drafts from this browser. Saved repository files will not change. The editor will reload.",
|
|
277
315
|
onconfirm: () => {
|
|
278
316
|
if (saveState === "saving") throw new Error("Wait for the current save to finish.");
|
|
279
317
|
eraseData();
|
|
@@ -372,7 +410,7 @@
|
|
|
372
410
|
if (current?.kind === "document") map[current.id] = draft;
|
|
373
411
|
return map;
|
|
374
412
|
});
|
|
375
|
-
function documentPreview(mode: "
|
|
413
|
+
function documentPreview(mode: "draft" | "website") {
|
|
376
414
|
if (sel?.kind !== "document" || newDocument?.id === sel.id) return null;
|
|
377
415
|
return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1) }, mode);
|
|
378
416
|
}
|
|
@@ -596,9 +634,8 @@
|
|
|
596
634
|
actionDialog = {
|
|
597
635
|
title: `Add to ${c.label}`, description: "Enter a title to start a draft. Nothing is written until you press Save.", fieldLabel: "Title", confirmLabel: "Create draft",
|
|
598
636
|
onconfirm: (title) => {
|
|
599
|
-
const slug = slugify(title);
|
|
637
|
+
const slug = slugify(title, c.posts.map((post) => post.slug));
|
|
600
638
|
if (!slug) throw new Error("Enter a title containing letters or numbers.");
|
|
601
|
-
if (c.posts.some((p) => p.slug === slug)) throw new Error(`A post with slug "${slug}" already exists.`);
|
|
602
639
|
newEntry = { collection: c.name, post: { slug, frontmatter: initialCollectionValues(c.fields, title), body: emptyBody() } };
|
|
603
640
|
openSelection({ kind: "post", collection: c.name, slug });
|
|
604
641
|
},
|
|
@@ -613,7 +650,10 @@
|
|
|
613
650
|
description: unsaved ? "This discards the new post and its unsaved changes." : `This deletes ${current.slug}.json${githubMode ? ` from ${manifest.github?.branch}` : ""} and discards any unsaved changes. This cannot be undone in the editor.`,
|
|
614
651
|
onconfirm: async () => {
|
|
615
652
|
if (!unsaved) {
|
|
616
|
-
if (githubMode)
|
|
653
|
+
if (githubMode) {
|
|
654
|
+
try { await deleteRemotePost(requireGitContext(), manifest, collection.name, current.slug); }
|
|
655
|
+
catch (error) { handleAuthError(error); throw error; }
|
|
656
|
+
}
|
|
617
657
|
else {
|
|
618
658
|
const res = await fetch(`/rigel/api/local/${collection.name}/${encodeURIComponent(current.slug)}`, { method: "DELETE" });
|
|
619
659
|
if (!res.ok) throw new Error((await res.text()) || `Delete failed (${res.status})`);
|
|
@@ -640,6 +680,12 @@
|
|
|
640
680
|
{/snippet}
|
|
641
681
|
|
|
642
682
|
<svelte:window
|
|
683
|
+
onstorage={(event) => {
|
|
684
|
+
if ((event.key === "rigelcms.session" || event.key === null) && blueprint?.github && !readSession(blueprint.github)) {
|
|
685
|
+
invalidateSession();
|
|
686
|
+
connectionError = "Signed out in another tab. Reconnect GitHub to save. Your unsaved edits are still here.";
|
|
687
|
+
}
|
|
688
|
+
}}
|
|
643
689
|
onresize={endPreviewResize}
|
|
644
690
|
onblur={endPreviewResize}
|
|
645
691
|
oncontextmenu={(event) => { if (!settings.devMode) event.preventDefault(); }}
|
|
@@ -658,8 +704,8 @@
|
|
|
658
704
|
{#if !ready || (githubMode && !connected)}
|
|
659
705
|
<div class="rigel connection-shell">
|
|
660
706
|
<main id="github-connection" class="connection-card" aria-busy={connectionBusy}>
|
|
661
|
-
<img src={logo} alt="" width="64" height="64"
|
|
662
|
-
<h1>{!ready ? "
|
|
707
|
+
<div class="connection-brand"><img src={logo} alt="" width="64" height="64" /><span>Rigel <span class="brand-suffix">CMS</span></span></div>
|
|
708
|
+
<h1>{!ready ? "Loading editor" : "Connect GitHub"}</h1>
|
|
663
709
|
{#if !ready}
|
|
664
710
|
{#if loadError}<p class="connection-error" role="alert">{loadError}</p><button class="primary" onclick={loadBlueprint}>Retry loading</button>
|
|
665
711
|
{:else}<p role="status">Loading the editor configuration…</p>{/if}
|
|
@@ -693,7 +739,7 @@
|
|
|
693
739
|
<aside id="rigel-sidebar">
|
|
694
740
|
<a class="brand" href="/rigel/" role="button" aria-label="Welcome" aria-describedby="rigel-beta" onclick={(event) => { event.preventDefault(); goWelcome(); }}>
|
|
695
741
|
<img src={logo} alt="" width="32" height="32" />
|
|
696
|
-
<span>
|
|
742
|
+
<span>Rigel <span class="brand-suffix">CMS</span></span>
|
|
697
743
|
<span id="rigel-beta" class="badge">Beta</span>
|
|
698
744
|
</a>
|
|
699
745
|
<TopBar {manifest} {sel} onselect={select} />
|
|
@@ -714,7 +760,7 @@
|
|
|
714
760
|
data-document={node.target.kind === "document" ? node.target.id : undefined}
|
|
715
761
|
data-page={node.target.kind === "page" ? node.target.slug : undefined}
|
|
716
762
|
aria-label={node.label}
|
|
717
|
-
|
|
763
|
+
data-tooltip={node.label}
|
|
718
764
|
class:active={navActive(node.target)}
|
|
719
765
|
aria-current={navActive(node.target) ? "page" : undefined}
|
|
720
766
|
onclick={() => select(node.target)}
|
|
@@ -725,21 +771,21 @@
|
|
|
725
771
|
{/each}
|
|
726
772
|
</nav>
|
|
727
773
|
|
|
728
|
-
<button class="sidebar-toggle" aria-expanded={!settings.userPref.sidebarCollapsed} aria-controls="rigel-sidebar-navigation" aria-label={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
774
|
+
<button class="sidebar-toggle" aria-expanded={!settings.userPref.sidebarCollapsed} aria-controls="rigel-sidebar-navigation" aria-label={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} data-tooltip={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} onclick={() => { settings.userPref.sidebarCollapsed = !settings.userPref.sidebarCollapsed; }}>
|
|
729
775
|
<SidebarIcon name="sidebar-simple" /><span class="sidebar-label">Collapse sidebar</span>
|
|
730
776
|
</button>
|
|
731
777
|
<div class="aside-foot">
|
|
732
778
|
<div class="user-menu-wrap">
|
|
733
|
-
<button class="user-btn" aria-label={githubMode ? "GitHub user menu" : "Local user menu"}
|
|
779
|
+
<button class="user-btn" aria-label={githubMode ? "GitHub user menu" : "Local user menu"} data-tooltip={githubMode ? "GitHub user menu" : "Local user menu"} aria-haspopup="menu" aria-expanded={userOpen} onclick={() => (userOpen = !userOpen)}>
|
|
734
780
|
{@render avatar()}
|
|
735
|
-
<span class="user-details"><span class="uname">{userName}</span><small>{
|
|
781
|
+
<span class="user-details"><span class="uname">{userName}</span><small>{userLogin}</small></span>
|
|
736
782
|
<span class="user-menu-icon"><Icon name="dotsThreeOutlineVertical" size={18} /></span>
|
|
737
783
|
</button>
|
|
738
784
|
{#if userOpen}
|
|
739
785
|
<div class="user-menu" role="menu">
|
|
740
786
|
<div class="user-menu-head">
|
|
741
787
|
{@render avatar()}
|
|
742
|
-
<div class="user-details"><strong>{userName}</strong><small>{
|
|
788
|
+
<div class="user-details"><strong>{userName}</strong><small>{userLogin}</small></div>
|
|
743
789
|
</div>
|
|
744
790
|
<div class="divider"></div>
|
|
745
791
|
<a role="menuitem" href="/" target="_blank" rel="noopener">
|
|
@@ -760,7 +806,7 @@
|
|
|
760
806
|
<a role="menuitem" href={githubAccess?.installUrl} target="_blank" rel="noopener noreferrer">Configure GitHub access</a>
|
|
761
807
|
<button role="menuitem" class="github-reconnect" disabled={connectionBusy || saveState === "saving"} onclick={() => { userOpen = false; void connectGitHub(); }}>Reconnect GitHub</button>
|
|
762
808
|
{/if}
|
|
763
|
-
<button id={githubMode ? "github-sign-out" : undefined} role="menuitem" class:muted={!githubMode} disabled={!githubMode || saveState === "saving" || connectionBusy}
|
|
809
|
+
<button id={githubMode ? "github-sign-out" : undefined} role="menuitem" class:muted={!githubMode} disabled={!githubMode || saveState === "saving" || connectionBusy} data-tooltip={githubMode ? "Sign out of this editor" : "Local mode"} onclick={signOut}>
|
|
764
810
|
<Icon name="signOut" size={13} />
|
|
765
811
|
Sign Out
|
|
766
812
|
{#if !githubMode}<span class="soon">backend: github</span>{/if}
|
|
@@ -807,7 +853,7 @@
|
|
|
807
853
|
<button class="primary" onclick={() => (sel?.kind === "collection" ? newPost(coll!) : newPage(sel?.kind === "documents" ? activeDocType : pageDocType))}>Add new</button>
|
|
808
854
|
{/if}
|
|
809
855
|
{#if sel?.kind === "post" && post}
|
|
810
|
-
<button class="icon-btn"
|
|
856
|
+
<button class="icon-btn" aria-label="Delete post" data-tooltip="Delete post" disabled={saveState === "saving"} onclick={deletePost}>
|
|
811
857
|
<Icon name="trash" size={15} />
|
|
812
858
|
</button>
|
|
813
859
|
{/if}
|
|
@@ -885,7 +931,7 @@
|
|
|
885
931
|
values={draft}
|
|
886
932
|
documents={draftDocuments}
|
|
887
933
|
previewPath={documentPreview("draft")}
|
|
888
|
-
|
|
934
|
+
websitePath={documentPreview("website")}
|
|
889
935
|
creating={newDocument?.id === sel.id}
|
|
890
936
|
devMode={settings.devMode}
|
|
891
937
|
reloadToken={documentReload}
|
|
@@ -933,7 +979,7 @@
|
|
|
933
979
|
bodyInline={settings.userPref.editorExperience === "inline"}
|
|
934
980
|
onbody={(value) => { bodyDraft = value; touch(); }}
|
|
935
981
|
previewPath={manifest.preview ? previewUrl(manifest.preview, sel, "draft", !!newEntry) : null}
|
|
936
|
-
|
|
982
|
+
websitePath={manifest.preview ? previewUrl(manifest.preview, sel, "website") : null}
|
|
937
983
|
creating={!!newEntry}
|
|
938
984
|
devMode={settings.devMode}
|
|
939
985
|
onchange={(next) => { draft = next; touch(); }}
|
|
@@ -947,7 +993,7 @@
|
|
|
947
993
|
{:else}
|
|
948
994
|
<div class="welcome">
|
|
949
995
|
<img src={logo} alt="" width="64" height="64" />
|
|
950
|
-
<h2>Welcome to
|
|
996
|
+
<h2>Welcome to Rigel CMS</h2>
|
|
951
997
|
<p>The best CMS in the whole space.</p>
|
|
952
998
|
<p class="welcome-hint">Select a page or collection from the sidebar to start editing.</p>
|
|
953
999
|
</div>
|
|
@@ -968,7 +1014,7 @@
|
|
|
968
1014
|
{/if}
|
|
969
1015
|
|
|
970
1016
|
{#if settingsOpen}
|
|
971
|
-
<Settings bind:s={settings} error={settingsError} saving={saveState === "saving"} onerase={eraseBrowserData} onclose={() => (settingsOpen = false)} />
|
|
1017
|
+
<Settings bind:s={settings} github={githubMode ? manifest.github : undefined} user={githubAccess?.user} error={settingsError} saving={saveState === "saving"} onerase={eraseBrowserData} onclose={() => (settingsOpen = false)} />
|
|
972
1018
|
{/if}
|
|
973
1019
|
{#if shortcutsOpen}
|
|
974
1020
|
<Shortcuts onclose={() => (shortcutsOpen = false)} />
|
|
@@ -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}
|
|
@@ -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>
|
|
@@ -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}
|
|
@@ -119,10 +119,10 @@
|
|
|
119
119
|
{#if devMode}<span class="schema-key">{section._key}</span>{/if}
|
|
120
120
|
</button>
|
|
121
121
|
<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)}`}
|
|
122
|
+
<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>
|
|
123
|
+
<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>
|
|
124
|
+
<button type="button" aria-label={`Duplicate ${sectionLabel(schema, section._type)}`} data-tooltip="Duplicate" onclick={() => setSections(duplicateItem(sections, index) as Record<string, unknown>[])}>⧉</button>
|
|
125
|
+
<button type="button" aria-label={`Remove ${sectionLabel(schema, section._type)}`} data-tooltip="Remove" onclick={() => removeSection(index)}>×</button>
|
|
126
126
|
</div>
|
|
127
127
|
</li>
|
|
128
128
|
{/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>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { mount, unmount, onDestroy, tick, type Snippet } from "svelte";
|
|
3
3
|
import BlockEditor from "./BlockEditor.svelte";
|
|
4
|
+
import { tooltipRoot } from "./tooltip";
|
|
4
5
|
import editorStyles from "../styles.css?inline";
|
|
5
6
|
import type { ArticleBody } from "../../article-body";
|
|
6
7
|
import { patchPreview } from "../../preview";
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
import type { ContentDocument, DocumentDef, Schema } from "../../schema/types";
|
|
14
15
|
|
|
15
16
|
let {
|
|
16
|
-
docId, schema, docType, values, previewValues, documents = {}, previewPath,
|
|
17
|
+
docId, schema, docType, values, previewValues, documents = {}, previewPath, websitePath, body = "", bodyInline = false, onbody = () => {}, properties, creating, devMode, reloadToken = 0, onchange, oneditdocument = () => {},
|
|
17
18
|
}: {
|
|
18
19
|
docId: string;
|
|
19
20
|
schema?: Schema;
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
previewValues?: Record<string, unknown>;
|
|
23
24
|
documents?: Record<string, Record<string, unknown>>;
|
|
24
25
|
previewPath: string | null;
|
|
25
|
-
|
|
26
|
+
websitePath?: string | null;
|
|
26
27
|
body?: "" | import("../../article-body").ArticleBody;
|
|
27
28
|
properties?: Snippet<[SelectedField | null, number, boolean, () => void]>;
|
|
28
29
|
bodyInline?: boolean;
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
let bodyContents: ChildNode[] = [];
|
|
46
47
|
let bodyAttributes: [string, string | null][] = [];
|
|
47
48
|
let bodyScope: HTMLElement | null = null;
|
|
49
|
+
let removeTooltips: (() => void) | undefined;
|
|
48
50
|
let error = $state("");
|
|
49
51
|
let mode = $state<"browse" | "edit">("edit");
|
|
50
52
|
let panelOpen = $state(true);
|
|
@@ -186,6 +188,7 @@
|
|
|
186
188
|
|
|
187
189
|
function stopBodyEdit() {
|
|
188
190
|
if (!bodyEditor) return;
|
|
191
|
+
removeTooltips?.(); removeTooltips = undefined;
|
|
189
192
|
void unmount(bodyEditor); bodyEditor = undefined;
|
|
190
193
|
bodyThemeObserver?.disconnect(); bodyThemeObserver = undefined;
|
|
191
194
|
editingBody?.replaceChildren(...bodyContents);
|
|
@@ -222,6 +225,7 @@
|
|
|
222
225
|
syncTheme();
|
|
223
226
|
const host = doc.createElement("div"); host.className = "rigel rigel-inline";
|
|
224
227
|
scope.append(host); doc.body.append(scope); bodyScope = scope;
|
|
228
|
+
removeTooltips = tooltipRoot(host).destroy;
|
|
225
229
|
bodyEditor = mount(BlockEditor, { target: host, props: { editableRoot: element, get value() { return body as ArticleBody; }, oninput: (next: ArticleBody) => onbody(next) } });
|
|
226
230
|
bodyThemeObserver = new MutationObserver(syncTheme);
|
|
227
231
|
bodyThemeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
|
|
@@ -435,8 +439,8 @@
|
|
|
435
439
|
</div>
|
|
436
440
|
<span class="visual-hint">{mode === "edit" ? "Click a region to edit it. Scripts, forms and links stay disabled." : "Scripts, forms and links are disabled in this preview."}</span>
|
|
437
441
|
<div class="visual-actions">
|
|
438
|
-
{#if
|
|
439
|
-
<button type="button" class="icon-btn" aria-pressed={panelOpen} aria-label="Toggle properties panel"
|
|
442
|
+
{#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}
|
|
443
|
+
<button type="button" class="icon-btn" aria-pressed={panelOpen} aria-label="Toggle properties panel" data-tooltip="Toggle properties panel" onclick={() => (panelOpen = !panelOpen)}>
|
|
440
444
|
<SidebarIcon name="square-half" />
|
|
441
445
|
</button>
|
|
442
446
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export interface GitHubAccess {
|
|
2
|
-
user: { login: string; avatar_url: string; html_url: string };
|
|
2
|
+
user: { login: string; name?: string; avatar_url: string; html_url: string };
|
|
3
3
|
status: "ready" | "installation-required" | "read-only";
|
|
4
4
|
installUrl: string;
|
|
5
5
|
installation: { id: number; account: string; html_url: string } | null;
|
|
@@ -63,7 +63,7 @@ export async function checkGitHubAccess(token: string, repo: string, appSlug: st
|
|
|
63
63
|
});
|
|
64
64
|
} catch { throw new Error("Unable to reach GitHub. Check your connection and try again."); }
|
|
65
65
|
if (!response.ok) {
|
|
66
|
-
if (response.status === 401) throw new Error("GitHub session expired or was revoked. Please sign in again.");
|
|
66
|
+
if (response.status === 401) throw Object.assign(new Error("GitHub session expired or was revoked. Please sign in again."), { status: 401 });
|
|
67
67
|
if (response.status === 429 || (response.status === 403 && (response.headers.get("x-ratelimit-remaining") === "0" || response.headers.has("retry-after")))) throw new Error("GitHub rate limit reached. Wait a few minutes and try again.");
|
|
68
68
|
if (response.status === 403) throw new Error("GitHub access denied. Check app permissions and organization approval, then sign in again.");
|
|
69
69
|
if (response.status === 404) throw new Error("GitHub repository or installation is unavailable. Check the repository name and app installation, then try again.");
|
|
@@ -85,7 +85,8 @@ export async function checkGitHubAccess(token: string, repo: string, appSlug: st
|
|
|
85
85
|
};
|
|
86
86
|
const rawUser = await get("/user");
|
|
87
87
|
if (!rawUser || ![rawUser.login, rawUser.avatar_url, rawUser.html_url].every(value => typeof value === "string" && value)) throw new Error("GitHub returned an invalid user. Please sign in again.");
|
|
88
|
-
const
|
|
88
|
+
const name = typeof rawUser.name === "string" ? rawUser.name.trim() : "";
|
|
89
|
+
const user = { login: rawUser.login, ...(name ? { name } : {}), avatar_url: rawUser.avatar_url, html_url: rawUser.html_url };
|
|
89
90
|
const installUrl = `https://github.com/apps/${appSlug}/installations/new`;
|
|
90
91
|
const result: GitHubAccess = { user, status: "installation-required", installUrl, installation: null, repository: null };
|
|
91
92
|
type Installation = { id: number; app_slug: string; account?: { login?: string }; html_url?: string; permissions?: { contents?: string }; suspended_at?: string | null };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export function slugify(input: string): string {
|
|
2
|
-
|
|
1
|
+
export function slugify(input: string, existingSlugs: Iterable<string> = []): string {
|
|
2
|
+
const base = input
|
|
3
3
|
.normalize("NFKD")
|
|
4
4
|
.replace(/[̀-ͯ]/g, "") // combining marks → strip (é→e)
|
|
5
5
|
.toLowerCase()
|
|
@@ -7,4 +7,9 @@ export function slugify(input: string): string {
|
|
|
7
7
|
.replace(/[^a-z0-9]+/g, "-")
|
|
8
8
|
.replace(/^-+|-+$/g, "")
|
|
9
9
|
.replace(/-{2,}/g, "-");
|
|
10
|
+
if (!base) return "";
|
|
11
|
+
const existing = new Set(existingSlugs);
|
|
12
|
+
let slug = base;
|
|
13
|
+
for (let suffix = 2; existing.has(slug); suffix++) slug = `${base}-${suffix}`;
|
|
14
|
+
return slug;
|
|
10
15
|
}
|
|
@@ -1,8 +1,36 @@
|
|
|
1
|
-
|
|
1
|
+
type SessionConfig = { authOrigin: string; repo: string; branch: string; appSlug: string };
|
|
2
|
+
type Session = { accessToken: string; expiresAt: number };
|
|
3
|
+
|
|
4
|
+
function sessionScope(config: SessionConfig) {
|
|
5
|
+
return JSON.stringify([config.authOrigin, config.repo, config.branch, config.appSlug]);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function clearSession() {
|
|
9
|
+
try { localStorage.removeItem("rigelcms.session"); } catch {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readSession(config: SessionConfig): Session | null {
|
|
13
|
+
try {
|
|
14
|
+
const saved = JSON.parse(localStorage.getItem("rigelcms.session") ?? "null");
|
|
15
|
+
if (saved?.version === 1 && saved.scope === sessionScope(config)
|
|
16
|
+
&& typeof saved.accessToken === "string" && /^ghu_[a-zA-Z0-9_]{1,4092}$/.test(saved.accessToken)
|
|
17
|
+
&& Number.isSafeInteger(saved.expiresAt) && saved.expiresAt > Date.now() + 30_000 && saved.expiresAt <= Date.now() + 86_400_000) {
|
|
18
|
+
return { accessToken: saved.accessToken, expiresAt: saved.expiresAt };
|
|
19
|
+
}
|
|
20
|
+
} catch {}
|
|
21
|
+
clearSession();
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function writeSession(config: SessionConfig, session: Session) {
|
|
26
|
+
try { writeStorage("session", { version: 1, scope: sessionScope(config), accessToken: session.accessToken, expiresAt: session.expiresAt }); } catch {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readStorage(name: "settings" | "keys" | "mediaView" | "session"): string | null {
|
|
2
30
|
return localStorage.getItem(`rigelcms.${name}`) ?? localStorage.getItem(`rigel.${name}`);
|
|
3
31
|
}
|
|
4
32
|
|
|
5
|
-
export function writeStorage(name: "settings" | "keys" | "mediaView", value: unknown) {
|
|
33
|
+
export function writeStorage(name: "settings" | "keys" | "mediaView" | "session", value: unknown) {
|
|
6
34
|
localStorage.setItem(`rigelcms.${name}`, JSON.stringify(value));
|
|
7
35
|
localStorage.removeItem(`rigel.${name}`);
|
|
8
36
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export function tooltipPosition(rect: Pick<DOMRect, "left" | "right" | "top" | "bottom">, width: number, height: number, viewportWidth: number, viewportHeight: number) {
|
|
2
|
+
return {
|
|
3
|
+
left: Math.max(8, Math.min((rect.left + rect.right - width) / 2, viewportWidth - width - 8)),
|
|
4
|
+
top: Math.max(8, Math.min(rect.top >= height + 14 ? rect.top - height - 6 : rect.bottom + 6, viewportHeight - height - 8)),
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let provider: ReturnType<typeof createTooltips> | undefined;
|
|
9
|
+
|
|
10
|
+
export function tooltipRoot(root: HTMLElement) {
|
|
11
|
+
provider ??= createTooltips();
|
|
12
|
+
const remove = provider.register(root);
|
|
13
|
+
return { destroy: remove };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createTooltips() {
|
|
17
|
+
let tip: HTMLDivElement | undefined;
|
|
18
|
+
let anchor: HTMLElement | null = null;
|
|
19
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
20
|
+
let opening: ReturnType<typeof setTimeout> | undefined;
|
|
21
|
+
let frame = 0;
|
|
22
|
+
let view: Window | null = null;
|
|
23
|
+
let pointerTime = 0;
|
|
24
|
+
let users = 0;
|
|
25
|
+
const id = "rigel-tooltip";
|
|
26
|
+
|
|
27
|
+
function hide() {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
clearTimeout(opening);
|
|
30
|
+
if (view) view.cancelAnimationFrame(frame);
|
|
31
|
+
if (anchor) {
|
|
32
|
+
const ids = (anchor.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(value => value && value !== id);
|
|
33
|
+
if (ids.length) anchor.setAttribute("aria-describedby", ids.join(" ")); else anchor.removeAttribute("aria-describedby");
|
|
34
|
+
}
|
|
35
|
+
anchor = null;
|
|
36
|
+
if (tip?.matches(":popover-open")) tip.hidePopover();
|
|
37
|
+
tip?.remove();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function update() {
|
|
41
|
+
if (!anchor || !tip || !view) return;
|
|
42
|
+
if (!anchor.isConnected || !anchor.checkVisibility() || !anchor.dataset.tooltip) { hide(); return; }
|
|
43
|
+
if (tip.textContent !== anchor.dataset.tooltip) tip.textContent = anchor.dataset.tooltip;
|
|
44
|
+
const position = tooltipPosition(anchor.getBoundingClientRect(), tip.offsetWidth, tip.offsetHeight, view.innerWidth, view.innerHeight);
|
|
45
|
+
tip.style.left = `${position.left}px`;
|
|
46
|
+
tip.style.top = `${position.top}px`;
|
|
47
|
+
frame = view.requestAnimationFrame(update);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function show(target: HTMLElement) {
|
|
51
|
+
hide();
|
|
52
|
+
if (!target.isConnected || !target.checkVisibility() || !target.dataset.tooltip) return;
|
|
53
|
+
const doc = target.ownerDocument;
|
|
54
|
+
tip ??= doc.createElement("div");
|
|
55
|
+
tip.id = id;
|
|
56
|
+
tip.className = "rigel-tooltip";
|
|
57
|
+
tip.setAttribute("role", "tooltip");
|
|
58
|
+
tip.popover = "manual";
|
|
59
|
+
anchor = target;
|
|
60
|
+
view = doc.defaultView;
|
|
61
|
+
const scope = target.closest("dialog, .rigel") ?? target.parentElement!;
|
|
62
|
+
scope.append(tip);
|
|
63
|
+
tip.textContent = target.dataset.tooltip;
|
|
64
|
+
target.setAttribute("aria-describedby", [...new Set([...(target.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean), id])].join(" "));
|
|
65
|
+
try { tip.showPopover(); update(); }
|
|
66
|
+
catch (error) {
|
|
67
|
+
if ((error as DOMException)?.name !== "InvalidStateError") throw error;
|
|
68
|
+
opening = setTimeout(() => { if (anchor !== target) return; if (!target.isConnected || !target.checkVisibility()) { hide(); return; } tip!.showPopover(); update(); }, 0);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function register(root: HTMLElement) {
|
|
73
|
+
users++;
|
|
74
|
+
const doc = root.ownerDocument;
|
|
75
|
+
const win = doc.defaultView!;
|
|
76
|
+
const target = (event: Event) => {
|
|
77
|
+
const element = (event.target as Element)?.closest?.<HTMLElement>("[data-tooltip]");
|
|
78
|
+
return element && root.contains(element) && element.closest(".rigel") ? element : null;
|
|
79
|
+
};
|
|
80
|
+
const over = (event: PointerEvent) => {
|
|
81
|
+
if (event.pointerType === "touch") return;
|
|
82
|
+
if (event.target === tip) { clearTimeout(timer); return; }
|
|
83
|
+
const element = target(event);
|
|
84
|
+
if (!element || element.contains(event.relatedTarget as Node | null) || element === anchor || element === doc.activeElement) return;
|
|
85
|
+
hide(); timer = setTimeout(() => show(element), 450);
|
|
86
|
+
};
|
|
87
|
+
const out = (event: PointerEvent) => {
|
|
88
|
+
const element = target(event);
|
|
89
|
+
if (element?.contains(event.relatedTarget as Node | null)) return;
|
|
90
|
+
if (event.relatedTarget === tip || anchor?.contains(event.relatedTarget as Node | null)) return;
|
|
91
|
+
if (anchor === doc.activeElement) return;
|
|
92
|
+
clearTimeout(timer); timer = setTimeout(hide, 150);
|
|
93
|
+
};
|
|
94
|
+
const focus = (event: FocusEvent) => {
|
|
95
|
+
const element = target(event);
|
|
96
|
+
if (element && Date.now() - pointerTime > 700) show(element);
|
|
97
|
+
};
|
|
98
|
+
const down = () => { pointerTime = Date.now(); hide(); };
|
|
99
|
+
const key = (event: KeyboardEvent) => {
|
|
100
|
+
pointerTime = 0;
|
|
101
|
+
if (event.key !== "Escape") return;
|
|
102
|
+
const visible = !!anchor;
|
|
103
|
+
hide();
|
|
104
|
+
if (visible) { event.preventDefault(); event.stopImmediatePropagation(); }
|
|
105
|
+
};
|
|
106
|
+
const listeners = [["pointerover", over], ["pointerout", out], ["focusin", focus], ["focusout", hide], ["pointerdown", down], ["click", hide], ["keydown", key]] as const;
|
|
107
|
+
for (const [name, listener] of listeners) root.addEventListener(name, listener as EventListener, true);
|
|
108
|
+
win.addEventListener("blur", hide);
|
|
109
|
+
win.addEventListener("resize", hide);
|
|
110
|
+
win.addEventListener("scroll", hide, true);
|
|
111
|
+
return () => {
|
|
112
|
+
hide();
|
|
113
|
+
for (const [name, listener] of listeners) root.removeEventListener(name, listener as EventListener, true);
|
|
114
|
+
win.removeEventListener("blur", hide); win.removeEventListener("resize", hide);
|
|
115
|
+
win.removeEventListener("scroll", hide, true);
|
|
116
|
+
if (!--users) { tip = undefined; provider = undefined; }
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { register };
|
|
120
|
+
}
|
package/src/editor/styles.css
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
.rigel, .rigel *, .rigel *::before, .rigel *::after { box-sizing: border-box; }
|
|
2
|
+
.rigel .rigel-tooltip { position: fixed; pointer-events: auto; inset: auto; margin: 0; padding: 0.375rem 0.625rem; max-width: min(20rem, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow: auto; border: 1px solid var(--border-control); border-radius: var(--radius); background: var(--text); color: var(--panel); box-shadow: var(--shadow-md); font: 500 0.75rem/1.5 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; overflow-wrap: anywhere; text-align: start; white-space: normal; }
|
|
3
|
+
.rigel .rigel-tooltip::backdrop { background: transparent; pointer-events: none; }
|
|
4
|
+
@media (forced-colors: active) { .rigel .rigel-tooltip { background: Canvas; color: CanvasText; border-color: CanvasText; } }
|
|
2
5
|
.rigel {
|
|
3
6
|
--rigel-50: #EFFBFF;
|
|
4
7
|
--rigel-100: #D9F4FF;
|
|
@@ -26,6 +29,9 @@
|
|
|
26
29
|
|
|
27
30
|
.rigel.connection-shell { align-items: center; justify-content: center; padding: 1.5rem; min-height: 100dvh; }
|
|
28
31
|
.rigel .connection-card { flex: none; width: 100%; max-width: 560px; margin: 0; padding: 2rem; background: var(--panel); border: 1px solid var(--border); overflow-wrap: anywhere; }
|
|
32
|
+
.rigel .connection-brand { display: flex; align-items: center; gap: 0.75rem; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.04em; }
|
|
33
|
+
.rigel .brand-suffix { font-weight: 450; }
|
|
34
|
+
.rigel .connection-brand img { flex-shrink: 0; }
|
|
29
35
|
.rigel .connection-card h1 { margin: 1rem 0; font-size: 1.75rem; line-height: 1.2; }
|
|
30
36
|
.rigel .connection-card p { margin: 1rem 0; }
|
|
31
37
|
.rigel .connection-card :is(button, a.primary) { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0.625rem 1rem; font: inherit; text-decoration: none; text-align: center; }
|
|
@@ -959,6 +965,10 @@
|
|
|
959
965
|
font-size: 0.875rem; font-weight: 600; text-transform: none;
|
|
960
966
|
letter-spacing: 0; color: var(--text); margin: 0 0 0.75rem;
|
|
961
967
|
}
|
|
968
|
+
.rigel .connection-details { margin: 0; }
|
|
969
|
+
.rigel .connection-details dt { margin-top: 1rem; font-size: 0.75rem; font-weight: 600; color: var(--text-2); }
|
|
970
|
+
.rigel .connection-details dd { margin: 0.375rem 0 0; font-size: 0.875rem; overflow-wrap: anywhere; }
|
|
971
|
+
.rigel .connection-details small { display: block; margin-top: 0.125rem; color: var(--text-muted); }
|
|
962
972
|
.rigel .settings-pane fieldset { border: none; padding: 0; margin: 0 0 1rem; }
|
|
963
973
|
.rigel .settings-pane fieldset.accessibility-options { margin-top: 1.5rem; }
|
|
964
974
|
.rigel .accessibility-options label.inline { margin-top: 0; }
|
package/src/preview.ts
CHANGED
|
@@ -30,8 +30,8 @@ export function previewPath(config: PreviewConfig, target: PreviewTarget, isNew
|
|
|
30
30
|
return typeof path === "string" && /^\/(?!\/)/.test(path) && !/[\\\s?#]/.test(path) && !/%(?:2e|2f|5c)/i.test(path) && !path.split("/").some(part => part === "." || part === "..") && !/^\/rigel(?:\/|$)/.test(path) ? path : null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export function previewUrl(config: PreviewConfig, target: PreviewTarget, mode: "live" | "draft" = "draft", isNew = false): string | null {
|
|
34
|
-
const path = previewPath(config, target, mode === "draft" && isNew);
|
|
33
|
+
export function previewUrl(config: PreviewConfig, target: PreviewTarget, mode: "live" | "draft" | "website" = "draft", isNew = false): string | null {
|
|
34
|
+
const path = previewPath(config, target, mode === "draft" && (target.kind === "post" || isNew));
|
|
35
35
|
const base = mode === "live" ? config.liveUrl : config.draftUrl;
|
|
36
36
|
if (!path || base === undefined) return path;
|
|
37
37
|
try { return new URL(path.slice(1), previewBaseUrl(base)).href; } catch { return null; }
|
package/src/routes/local-post.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
import { writeFile, mkdir, rm, readFile, rename } from "node:fs/promises";
|
|
2
|
+
import { writeFile, mkdir, rm, readFile, rename, link } from "node:fs/promises";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
@@ -55,7 +55,13 @@ export const POST: APIRoute = async ({ request, params }) => {
|
|
|
55
55
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
56
56
|
try {
|
|
57
57
|
await writeFile(temporary, JSON.stringify({ frontmatter: fm, body: body.body }, null, 2) + "\n", { flag: "wx" });
|
|
58
|
-
|
|
58
|
+
if (request.headers.get("If-None-Match") === "*") {
|
|
59
|
+
try { await link(temporary, file); }
|
|
60
|
+
catch (error) {
|
|
61
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return new Response("A post with that slug already exists. Reload the collection and create the draft again.", { status: 409 });
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
} else await rename(temporary, file);
|
|
59
65
|
} finally { await rm(temporary, { force: true }); }
|
|
60
66
|
return new Response(null, { status: 204 });
|
|
61
67
|
};
|