@rigel-cms/rigel 0.0.0-stage → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +8 -2
  3. package/package.json +78 -4
  4. package/src/article-body.ts +60 -0
  5. package/src/article-render.ts +87 -0
  6. package/src/config.ts +97 -0
  7. package/src/content.ts +5 -0
  8. package/src/editor/App.svelte +972 -0
  9. package/src/editor/assets/rigelcms-icon.png +0 -0
  10. package/src/editor/assets/sidebar/LICENSE +21 -0
  11. package/src/editor/assets/sidebar/book-open.svg +1 -0
  12. package/src/editor/assets/sidebar/calendar.svg +1 -0
  13. package/src/editor/assets/sidebar/chart-bar.svg +1 -0
  14. package/src/editor/assets/sidebar/file-text.svg +1 -0
  15. package/src/editor/assets/sidebar/files.svg +1 -0
  16. package/src/editor/assets/sidebar/folder.svg +1 -0
  17. package/src/editor/assets/sidebar/folders.svg +1 -0
  18. package/src/editor/assets/sidebar/gear.svg +1 -0
  19. package/src/editor/assets/sidebar/globe.svg +1 -0
  20. package/src/editor/assets/sidebar/heart.svg +1 -0
  21. package/src/editor/assets/sidebar/house.svg +1 -0
  22. package/src/editor/assets/sidebar/images.svg +1 -0
  23. package/src/editor/assets/sidebar/layout.svg +1 -0
  24. package/src/editor/assets/sidebar/list.svg +1 -0
  25. package/src/editor/assets/sidebar/magnifying-glass.svg +1 -0
  26. package/src/editor/assets/sidebar/megaphone.svg +1 -0
  27. package/src/editor/assets/sidebar/newspaper.svg +1 -0
  28. package/src/editor/assets/sidebar/pencil-simple.svg +1 -0
  29. package/src/editor/assets/sidebar/shopping-bag.svg +1 -0
  30. package/src/editor/assets/sidebar/sidebar-simple.svg +1 -0
  31. package/src/editor/assets/sidebar/square-half.svg +1 -0
  32. package/src/editor/assets/sidebar/squares-four.svg +1 -0
  33. package/src/editor/assets/sidebar/tag.svg +1 -0
  34. package/src/editor/assets/sidebar/user.svg +1 -0
  35. package/src/editor/assets/sidebar/users.svg +1 -0
  36. package/src/editor/blocks/bulleted-list.ts +2 -0
  37. package/src/editor/blocks/code.ts +1 -0
  38. package/src/editor/blocks/divider.ts +14 -0
  39. package/src/editor/blocks/h1.ts +2 -0
  40. package/src/editor/blocks/h2.ts +2 -0
  41. package/src/editor/blocks/h3.ts +2 -0
  42. package/src/editor/blocks/h4.ts +2 -0
  43. package/src/editor/blocks/h5.ts +2 -0
  44. package/src/editor/blocks/h6.ts +2 -0
  45. package/src/editor/blocks/image.ts +34 -0
  46. package/src/editor/blocks/index.ts +40 -0
  47. package/src/editor/blocks/numbered-list.ts +2 -0
  48. package/src/editor/blocks/paragraph.ts +1 -0
  49. package/src/editor/blocks/quote.ts +1 -0
  50. package/src/editor/blocks/table.ts +5 -0
  51. package/src/editor/lib/ActionDialog.svelte +51 -0
  52. package/src/editor/lib/BlockEditor.svelte +614 -0
  53. package/src/editor/lib/CollectionPanel.svelte +103 -0
  54. package/src/editor/lib/DatePicker.svelte +106 -0
  55. package/src/editor/lib/Field.svelte +80 -0
  56. package/src/editor/lib/Icon.svelte +34 -0
  57. package/src/editor/lib/ImagePicker.svelte +264 -0
  58. package/src/editor/lib/MarkdownEditor.svelte +206 -0
  59. package/src/editor/lib/MenuEditor.svelte +209 -0
  60. package/src/editor/lib/PropertiesTabs.svelte +15 -0
  61. package/src/editor/lib/ReferenceSelect.svelte +17 -0
  62. package/src/editor/lib/SchemaField.svelte +156 -0
  63. package/src/editor/lib/SchemaPanel.svelte +189 -0
  64. package/src/editor/lib/Select.svelte +138 -0
  65. package/src/editor/lib/SeoGuidance.svelte +21 -0
  66. package/src/editor/lib/Settings.svelte +183 -0
  67. package/src/editor/lib/Shortcuts.svelte +29 -0
  68. package/src/editor/lib/SidebarIcon.svelte +8 -0
  69. package/src/editor/lib/SitePreview.svelte +50 -0
  70. package/src/editor/lib/TopBar.svelte +165 -0
  71. package/src/editor/lib/UnsavedChanges.svelte +38 -0
  72. package/src/editor/lib/VisualEditor.svelte +494 -0
  73. package/src/editor/lib/calendar.ts +55 -0
  74. package/src/editor/lib/github-auth.ts +117 -0
  75. package/src/editor/lib/github-backend.ts +263 -0
  76. package/src/editor/lib/icons.ts +111 -0
  77. package/src/editor/lib/images.ts +195 -0
  78. package/src/editor/lib/markdown.ts +71 -0
  79. package/src/editor/lib/media-library.ts +39 -0
  80. package/src/editor/lib/menu-tree.ts +37 -0
  81. package/src/editor/lib/rich-markdown.ts +30 -0
  82. package/src/editor/lib/schema-edit.ts +110 -0
  83. package/src/editor/lib/settings.ts +103 -0
  84. package/src/editor/lib/slugify.ts +10 -0
  85. package/src/editor/lib/storage.ts +17 -0
  86. package/src/editor/lib/store.svelte.ts +33 -0
  87. package/src/editor/main.ts +9 -0
  88. package/src/editor/styles.css +1280 -0
  89. package/src/env.d.ts +22 -0
  90. package/src/index.ts +128 -0
  91. package/src/manifest/frontmatter.ts +56 -0
  92. package/src/manifest/loader.ts +14 -0
  93. package/src/manifest/menu.ts +46 -0
  94. package/src/manifest/navigation.ts +94 -0
  95. package/src/manifest/references.ts +39 -0
  96. package/src/manifest/seo.ts +15 -0
  97. package/src/manifest/types.ts +75 -0
  98. package/src/media-types.ts +21 -0
  99. package/src/preview-urls.ts +3 -0
  100. package/src/preview.ts +167 -0
  101. package/src/routes/local-api.ts +42 -0
  102. package/src/routes/local-documents.ts +83 -0
  103. package/src/routes/local-media.ts +124 -0
  104. package/src/routes/local-menu.ts +81 -0
  105. package/src/routes/local-post.ts +80 -0
  106. package/src/routes/manifest.json.ts +15 -0
  107. package/src/routes/media-files.ts +126 -0
  108. package/src/routes/media-validation.ts +37 -0
  109. package/src/routes/media.json.ts +12 -0
  110. package/src/routes/rigel-page.astro +25 -0
  111. package/src/routes/serialize.ts +139 -0
  112. package/src/schema/docs.ts +33 -0
  113. package/src/schema/paths.ts +136 -0
  114. package/src/schema/store.ts +141 -0
  115. package/src/schema/types.ts +81 -0
  116. package/src/schema/validate.ts +327 -0
  117. package/src/vite/manifest-plugin.ts +180 -0
@@ -0,0 +1,972 @@
1
+ <script lang="ts">
2
+ import type {
3
+ Manifest,
4
+ ManifestCollection,
5
+ CollectionPost,
6
+ } from "../manifest/types";
7
+ import Field from "./lib/Field.svelte";
8
+ import SitePreview from "./lib/SitePreview.svelte";
9
+ import ImagePicker from "./lib/ImagePicker.svelte";
10
+ import Settings from "./lib/Settings.svelte";
11
+ import TopBar from "./lib/TopBar.svelte";
12
+ import Shortcuts from "./lib/Shortcuts.svelte";
13
+ import CollectionPanel from "./lib/CollectionPanel.svelte";
14
+ import { emptyBody, type ArticleBody } from "../article-body";
15
+ import SchemaPanel from "./lib/SchemaPanel.svelte";
16
+ import { collectionPreviewValues, referenceLabel, referenceOptions } from "../manifest/references";
17
+ import { isSeoField } from "../manifest/seo";
18
+ import { slugify } from "./lib/slugify";
19
+ import Icon from "./lib/Icon.svelte";
20
+ import SidebarIcon from "./lib/SidebarIcon.svelte";
21
+ import logo from "./assets/rigelcms-icon.png?url";
22
+ import { getSettings, setSettings } from "./lib/settings";
23
+ import { eraseData } from "./lib/storage";
24
+ import MenuEditor from "./lib/MenuEditor.svelte";
25
+ import type { MenuItem } from "../manifest/menu";
26
+ import UnsavedChanges from "./lib/UnsavedChanges.svelte";
27
+ import ActionDialog from "./lib/ActionDialog.svelte";
28
+ import VisualEditor from "./lib/VisualEditor.svelte";
29
+ import { newDocumentFromTemplate } from "../schema/paths";
30
+ import { previewPath } from "../preview";
31
+ import type { ContentDocument, Schema } from "../schema/types";
32
+ import { buildSidebar, navigationKey, type Selection } from "../manifest/navigation";
33
+ import { startGitHubLogin, checkGitHubAccess, type GitHubAccess } from "./lib/github-auth";
34
+ import { loadManifestRemote, saveRemoteSelection, deleteRemotePost, type GitContext } from "./lib/github-backend";
35
+
36
+ type Sel = Selection;
37
+
38
+ let manifest = $state<Manifest>({ pages: [] });
39
+ let ready = $state(false);
40
+ let blueprint: Manifest | null = null;
41
+ let gitContext: GitContext | null = null;
42
+ let connected = $state(false);
43
+ let connectionBusy = $state(false);
44
+ let connectionError = $state("");
45
+ let loadError = $state("");
46
+ let githubAccess = $state<GitHubAccess | null>(null);
47
+ let sessionExpiresAt = $state(0);
48
+ let tokenExpired = $state(false);
49
+ const githubMode = $derived(manifest.backend === "github");
50
+ let sel = $state<Sel | null>(null);
51
+ const previewTarget = $derived(sel?.kind === "page" ? sel : null);
52
+ let draft = $state<Record<string, unknown>>({});
53
+ let bodyDraft = $state<"" | ArticleBody>("");
54
+ let bodyBase = $state<"" | ArticleBody>("");
55
+ let dirty = $state(false);
56
+ let saveState = $state<"idle" | "saving" | "saved" | "error">("idle");
57
+ let saveTask: Promise<boolean> | null = null;
58
+ let saveError = $state("");
59
+ let pendingNavigation = $state<(() => void) | null>(null);
60
+ let actionDialog = $state<{ title: string; description: string; confirmLabel: string; fieldLabel?: string; danger?: boolean; onconfirm: (value: string) => void | Promise<void> } | null>(null);
61
+ let newEntry = $state<{ collection: string; post: CollectionPost } | null>(null);
62
+ let newDocument = $state<{ type: string; id: string; slug: string } | null>(null);
63
+ let docBase = $state<Record<string, unknown> | null>(null);
64
+ let documentReload = $state(0);
65
+ if (import.meta.hot) import.meta.hot.on("rigel:content-updated", () => { if (!githubMode) documentReload += 1; });
66
+
67
+ async function loadBlueprint() {
68
+ loadError = "";
69
+ ready = false;
70
+ try {
71
+ const response = await fetch("/rigel/manifest.json", { cache: "no-store" });
72
+ if (!response.ok) throw new Error("Could not load the editor configuration. Rebuild the site and try again.");
73
+ const data: Manifest = await response.json();
74
+ if (!Array.isArray(data.pages) || (data.backend === "github" && !data.github)) throw new Error("The editor configuration is incomplete. Rebuild the site and try again.");
75
+ blueprint = data;
76
+ manifest = data;
77
+ ready = true;
78
+ } catch (error) {
79
+ loadError = error instanceof Error ? error.message : "Could not load the editor configuration.";
80
+ }
81
+ }
82
+
83
+ function requireGitContext() {
84
+ if (connectionBusy) throw new Error("Wait for GitHub access verification before saving.");
85
+ if (!gitContext || !connected || githubAccess?.status !== "ready") throw new Error("Connect GitHub and verify repository access before saving.");
86
+ if (Date.now() >= sessionExpiresAt - 30_000) {
87
+ tokenExpired = true;
88
+ throw new Error("Your GitHub session expired. Reconnect GitHub, then retry. Your unsaved edits are still here.");
89
+ }
90
+ return gitContext;
91
+ }
92
+
93
+ async function verifyGitHub() {
94
+ const config = blueprint?.github;
95
+ if (!config || !gitContext) return;
96
+ if (Date.now() >= sessionExpiresAt - 30_000) throw new Error("Your GitHub session expired. Sign in again to check repository access.");
97
+ githubAccess = await checkGitHubAccess(gitContext.token, config.repo, config.appSlug);
98
+ if (githubAccess.status !== "ready") return;
99
+ if (!connected && blueprint) {
100
+ const remote = await loadManifestRemote(gitContext, blueprint);
101
+ manifest = remote;
102
+ connected = true;
103
+ }
104
+ }
105
+
106
+ async function connectGitHub() {
107
+ if (connectionBusy || saveState === "saving" || !manifest.github) return;
108
+ connectionBusy = true;
109
+ connectionError = "";
110
+ try {
111
+ const session = await startGitHubLogin(manifest.github.authOrigin);
112
+ if (gitContext) gitContext.token = session.accessToken;
113
+ else gitContext = { token: session.accessToken, repo: manifest.github.repo, branch: manifest.github.branch };
114
+ githubAccess = null;
115
+ sessionExpiresAt = session.expiresAt;
116
+ await verifyGitHub();
117
+ } catch (error) {
118
+ connectionError = error instanceof Error ? error.message : "Could not connect GitHub. Try signing in again.";
119
+ } finally {
120
+ connectionBusy = false;
121
+ }
122
+ }
123
+
124
+ async function recheckGitHub() {
125
+ if (connectionBusy) return;
126
+ connectionBusy = true;
127
+ connectionError = "";
128
+ try { await verifyGitHub(); }
129
+ catch (error) { connectionError = error instanceof Error ? error.message : "Could not check GitHub access. Try again."; }
130
+ finally { connectionBusy = false; }
131
+ }
132
+
133
+ function signOut() {
134
+ if (saveState === "saving" || connectionBusy) return;
135
+ userOpen = false;
136
+ requestNavigation(() => {
137
+ gitContext = null;
138
+ sessionExpiresAt = 0;
139
+ githubAccess = null;
140
+ connected = false;
141
+ connectionError = "";
142
+ settingsOpen = false;
143
+ shortcutsOpen = false;
144
+ newEntry = null;
145
+ newDocument = null;
146
+ draft = {};
147
+ bodyDraft = "";
148
+ bodyBase = "";
149
+ docBase = null;
150
+ dirty = false;
151
+ saveState = "idle";
152
+ saveError = "";
153
+ sel = null;
154
+ if (blueprint) manifest = structuredClone(blueprint);
155
+ });
156
+ }
157
+
158
+ $effect(() => {
159
+ const expiry = sessionExpiresAt;
160
+ tokenExpired = !!expiry && Date.now() >= expiry - 30_000;
161
+ if (!expiry) return;
162
+ const timer = setTimeout(() => { tokenExpired = true; }, Math.max(0, expiry - Date.now() - 30_000));
163
+ return () => clearTimeout(timer);
164
+ });
165
+
166
+ function save(): Promise<boolean> {
167
+ if (saveTask) return saveTask;
168
+ saveTask = (async () => {
169
+ if (dirty) {
170
+ saveState = "saving";
171
+
172
+ const values = JSON.parse(JSON.stringify(draft));
173
+ const body = JSON.parse(JSON.stringify(bodyDraft));
174
+ const p = page, po = post, c = coll, m = menu;
175
+ const creating = newEntry;
176
+ const selection = sel;
177
+ const docSel = sel?.kind === "document" ? sel : null;
178
+ const creatingDoc = docSel !== null && newDocument?.id === docSel.id;
179
+ try {
180
+ if (githubMode) {
181
+ if (!selection) throw new Error("Select content before saving.");
182
+ await saveRemoteSelection(requireGitContext(), manifest, selection, values, body, !!creating || creatingDoc);
183
+ } else {
184
+ const url = docSel
185
+ ? `/rigel/api/local/documents/${encodeURIComponent(docSel.id)}`
186
+ : m ? `/rigel/api/local/menus/${m.slug}` : po && c ? `/rigel/api/local/${c.name}/${encodeURIComponent(po.slug)}` : "/rigel/api/local/save";
187
+ const payload = docSel ? { data: values } : m ? { slug: m.slug, title: values.title, items: values.items } : po ? { frontmatter: values, body } : { slug: p?.slug, values };
188
+ const res = await fetch(url, { method: docSel ? (creatingDoc ? "POST" : "PUT") : m ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
189
+ if (!res.ok) throw new Error((await res.text()) || `Save failed (${res.status})`);
190
+ }
191
+ if (docSel) {
192
+ const list = manifest.documents ?? (manifest.documents = []);
193
+ const existing = list.find((entry) => entry.id === docSel.id);
194
+ if (existing) existing.data = values;
195
+ else list.push({ id: docSel.id, type: docSel.type, slug: docSel.id.slice(docSel.type.length + 1), data: values });
196
+ newDocument = null;
197
+ docBase = JSON.parse(JSON.stringify(values));
198
+ } else if (m) { m.title = values.title; m.items = values.items; }
199
+ else if (po) { po.frontmatter = { slug: po.slug, ...values }; po.body = body; bodyBase = body; }
200
+ else if (p) p.values = values;
201
+ if (creating && po && c) c.posts.push(po);
202
+ newEntry = null;
203
+ dirty = JSON.stringify({ fm: draft, body: bodyDraft }) !== JSON.stringify({ fm: docSel ? docBase : values, body });
204
+ saveState = dirty ? "idle" : "saved";
205
+ saveError = "";
206
+ } catch (error) {
207
+ saveState = "error";
208
+ saveError = error instanceof Error ? error.message : "Could not save changes";
209
+ return false;
210
+ }
211
+ }
212
+ return !dirty;
213
+ })().finally(() => { saveTask = null; });
214
+ return saveTask;
215
+ }
216
+ let settingsOpen = $state(false);
217
+ let userOpen = $state(false);
218
+ let shortcutsOpen = $state(false);
219
+
220
+ let settings = $state(getSettings());
221
+ let settingsError = $state("");
222
+ const userName = $derived(githubMode ? githubAccess?.user.login ?? "GitHub user" : "Local user");
223
+ const userMode = $derived(githubMode ? `${manifest.github?.repo} · ${manifest.github?.branch}` : "Local mode");
224
+ let content: HTMLDivElement;
225
+ let contentWidth = $state(0);
226
+ let resizing = $state(false);
227
+ let resizePointer: { element: HTMLElement; id: number } | null = null;
228
+ const splitMin = $derived(Math.min(0.5, Math.max(0.2, 320 / Math.max(1, contentWidth - 10))));
229
+ const splitMax = $derived(1 - splitMin);
230
+ const previewSplit = $derived(Math.max(splitMin, Math.min(splitMax, settings.userPref.previewSplit)));
231
+
232
+ function setPreviewSplit(value: number) {
233
+ settings.userPref.previewSplit = Math.max(splitMin, Math.min(splitMax, value));
234
+ }
235
+
236
+ function endPreviewResize() {
237
+ const pointer = resizePointer;
238
+ resizePointer = null;
239
+ resizing = false;
240
+ if (pointer?.element.hasPointerCapture(pointer.id)) pointer.element.releasePointerCapture(pointer.id);
241
+ }
242
+
243
+ function startPreviewResize(event: PointerEvent) {
244
+ if (event.button !== 0 || resizePointer) return;
245
+ event.preventDefault();
246
+ const element = event.currentTarget as HTMLElement;
247
+ element.focus();
248
+ element.setPointerCapture(event.pointerId);
249
+ resizePointer = { element, id: event.pointerId };
250
+ resizing = true;
251
+ }
252
+
253
+ function movePreviewResize(event: PointerEvent) {
254
+ if (resizePointer?.id !== event.pointerId) return;
255
+ setPreviewSplit((event.clientX - content.getBoundingClientRect().left - 5) / (contentWidth - 10));
256
+ }
257
+
258
+ function keyPreviewResize(event: KeyboardEvent) {
259
+ const value = { ArrowLeft: previewSplit - 0.02, ArrowRight: previewSplit + 0.02, Home: splitMin, End: splitMax }[event.key];
260
+ if (value === undefined) return;
261
+ event.preventDefault();
262
+ setPreviewSplit(value);
263
+ }
264
+
265
+ $effect(() => {
266
+ previewTarget;
267
+ return endPreviewResize;
268
+ });
269
+
270
+ function eraseBrowserData() {
271
+ actionDialog = {
272
+ title: "Erase browser data?", confirmLabel: "Erase data", danger: true,
273
+ description: "This removes Rigel settings, API keys and unsaved drafts from this browser. Saved repository files will not change. The editor will reload.",
274
+ onconfirm: () => {
275
+ if (saveState === "saving") throw new Error("Wait for the current save to finish.");
276
+ eraseData();
277
+ dirty = false;
278
+ location.reload();
279
+ },
280
+ };
281
+ }
282
+ let sortBy = $state("");
283
+ let sortDir = $state<1 | -1>(1);
284
+
285
+
286
+ function pageSlug(s: Sel | null): string | null {
287
+ return s && s.kind === "page" ? s.slug : null;
288
+ }
289
+ const page = $derived.by(() => {
290
+ const slug = pageSlug(sel);
291
+ return slug ? manifest.pages.find((p) => p.slug === slug) : undefined;
292
+ });
293
+
294
+ function findColl(name: string): ManifestCollection | undefined {
295
+ return manifest.collections?.find((x) => x.name === name);
296
+ }
297
+
298
+ function collName(s: Sel | null): string | null {
299
+ if (!s) return null;
300
+ if (s.kind === "post") return s.collection;
301
+ if (s.kind === "collection") return s.name;
302
+ return null;
303
+ }
304
+
305
+ const coll = $derived.by((): ManifestCollection | undefined => {
306
+ const n = collName(sel);
307
+ return n ? findColl(n) : undefined;
308
+ });
309
+
310
+ function postSlug(s: Sel | null): string | null {
311
+ return s && s.kind === "post" ? s.slug : null;
312
+ }
313
+ const post = $derived.by(() => {
314
+ const slug = postSlug(sel);
315
+ if (!slug || !coll) return undefined;
316
+ if (newEntry && newEntry.collection === coll.name && newEntry.post.slug === slug) return newEntry.post;
317
+ return coll.posts.find((p) => p.slug === slug);
318
+ });
319
+
320
+ const columns = $derived.by((): { key: string; label: string; image?: boolean }[] => {
321
+ if (sel?.kind === "menus") return [{ key: "title", label: "Name" }, { key: "slug", label: "Slug" }];
322
+ if (sel?.kind === "pages") return [{ key: "label", label: "Title" }, { key: "slug", label: "Slug" }, { key: "category", label: "Category" }];
323
+ const source = sel?.kind === "documents" ? activeDocType : coll;
324
+ if (!source) return [];
325
+ const keys = ["title", "slug", ...(source.columns ?? (sel?.kind === "documents" ? [] : source.fields.filter(field => !isSeoField(field.key)).map(field => field.key)))];
326
+ const titleLabel = sel?.kind === "documents" && !source.fields.some(field => field.key === "title") ? source.fields.find(field => field.key === "name")?.label ?? "Title" : "Title";
327
+ const seen = new Set<string>();
328
+ const out: { key: string; label: string; image?: boolean }[] = [];
329
+
330
+ for (const k of keys) {
331
+ if (seen.has(k)) continue;
332
+ seen.add(k);
333
+ const f = source.fields.find((x) => x.key === k);
334
+ out.push({ key: k, label: k === "slug" ? "Slug" : k === "title" ? titleLabel : f?.label ?? k, image: f?.type === "image" });
335
+ }
336
+ return sel?.kind === "documents" ? [...out.filter(column => column.image), ...out.filter(column => !column.image && column.key !== "slug"), ...out.filter(column => column.key === "slug")] : out;
337
+ });
338
+
339
+ const sortedPosts = $derived.by(() => {
340
+ if (!coll) return [];
341
+ const posts = [...coll.posts];
342
+ if (!sortBy) return posts;
343
+ return posts.sort((a, b) => {
344
+ const va = cellValue(a, sortBy);
345
+ const vb = cellValue(b, sortBy);
346
+ return va.localeCompare(vb) * sortDir;
347
+ });
348
+ });
349
+
350
+ const sidebar = $derived(buildSidebar(manifest));
351
+ const pageCategory = $derived(sel?.kind === "pages" ? sel.category : undefined);
352
+ const schema = $derived<Schema | undefined>(manifest.schema);
353
+ const docTypes = $derived(schema?.documents ?? []);
354
+
355
+ const pageDocType = $derived(docTypes.find((type) => !type.shared && !type.singleton));
356
+ const pageDocuments = $derived((manifest.documents ?? []).filter((doc) => docTypes.some(type => type.name === doc.type && !type.shared)));
357
+ function docTypeName(s: Sel | null): string | null {
358
+ return s && (s.kind === "document" || s.kind === "documents") ? s.type : null;
359
+ }
360
+ const activeDocType = $derived(docTypes.find((type) => type.name === docTypeName(sel)));
361
+ const activeDocument = $derived.by(() => {
362
+ const id = sel?.kind === "document" ? sel.id : null;
363
+ return (manifest.documents ?? []).find(doc => doc.id === id);
364
+ });
365
+ const draftDocuments = $derived.by(() => {
366
+ const map: Record<string, Record<string, unknown>> = {};
367
+ for (const doc of manifest.documents ?? []) map[doc.id] = doc.data;
368
+ const current = sel;
369
+ if (current?.kind === "document") map[current.id] = draft;
370
+ return map;
371
+ });
372
+ const previewDocPath = $derived.by(() => {
373
+ if (sel?.kind !== "document" || newDocument?.id === sel.id) return null;
374
+ return previewPath(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1) });
375
+ });
376
+ const standaloneDocument = $derived(sel?.kind === "document" && activeDocType?.shared === true);
377
+ const visualSelection = $derived(sel?.kind === "post" || (sel?.kind === "document" && !standaloneDocument));
378
+
379
+ const sortedPages = $derived.by(() => {
380
+ const rows = [
381
+ ...manifest.pages.filter((p) => !p.shared && (pageCategory === undefined || p.category === pageCategory))
382
+ .map((p) => ({ slug: p.slug, title: p.label, category: p.category, draft: false, target: { kind: "page", slug: p.slug } as Sel })),
383
+ ...(pageCategory === undefined ? pageDocuments
384
+ .map((doc) => ({ slug: doc.slug, title: String(doc.data.title ?? doc.slug), category: undefined, draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel })) : []),
385
+ ];
386
+ const key = sortBy === "slug" || sortBy === "category" ? sortBy : "title";
387
+ return rows.sort((a, b) => String(a[key] ?? "").localeCompare(String(b[key] ?? "")) * sortDir);
388
+ });
389
+ function documentCell(doc: ContentDocument, key: string): string {
390
+ return String(key === "title" ? doc.data.title ?? doc.data.name ?? doc.slug : key === "slug" ? doc.slug : doc.data[key] ?? "");
391
+ }
392
+ const typeDocuments = $derived((manifest.documents ?? []).filter((doc) => doc.type === docTypeName(sel)).sort((a, b) => documentCell(a, sortBy).localeCompare(documentCell(b, sortBy)) * sortDir));
393
+
394
+
395
+ const menu = $derived.by(() => { const slug = sel?.kind === "menu" ? sel.slug : null; return manifest.menus?.entries.find((m) => m.slug === slug); });
396
+ const sortedMenus = $derived([...(manifest.menus?.entries ?? [])].sort((a, b) => (sortBy === "slug" ? a.slug.localeCompare(b.slug) : a.title.localeCompare(b.title)) * sortDir));
397
+ const listTitle = $derived(sel?.kind === "menus" ? "All Menus" : sel?.kind === "documents" ? activeDocType?.title ?? "Documents" : sel?.kind === "pages" ? `All ${pageCategory ?? "pages"}` : coll?.listTitle?.trim() || `All ${coll?.label ?? "entries"}`);
398
+ const listRows = $derived(sel?.kind === "menus"
399
+ ? sortedMenus.map((m) => ({ slug: m.slug, cells: { slug: m.slug, title: m.title } as Record<string, string>, draft: false, target: { kind: "menu", slug: m.slug } as Sel }))
400
+ : sel?.kind === "documents"
401
+ ? typeDocuments.map((doc) => ({ slug: doc.slug, cells: Object.fromEntries(columns.map(column => [column.key, documentCell(doc, column.key)])), draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel }))
402
+ : sel?.kind === "pages"
403
+ ? sortedPages.map((p) => ({ slug: p.slug, cells: { label: p.title, slug: p.slug, category: p.category ?? "—" } as Record<string, string>, draft: p.draft, target: p.target }))
404
+ : sortedPosts.map((p) => ({ slug: p.slug, cells: Object.fromEntries(columns.map((c) => [c.key, cellValue(p, c.key)])), draft: Boolean(p.frontmatter.draft), target: { kind: "post", collection: coll!.name, slug: p.slug } as Sel })));
405
+ const titleColumn = $derived(sel?.kind === "pages" ? "label" : "title");
406
+
407
+ function goWelcome() {
408
+ void select(null);
409
+ }
410
+
411
+ $effect(() => {
412
+ void loadBlueprint();
413
+ });
414
+
415
+ $effect(() => {
416
+ try {
417
+ setSettings(settings);
418
+ settingsError = "";
419
+ } catch {
420
+ settingsError = "Settings could not be saved in this browser. Allow browser storage and try again.";
421
+ }
422
+ });
423
+
424
+ $effect(() => {
425
+ const theme = settings.theme;
426
+ const media = matchMedia("(prefers-color-scheme: dark)");
427
+ const apply = () => {
428
+ document.documentElement.dataset.theme =
429
+ theme === "dark" || (theme === "system" && media.matches) ? "dark" : "light";
430
+ };
431
+ apply();
432
+ media.addEventListener("change", apply);
433
+ return () => media.removeEventListener("change", apply);
434
+ });
435
+
436
+ $effect(() => {
437
+ document.documentElement.dataset.contrast = settings.highContrast ? "high" : "normal";
438
+ });
439
+
440
+ function sameSel(a: Sel, b: Sel): boolean {
441
+ if (a.kind !== b.kind) return false;
442
+ if (a.kind === "menus" || a.kind === "media") return true;
443
+ if (a.kind === "menu" && b.kind === "menu") return a.slug === b.slug;
444
+ if (a.kind === "pages" && b.kind === "pages") return a.category === b.category;
445
+ if (a.kind === "page" && b.kind === "page") return a.slug === b.slug;
446
+ if (a.kind === "documents" && b.kind === "documents") return a.type === b.type;
447
+ if (a.kind === "document" && b.kind === "document") return a.type === b.type && a.id === b.id;
448
+ if (a.kind === "collection" && b.kind === "collection") return a.name === b.name;
449
+ if (a.kind === "post" && b.kind === "post")
450
+ return a.collection === b.collection && a.slug === b.slug;
451
+ return false;
452
+ }
453
+
454
+ function navActive(target: Sel): boolean {
455
+ if (!sel) return false;
456
+ if (sameSel(sel, target)) return true;
457
+ if (target.kind === "collection") return sel.kind === "post" && sel.collection === target.name;
458
+ if (target.kind === "menus") return sel.kind === "menu";
459
+ if (target.kind === "documents") return sel.kind === "document" && sel.type === target.type;
460
+ if (target.kind === "pages") {
461
+ if (sel.kind === "page") return !page?.shared && (target.category ? page?.category === target.category : !page?.category);
462
+ const typeName = sel.kind === "document" ? sel.type : null;
463
+ return !target.category && docTypes.some(type => type.name === typeName && !type.shared);
464
+ }
465
+ return false;
466
+ }
467
+
468
+ function requestNavigation(action: () => void) {
469
+ if (dirty || saveState === "saving") pendingNavigation = action;
470
+ else action();
471
+ }
472
+
473
+ async function saveAndLeave() {
474
+ if (!(await save())) return;
475
+ const action = pendingNavigation;
476
+ pendingNavigation = null;
477
+ action?.();
478
+ }
479
+
480
+ function discardAndLeave() {
481
+ const action = pendingNavigation;
482
+ pendingNavigation = null;
483
+ const fallback: Sel | null = newEntry ? { kind: "collection", name: newEntry.collection } : newDocument ? { kind: "pages" } : sel;
484
+ newEntry = null;
485
+ newDocument = null;
486
+ openSelection(fallback);
487
+ action?.();
488
+ }
489
+
490
+ function select(next: Sel | null) {
491
+ if (sel && next && sameSel(sel, next)) return;
492
+ requestNavigation(() => openSelection(next));
493
+ }
494
+
495
+ function openSelection(next: Sel | null) {
496
+ sel = next;
497
+ saveError = "";
498
+ sortBy = "";
499
+ sortDir = 1;
500
+ if (next?.kind === "menu") {
501
+ const m = menu;
502
+ if (!m) return;
503
+ draft = JSON.parse(JSON.stringify({ title: m.title, items: m.items }));
504
+ bodyDraft = "";
505
+ bodyBase = "";
506
+ } else if (next?.kind === "page") {
507
+ const p = manifest.pages.find((x) => x.slug === next.slug);
508
+ if (!p) return;
509
+ draft = JSON.parse(JSON.stringify(p.values));
510
+ bodyDraft = "";
511
+ bodyBase = "";
512
+ } else if (next?.kind === "document") {
513
+ if (newDocument?.id === next.id) docBase = null;
514
+ else {
515
+ const doc = (manifest.documents ?? []).find((entry) => entry.id === next.id);
516
+ if (!doc) return;
517
+ draft = JSON.parse(JSON.stringify(doc.data));
518
+ docBase = JSON.parse(JSON.stringify(doc.data));
519
+ }
520
+ bodyDraft = "";
521
+ bodyBase = "";
522
+ } else if (next?.kind === "post") {
523
+ const po = post;
524
+ if (!po) return;
525
+ const fm: Record<string, unknown> = { ...po.frontmatter };
526
+ delete fm.slug;
527
+ draft = JSON.parse(JSON.stringify(fm));
528
+ bodyDraft = po.body;
529
+ bodyBase = po.body;
530
+ }
531
+ dirty = newEntry !== null || (newDocument !== null && next?.kind === "document");
532
+ saveState = "idle";
533
+
534
+ }
535
+
536
+ function sort(key: string) {
537
+ if (sortBy === key) sortDir = sortDir === 1 ? -1 : 1;
538
+ else {
539
+ sortBy = key;
540
+ sortDir = 1;
541
+ }
542
+ }
543
+
544
+ function touch() {
545
+ if (sel?.kind === "document") {
546
+ dirty = JSON.stringify(draft) !== JSON.stringify(docBase);
547
+ if (saveState !== "saving") saveState = "idle";
548
+ return;
549
+ }
550
+ const ref =
551
+ sel?.kind === "menu" && menu ? { title: menu.title, items: menu.items } : sel?.kind === "post" && post
552
+ ? Object.fromEntries(
553
+ Object.entries(post.frontmatter).filter(([k]) => k !== "slug"),
554
+ )
555
+ : page?.values;
556
+ dirty =
557
+ newEntry !== null ||
558
+ JSON.stringify({ fm: draft, body: bodyDraft }) !==
559
+ JSON.stringify({ fm: ref, body: bodyBase });
560
+ if (saveState !== "saving") saveState = "idle";
561
+ }
562
+
563
+ function cellValue(p: { slug: string; frontmatter: Record<string, unknown> }, key: string): string {
564
+ const v = key === "slug" ? p.slug : p.frontmatter[key];
565
+ const field = coll?.fields.find(field => field.key === key);
566
+ if (field?.type === "reference") return referenceLabel(v, referenceOptions(field, manifest.documents)) || "—";
567
+ if (v === true) return "✓";
568
+ if (v === false || v == null) return "—";
569
+ if (Array.isArray(v)) return v.join(", ");
570
+ return String(v);
571
+ }
572
+
573
+ function newPage(type = pageDocType) {
574
+ if (!type || !schema || type.creatable === false || type.singleton) return;
575
+ actionDialog = {
576
+ title: `Add ${type.title}`, description: "Enter a name to start a draft from the template. Nothing is written until you press Save.", fieldLabel: type.shared ? "Name" : "Page name", confirmLabel: "Create draft",
577
+ onconfirm: (title) => {
578
+ const slug = slugify(title);
579
+ if (!slug) throw new Error("Enter a title containing letters or numbers.");
580
+ const taken = [...(manifest.documents ?? []).filter(doc => !type.shared || doc.type === type.name).map((doc) => doc.slug), ...(!type.shared ? manifest.pages.map((page) => page.slug) : [])];
581
+ if (taken.includes(slug)) throw new Error(`The slug "${slug}" is already used by another document or page.`);
582
+ const id = `${type.name}:${slug}`;
583
+ const data = newDocumentFromTemplate(schema, type.name, title, slug);
584
+ newDocument = { type: type.name, id, slug };
585
+ draft = JSON.parse(JSON.stringify(data));
586
+ docBase = null;
587
+ openSelection({ kind: "document", type: type.name, id });
588
+ },
589
+ };
590
+ }
591
+
592
+ function newPost(c: ManifestCollection) {
593
+ actionDialog = {
594
+ 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",
595
+ onconfirm: (title) => {
596
+ const slug = slugify(title);
597
+ if (!slug) throw new Error("Enter a title containing letters or numbers.");
598
+ if (c.posts.some((p) => p.slug === slug)) throw new Error(`A post with slug "${slug}" already exists.`);
599
+ newEntry = { collection: c.name, post: { slug, frontmatter: { title, date: new Date().toISOString().slice(0, 10), author: "", draft: true, ...Object.fromEntries(c.fields.filter(field => field.type === "reference" && field.multiple).map(field => [field.key, []])) }, body: emptyBody() } };
600
+ openSelection({ kind: "post", collection: c.name, slug });
601
+ },
602
+ };
603
+ }
604
+
605
+ function deletePost() {
606
+ const current = post, collection = coll, unsaved = newEntry !== null;
607
+ if (!current || !collection) return;
608
+ actionDialog = {
609
+ title: `Delete “${String(current.frontmatter.title ?? current.slug)}”?`, confirmLabel: "Delete", danger: true,
610
+ 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.`,
611
+ onconfirm: async () => {
612
+ if (!unsaved) {
613
+ if (githubMode) await deleteRemotePost(requireGitContext(), manifest, collection.name, current.slug);
614
+ else {
615
+ const res = await fetch(`/rigel/api/local/${collection.name}/${encodeURIComponent(current.slug)}`, { method: "DELETE" });
616
+ if (!res.ok) throw new Error((await res.text()) || `Delete failed (${res.status})`);
617
+ }
618
+ collection.posts = collection.posts.filter((p) => p.slug !== current.slug);
619
+ }
620
+ newEntry = null;
621
+ openSelection({ kind: "collection", name: collection.name });
622
+ },
623
+ };
624
+ }
625
+
626
+
627
+ </script>
628
+
629
+ <svelte:window
630
+ onresize={endPreviewResize}
631
+ onblur={endPreviewResize}
632
+ oncontextmenu={(event) => { if (!settings.devMode) event.preventDefault(); }}
633
+ onbeforeunload={(event) => { if (dirty || saveState === "saving") { event.preventDefault(); event.returnValue = ""; } }}
634
+ onclick={(e) => {
635
+ if (!(e.target instanceof Element) || !e.target.closest('.user-menu-wrap')) userOpen = false;
636
+ }}
637
+ onkeydown={(e) => {
638
+ if (e.key === "Escape") endPreviewResize();
639
+ if (e.key === "Escape" && userOpen) {
640
+ userOpen = false;
641
+ document.querySelector<HTMLButtonElement>('.user-btn')?.focus();
642
+ }
643
+ }}
644
+ />
645
+ {#if !ready || (githubMode && !connected)}
646
+ <div class="rigel connection-shell">
647
+ <main id="github-connection" class="connection-card" aria-busy={connectionBusy}>
648
+ <img src={logo} alt="" width="64" height="64" />
649
+ <h1>{!ready ? "RigelCMS" : "Connect GitHub"}</h1>
650
+ {#if !ready}
651
+ {#if loadError}<p class="connection-error" role="alert">{loadError}</p><button class="primary" onclick={loadBlueprint}>Retry loading</button>
652
+ {:else}<p role="status">Loading the editor configuration…</p>{/if}
653
+ {:else if manifest.github}
654
+ <p>Content repository <strong>{manifest.github.repo}</strong></p>
655
+ {#if githubAccess}
656
+ <p>Signed in as <strong>{githubAccess.user.login}</strong>.</p>
657
+ {#if githubAccess.status === "read-only"}
658
+ <p>Editing needs write access from both your account and the GitHub App. Ask an administrator to check repository permissions and the App installation, then check again.</p>
659
+ {:else if githubAccess.status === "installation-required"}
660
+ <p>Install or configure the Rigel GitHub App for this repository. On GitHub, choose your account or organization, select <strong>Only select repositories</strong>, and include <strong>{manifest.github.repo}</strong>.</p>
661
+ <p>If your organization requires approval, its administrator must approve the installation first.</p>
662
+ {:else}
663
+ <p role="status">{connectionBusy ? "Loading the shared drafts workspace…" : "Repository access verified. Retry loading the drafts workspace."}</p>
664
+ {/if}
665
+ {#if githubAccess.status !== "ready"}
666
+ <a id="github-install" class="primary" href={githubAccess.installUrl} target="_blank" rel="noopener noreferrer">Install / configure GitHub access</a>
667
+ <p>Keep this tab open. Return here after configuring access on GitHub.</p>
668
+ {/if}
669
+ {:else}
670
+ <p>Sign in, then choose which repositories the Rigel GitHub App may access. The editor opens only after GitHub confirms write access to this repository.</p>
671
+ {/if}
672
+ <div class="connection-actions">
673
+ {#if sessionExpiresAt && !tokenExpired}
674
+ <button id="github-check-access" class="primary" disabled={connectionBusy} onclick={recheckGitHub}>{connectionBusy ? "Checking access…" : githubAccess?.status === "ready" ? "Retry loading drafts" : "Check repository access"}</button>
675
+ {/if}
676
+ <button id="github-sign-in" class:primary={!sessionExpiresAt || tokenExpired} class:outlined={sessionExpiresAt > 0 && !tokenExpired} disabled={connectionBusy} onclick={connectGitHub}>{connectionBusy && !sessionExpiresAt ? "Signing in…" : sessionExpiresAt ? "Sign in again" : "Sign in with GitHub"}</button>
677
+ </div>
678
+ <p class="connection-note">Save writes to <strong>{manifest.github.branch}</strong>, not <strong>{manifest.github.publishBranch}</strong>. Publishing is a separate pull request.</p>
679
+ {/if}
680
+ {#if connectionError}<p class="connection-error" role="alert">{connectionError}</p>{/if}
681
+ </main>
682
+ </div>
683
+ {:else}
684
+ <div class="rigel" class:sidebar-collapsed={settings.userPref.sidebarCollapsed}>
685
+ <aside id="rigel-sidebar">
686
+ <a class="brand" href="/rigel/" role="button" aria-label="Welcome" aria-describedby="rigel-beta" onclick={(event) => { event.preventDefault(); goWelcome(); }}>
687
+ <img src={logo} alt="" width="32" height="32" />
688
+ <span>RigelCMS</span>
689
+ <span id="rigel-beta" class="badge">Beta</span>
690
+ </a>
691
+ <TopBar {manifest} {sel} onselect={select} />
692
+
693
+ <nav id="rigel-sidebar-navigation" class="rail-scroll" aria-label="Content">
694
+ {#each sidebar as node}
695
+ {#if node.kind === "heading"}
696
+ <h3 class="sidebar-heading">{node.label}</h3>
697
+ {:else if node.kind === "separator"}
698
+ <hr class="sidebar-separator" />
699
+ {:else}
700
+ <button
701
+ id={node.target.kind === "media" ? "nav-media" : node.target.kind === "menus" ? "nav-menus" : node.target.kind === "pages" && !node.target.category ? "nav-pages" : undefined}
702
+ data-nav={navigationKey(node.target)}
703
+ data-collection={node.target.kind === "collection" ? node.target.name : undefined}
704
+ data-category={node.target.kind === "pages" ? node.target.category : undefined}
705
+ data-doctype={node.target.kind === "documents" ? node.target.type : undefined}
706
+ data-document={node.target.kind === "document" ? node.target.id : undefined}
707
+ data-page={node.target.kind === "page" ? node.target.slug : undefined}
708
+ aria-label={node.label}
709
+ title={node.label}
710
+ class:active={navActive(node.target)}
711
+ aria-current={navActive(node.target) ? "page" : undefined}
712
+ onclick={() => select(node.target)}
713
+ >
714
+ <SidebarIcon name={node.icon ?? ({ pages: "files", page: "file-text", collection: "newspaper", documents: "squares-four", document: "file-text", menus: "list", media: "images" }[node.target.kind])} /><span class="sidebar-label">{node.label}</span>
715
+ </button>
716
+ {/if}
717
+ {/each}
718
+ </nav>
719
+
720
+ <button class="sidebar-toggle" aria-expanded={!settings.userPref.sidebarCollapsed} aria-controls="rigel-sidebar-navigation" aria-label={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} title={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} onclick={() => { settings.userPref.sidebarCollapsed = !settings.userPref.sidebarCollapsed; }}>
721
+ <SidebarIcon name="sidebar-simple" /><span class="sidebar-label">Collapse sidebar</span>
722
+ </button>
723
+ <div class="aside-foot">
724
+ <div class="user-menu-wrap">
725
+ <button class="user-btn" aria-label={githubMode ? "GitHub user menu" : "Local user menu"} title={githubMode ? "GitHub user menu" : "Local user menu"} aria-haspopup="menu" aria-expanded={userOpen} onclick={() => (userOpen = !userOpen)}>
726
+ <span class="avatar"><Icon name="user" size={20} /></span>
727
+ <span class="user-details"><span class="uname">{userName}</span><small>{userMode}</small></span>
728
+ <span class="user-menu-icon"><Icon name="dotsThreeOutlineVertical" size={18} /></span>
729
+ </button>
730
+ {#if userOpen}
731
+ <div class="user-menu" role="menu">
732
+ <div class="user-menu-head">
733
+ <span class="avatar"><Icon name="user" size={20} /></span>
734
+ <div class="user-details"><strong>{userName}</strong><small>{userMode}</small></div>
735
+ </div>
736
+ <div class="divider"></div>
737
+ <a role="menuitem" href="/" target="_blank" rel="noopener">
738
+ <Icon name="external" size={13} />
739
+ Live Site
740
+ </a>
741
+ <div class="divider"></div>
742
+ <button role="menuitem" onclick={() => { userOpen = false; requestNavigation(() => { settingsOpen = true; }); }}>
743
+ <Icon name="gear" size={13} />
744
+ Settings
745
+ </button>
746
+ <button role="menuitem" onclick={() => { userOpen = false; requestNavigation(() => { shortcutsOpen = true; }); }}>
747
+ <Icon name="keyboard" size={13} />
748
+ Keyboard Shortcuts
749
+ </button>
750
+ <div class="divider"></div>
751
+ {#if githubMode}
752
+ <a role="menuitem" href={githubAccess?.installUrl} target="_blank" rel="noopener noreferrer">Configure GitHub access</a>
753
+ <button role="menuitem" class="github-reconnect" disabled={connectionBusy || saveState === "saving"} onclick={() => { userOpen = false; void connectGitHub(); }}>Reconnect GitHub</button>
754
+ {/if}
755
+ <button id={githubMode ? "github-sign-out" : undefined} role="menuitem" class:muted={!githubMode} disabled={!githubMode || saveState === "saving" || connectionBusy} title={githubMode ? "Sign out of this editor" : "Local mode"} onclick={signOut}>
756
+ <Icon name="signOut" size={13} />
757
+ Sign Out
758
+ {#if !githubMode}<span class="soon">backend: github</span>{/if}
759
+ </button>
760
+ </div>
761
+ {/if}
762
+ </div>
763
+ </div>
764
+ </aside>
765
+
766
+ <div class="content" bind:this={content} bind:clientWidth={contentWidth} class:resizing-preview={resizing} class:with-preview={manifest.preview && previewTarget !== null} class:visual={visualSelection} style={`--editor-split: ${previewSplit}fr; --preview-split: ${1 - previewSplit}fr`}>
767
+ <main id="rigel-editor-pane" class:wide={sel?.kind === "collection" || sel?.kind === "pages" || sel?.kind === "documents" || sel?.kind === "document" || sel?.kind === "post" || sel?.kind === "menus" || sel?.kind === "menu" || sel?.kind === "media"} class:visual-pane={visualSelection}>
768
+ {#if githubMode && (tokenExpired || githubAccess?.status !== "ready" || connectionError)}
769
+ <div class="github-session" role="status">
770
+ <p>{connectionError || (tokenExpired ? "Your GitHub session expired. Your unsaved edits are still here." : "Repository access changed. Reconnect GitHub before saving.")}</p>
771
+ <button class="primary" disabled={connectionBusy || saveState === "saving"} onclick={connectGitHub}>{connectionBusy ? "Connecting…" : "Reconnect GitHub"}</button>
772
+ </div>
773
+ {/if}
774
+ {#if settingsError}<p role="alert">{settingsError}</p>{/if}
775
+ {#if ready && sel && sel.kind !== "media"}
776
+ {#if sel.kind === "page" || sel.kind === "post" || sel.kind === "menu" || sel.kind === "document"}
777
+ <div class="editor-actions">
778
+ {#if sel.kind === "page" && page}
779
+ <p class="back"><button onclick={() => select(page.shared ? null : { kind: "pages", category: page.category })}>← Back to {page.shared ? "Welcome" : page.category ?? "Pages"}</button></p>
780
+ {:else if sel.kind === "document"}
781
+ {@const backSel: Sel | null = activeDocType?.singleton ? null : sel.type === pageDocType?.name ? { kind: "pages" } : { kind: "documents", type: sel.type }}
782
+ <p class="back"><button onclick={() => select(backSel)}>← Back to {activeDocType?.singleton ? "Welcome" : sel.type === pageDocType?.name ? "Pages" : activeDocType?.title ?? "Documents"}</button></p>
783
+ {:else if sel.kind === "post" && coll}
784
+ <p class="back"><button onclick={() => select({ kind: "collection", name: coll.name })}>← Back to {coll.label}</button></p>
785
+ {:else if sel.kind === "menu"}
786
+ <p class="back"><button onclick={() => select({ kind: "menus" })}>← Back to Menus</button></p>
787
+ {/if}
788
+ <div class="save-actions">
789
+ <span class="save-status" role="status" class:dirty={dirty && saveState !== "saving"} class:saving={saveState === "saving"} class:saved={!dirty && saveState === "saved"} class:error={saveState === "error"}>
790
+ {saveState === "saving" ? "Saving…" : saveState === "error" ? "Save failed" : dirty ? "Unsaved" : saveState === "saved" ? "Saved" : ""}
791
+ </span>
792
+ <button class="primary save-button" disabled={!dirty || saveState === "saving"} onclick={() => save()}>{saveState === "saving" ? "Saving…" : "Save"}</button>
793
+ </div>
794
+ </div>
795
+ {/if}
796
+ <div class="titlebar">
797
+ <h2>{sel.kind === "page" ? page?.label : sel.kind === "menu" ? String(draft.title || menu?.slug || "") : sel.kind === "post" ? String(draft.title || post?.slug || "") : sel.kind === "document" ? activeDocType?.singleton ? activeDocType.title : String(draft.title || draft.name || activeDocument?.slug || sel.id) : listTitle}</h2>
798
+ {#if (sel?.kind === "collection" && coll) || (sel?.kind === "pages" && pageDocType?.creatable !== false && pageDocType) || (sel?.kind === "documents" && activeDocType && activeDocType.creatable !== false && !activeDocType.singleton)}
799
+ <button class="primary" onclick={() => (sel?.kind === "collection" ? newPost(coll!) : newPage(sel?.kind === "documents" ? activeDocType : pageDocType))}>Add new</button>
800
+ {/if}
801
+ {#if sel?.kind === "post" && post}
802
+ <button class="icon-btn" title="Delete post" disabled={saveState === "saving"} onclick={deletePost}>
803
+ <Icon name="trash" size={15} />
804
+ </button>
805
+ {/if}
806
+ </div>
807
+ {/if}
808
+ {#if saveError}
809
+ <div class="save-toast" role="alert">
810
+ <div class="save-toast-content">
811
+ <strong>Could not save changes</strong>
812
+ <p>{saveError}</p>
813
+ <button class="outlined" disabled={saveState === "saving"} onclick={() => save()}>Retry save</button>
814
+ {#if githubMode}<button class="outlined github-reconnect" disabled={connectionBusy || saveState === "saving"} onclick={connectGitHub}>Reconnect GitHub</button>{/if}
815
+ </div>
816
+ <button type="button" class="icon-btn" aria-label="Dismiss save error" onclick={() => { saveError = ""; }}><Icon name="x" size={18} /></button>
817
+ </div>
818
+ {/if}
819
+ {#if !ready}
820
+ <p>Loading…</p>
821
+
822
+ {:else if sel?.kind === "media"}
823
+ <ImagePicker inline />
824
+ {:else if sel?.kind === "pages" || sel?.kind === "documents" || sel?.kind === "menus" || (sel?.kind === "collection" && coll)}
825
+ <div class="table-card">
826
+ <table>
827
+ <thead>
828
+ <tr>
829
+ {#each columns as col (col.key)}
830
+ {#if col.image && sel?.kind === "documents"}
831
+ <th class="thumbnail-column" aria-label={col.label}></th>
832
+ {:else}
833
+ <th aria-sort={sortBy === col.key || (!sortBy && sel?.kind === "pages" && col.key === "label") ? (sortDir === 1 ? "ascending" : "descending") : "none"}>
834
+ <button class="table-sort" onclick={() => sort(col.key)}>{col.label} {sortBy === col.key ? (sortDir === 1 ? "↑" : "↓") : ""}</button>
835
+ </th>
836
+ {/if}
837
+ {/each}
838
+ </tr>
839
+ </thead>
840
+ <tbody>
841
+ {#each listRows as row (navigationKey(row.target))}
842
+ <tr onclick={() => select(row.target)}>
843
+ {#each columns as col (col.key)}
844
+ <td>
845
+ {#if col.key === titleColumn}
846
+ <button class="table-link" onclick={() => select(row.target)}>{row.cells[col.key]}</button>
847
+ {#if row.draft}<span class="badge">draft</span>{/if}
848
+ {:else if col.image}
849
+ {#if row.cells[col.key]}
850
+ <img class="table-thumbnail" src={row.cells[col.key]} alt="" width="32" height="32" loading="lazy" decoding="async" />
851
+ {:else if col.key === "photo"}
852
+ <span class="table-thumbnail table-avatar-placeholder" aria-hidden="true"><Icon name="user" size={18} /></span>
853
+ {/if}
854
+ {:else}
855
+ {row.cells[col.key]}
856
+ {/if}
857
+ </td>
858
+ {/each}
859
+ </tr>
860
+ {:else}
861
+ <tr><td colspan={columns.length}>No entries</td></tr>
862
+ {/each}
863
+ </tbody>
864
+ </table>
865
+ </div>
866
+
867
+ {:else if sel?.kind === "document" && schema && activeDocType}
868
+ {#if standaloneDocument}
869
+ <div class="document-form">
870
+ <SchemaPanel {schema} docType={activeDocType} values={draft} docId={sel.id} selected={null} creating={newDocument?.id === sel.id} pending={[]} documents={manifest.documents ?? []} focusToken={0} devMode={settings.devMode} onchange={(next) => { draft = next; touch(); }} onselect={() => {}} oneditdocument={(id) => { const doc = manifest.documents?.find(doc => doc.id === id); if (doc) select({ kind: "document", type: doc.type, id }); }} />
871
+ </div>
872
+ {:else}
873
+ <VisualEditor
874
+ docId={sel.id}
875
+ {schema}
876
+ docType={activeDocType}
877
+ values={draft}
878
+ documents={draftDocuments}
879
+ previewPath={previewDocPath}
880
+ creating={newDocument?.id === sel.id}
881
+ devMode={settings.devMode}
882
+ reloadToken={documentReload}
883
+ oneditdocument={(id) => {
884
+ const doc = manifest.documents?.find((entry) => entry.id === id);
885
+ if (doc) select({ kind: "document", type: doc.type, id: doc.id });
886
+ }}
887
+ onchange={(next: Record<string, unknown>) => { draft = next; touch(); }}
888
+ />
889
+ {/if}
890
+
891
+ {:else if sel?.kind === "menu" && menu && manifest.menus}
892
+ <section class="menu-details">
893
+ <label><span>Slug</span><input type="text" value={menu.slug} readonly /></label>
894
+ <label><span>Name</span><input type="text" value={String(draft.title ?? "")} oninput={(e) => { draft.title = e.currentTarget.value; touch(); }} /></label>
895
+ </section>
896
+ <MenuEditor items={draft.items as MenuItem[]} maxDepth={manifest.menus.maxDepth} oninput={(items) => { draft.items = items; touch(); }} />
897
+
898
+ {:else if sel?.kind === "page" && page}
899
+ {#each page.groups as group (group.id)}
900
+ <section>
901
+ <h3>{group.label}</h3>
902
+ {#each group.fields as field (field.key)}
903
+ <Field
904
+ {field}
905
+ devMode={settings.devMode}
906
+ value={(draft[group.id] as Record<string, unknown> | undefined)?.[field.key]}
907
+ oninput={(v) => {
908
+ draft[group.id] = { ...((draft[group.id] as object) ?? {}), [field.key]: v };
909
+ touch();
910
+ }}
911
+ />
912
+ {/each}
913
+ </section>
914
+ {/each}
915
+
916
+
917
+ {:else if sel?.kind === "post" && coll && post}
918
+ {#key `${coll.name}/${post.slug}`}
919
+ <VisualEditor
920
+ docId={`${coll.name}:${post.slug}`}
921
+ values={draft}
922
+ previewValues={collectionPreviewValues(coll.fields, draft, manifest.documents ?? [])}
923
+ body={bodyDraft}
924
+ bodyInline={settings.userPref.editorExperience === "inline"}
925
+ onbody={(value) => { bodyDraft = value; touch(); }}
926
+ previewPath={manifest.preview ? previewPath(manifest.preview, sel, !!newEntry) : null}
927
+ livePath={manifest.preview ? previewPath(manifest.preview, sel) : null}
928
+ creating={!!newEntry}
929
+ devMode={settings.devMode}
930
+ onchange={(next) => { draft = next; touch(); }}
931
+ >
932
+ {#snippet properties(selected, focusToken, bodyInline, oneditbody)}
933
+ <CollectionPanel fields={coll.fields} values={draft} documents={manifest.documents ?? []} body={bodyDraft as ArticleBody} {bodyInline} {oneditbody} {selected} {focusToken} devMode={settings.devMode} onchange={(key, value) => { draft[key] = value; touch(); }} onbody={(value) => { bodyDraft = value; touch(); }} />
934
+ {/snippet}
935
+ </VisualEditor>
936
+ {/key}
937
+
938
+ {:else}
939
+ <div class="welcome">
940
+ <img src={logo} alt="" width="64" height="64" />
941
+ <h2>Welcome to RigelCMS</h2>
942
+ <p>The best CMS in the whole space.</p>
943
+ <p class="welcome-hint">Select a page or collection from the sidebar to start editing.</p>
944
+ </div>
945
+ {/if}
946
+ {#if settings.devMode && (sel?.kind === "page" || sel?.kind === "post" || sel?.kind === "menu" || sel?.kind === "document")}
947
+ <section class="md-preview" class:article-state={sel.kind === "post"}>
948
+ <h3>Current draft</h3>
949
+ <pre>{JSON.stringify(sel.kind === "post" ? { frontmatter: draft, body: bodyDraft } : draft, null, 2)}</pre>
950
+ </section>
951
+ {/if}
952
+ </main>
953
+ {#if manifest.preview && previewTarget}
954
+ <div class="preview-divider" role="separator" tabindex="0" aria-label="Editor and preview width" aria-orientation="vertical" aria-controls="rigel-editor-pane" aria-valuemin={Math.round(splitMin * 100)} aria-valuemax={Math.round(splitMax * 100)} aria-valuenow={Math.round(previewSplit * 100)} aria-valuetext={`${Math.round(previewSplit * 100)}% editor width`} onpointerdown={startPreviewResize} onpointermove={movePreviewResize} onpointerup={endPreviewResize} onpointercancel={endPreviewResize} onlostpointercapture={endPreviewResize} onkeydown={keyPreviewResize}></div>
955
+ <SitePreview target={previewTarget} config={manifest.preview} isNew={!!newEntry} values={draft} body={bodyDraft} devMode={settings.devMode} />
956
+ {/if}
957
+ </div>
958
+ </div>
959
+ {/if}
960
+
961
+ {#if settingsOpen}
962
+ <Settings bind:s={settings} error={settingsError} saving={saveState === "saving"} onerase={eraseBrowserData} onclose={() => (settingsOpen = false)} />
963
+ {/if}
964
+ {#if shortcutsOpen}
965
+ <Shortcuts onclose={() => (shortcutsOpen = false)} />
966
+ {/if}
967
+ {#if pendingNavigation}
968
+ <UnsavedChanges busy={saveState === "saving"} error={saveError} onsave={saveAndLeave} ondiscard={discardAndLeave} oncancel={() => { pendingNavigation = null; }} />
969
+ {/if}
970
+ {#if actionDialog}
971
+ <ActionDialog {...actionDialog} onclose={() => { actionDialog = null; }} />
972
+ {/if}