@rigel-cms/rigel 0.0.0-stage → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +203 -0
- package/README.md +8 -2
- package/package.json +78 -4
- package/src/article-body.ts +60 -0
- package/src/article-render.ts +87 -0
- package/src/config.ts +97 -0
- package/src/content.ts +5 -0
- package/src/editor/App.svelte +972 -0
- package/src/editor/assets/rigelcms-icon.png +0 -0
- package/src/editor/assets/sidebar/LICENSE +21 -0
- package/src/editor/assets/sidebar/book-open.svg +1 -0
- package/src/editor/assets/sidebar/calendar.svg +1 -0
- package/src/editor/assets/sidebar/chart-bar.svg +1 -0
- package/src/editor/assets/sidebar/file-text.svg +1 -0
- package/src/editor/assets/sidebar/files.svg +1 -0
- package/src/editor/assets/sidebar/folder.svg +1 -0
- package/src/editor/assets/sidebar/folders.svg +1 -0
- package/src/editor/assets/sidebar/gear.svg +1 -0
- package/src/editor/assets/sidebar/globe.svg +1 -0
- package/src/editor/assets/sidebar/heart.svg +1 -0
- package/src/editor/assets/sidebar/house.svg +1 -0
- package/src/editor/assets/sidebar/images.svg +1 -0
- package/src/editor/assets/sidebar/layout.svg +1 -0
- package/src/editor/assets/sidebar/list.svg +1 -0
- package/src/editor/assets/sidebar/magnifying-glass.svg +1 -0
- package/src/editor/assets/sidebar/megaphone.svg +1 -0
- package/src/editor/assets/sidebar/newspaper.svg +1 -0
- package/src/editor/assets/sidebar/pencil-simple.svg +1 -0
- package/src/editor/assets/sidebar/shopping-bag.svg +1 -0
- package/src/editor/assets/sidebar/sidebar-simple.svg +1 -0
- package/src/editor/assets/sidebar/square-half.svg +1 -0
- package/src/editor/assets/sidebar/squares-four.svg +1 -0
- package/src/editor/assets/sidebar/tag.svg +1 -0
- package/src/editor/assets/sidebar/user.svg +1 -0
- package/src/editor/assets/sidebar/users.svg +1 -0
- package/src/editor/blocks/bulleted-list.ts +2 -0
- package/src/editor/blocks/code.ts +1 -0
- package/src/editor/blocks/divider.ts +14 -0
- package/src/editor/blocks/h1.ts +2 -0
- package/src/editor/blocks/h2.ts +2 -0
- package/src/editor/blocks/h3.ts +2 -0
- package/src/editor/blocks/h4.ts +2 -0
- package/src/editor/blocks/h5.ts +2 -0
- package/src/editor/blocks/h6.ts +2 -0
- package/src/editor/blocks/image.ts +34 -0
- package/src/editor/blocks/index.ts +40 -0
- package/src/editor/blocks/numbered-list.ts +2 -0
- package/src/editor/blocks/paragraph.ts +1 -0
- package/src/editor/blocks/quote.ts +1 -0
- package/src/editor/blocks/table.ts +5 -0
- package/src/editor/lib/ActionDialog.svelte +51 -0
- package/src/editor/lib/BlockEditor.svelte +614 -0
- package/src/editor/lib/CollectionPanel.svelte +103 -0
- package/src/editor/lib/DatePicker.svelte +106 -0
- package/src/editor/lib/Field.svelte +80 -0
- package/src/editor/lib/Icon.svelte +34 -0
- package/src/editor/lib/ImagePicker.svelte +264 -0
- package/src/editor/lib/MarkdownEditor.svelte +206 -0
- package/src/editor/lib/MenuEditor.svelte +209 -0
- package/src/editor/lib/PropertiesTabs.svelte +15 -0
- package/src/editor/lib/ReferenceSelect.svelte +17 -0
- package/src/editor/lib/SchemaField.svelte +156 -0
- package/src/editor/lib/SchemaPanel.svelte +189 -0
- package/src/editor/lib/Select.svelte +138 -0
- package/src/editor/lib/SeoGuidance.svelte +21 -0
- package/src/editor/lib/Settings.svelte +183 -0
- package/src/editor/lib/Shortcuts.svelte +29 -0
- package/src/editor/lib/SidebarIcon.svelte +8 -0
- package/src/editor/lib/SitePreview.svelte +50 -0
- package/src/editor/lib/TopBar.svelte +165 -0
- package/src/editor/lib/UnsavedChanges.svelte +38 -0
- package/src/editor/lib/VisualEditor.svelte +494 -0
- package/src/editor/lib/calendar.ts +55 -0
- package/src/editor/lib/github-auth.ts +117 -0
- package/src/editor/lib/github-backend.ts +263 -0
- package/src/editor/lib/icons.ts +111 -0
- package/src/editor/lib/images.ts +195 -0
- package/src/editor/lib/markdown.ts +71 -0
- package/src/editor/lib/media-library.ts +39 -0
- package/src/editor/lib/menu-tree.ts +37 -0
- package/src/editor/lib/rich-markdown.ts +30 -0
- package/src/editor/lib/schema-edit.ts +110 -0
- package/src/editor/lib/settings.ts +103 -0
- package/src/editor/lib/slugify.ts +10 -0
- package/src/editor/lib/storage.ts +17 -0
- package/src/editor/lib/store.svelte.ts +33 -0
- package/src/editor/main.ts +9 -0
- package/src/editor/styles.css +1280 -0
- package/src/env.d.ts +22 -0
- package/src/index.ts +128 -0
- package/src/manifest/frontmatter.ts +56 -0
- package/src/manifest/loader.ts +14 -0
- package/src/manifest/menu.ts +46 -0
- package/src/manifest/navigation.ts +94 -0
- package/src/manifest/references.ts +39 -0
- package/src/manifest/seo.ts +15 -0
- package/src/manifest/types.ts +75 -0
- package/src/media-types.ts +21 -0
- package/src/preview-urls.ts +3 -0
- package/src/preview.ts +167 -0
- package/src/routes/local-api.ts +42 -0
- package/src/routes/local-documents.ts +83 -0
- package/src/routes/local-media.ts +124 -0
- package/src/routes/local-menu.ts +81 -0
- package/src/routes/local-post.ts +80 -0
- package/src/routes/manifest.json.ts +15 -0
- package/src/routes/media-files.ts +126 -0
- package/src/routes/media-validation.ts +37 -0
- package/src/routes/media.json.ts +12 -0
- package/src/routes/rigel-page.astro +25 -0
- package/src/routes/serialize.ts +139 -0
- package/src/schema/docs.ts +33 -0
- package/src/schema/paths.ts +136 -0
- package/src/schema/store.ts +141 -0
- package/src/schema/types.ts +81 -0
- package/src/schema/validate.ts +327 -0
- package/src/vite/manifest-plugin.ts +180 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function renderMarkdown(src: string): string {
|
|
2
|
+
const lines = src.split("\n");
|
|
3
|
+
let html = "";
|
|
4
|
+
let inCode = false;
|
|
5
|
+
let inList = false;
|
|
6
|
+
let para: string[] = [];
|
|
7
|
+
|
|
8
|
+
const esc = (s: string) =>
|
|
9
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
10
|
+
const inline = (s: string) =>
|
|
11
|
+
esc(s)
|
|
12
|
+
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2" />')
|
|
13
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
|
14
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
15
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
16
|
+
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
17
|
+
|
|
18
|
+
const flushPara = () => {
|
|
19
|
+
if (para.length) {
|
|
20
|
+
html += `<p>${para.map(inline).join(" ")}</p>`;
|
|
21
|
+
para = [];
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const closeList = () => {
|
|
25
|
+
if (inList) {
|
|
26
|
+
html += "</ul>";
|
|
27
|
+
inList = false;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (const raw of lines) {
|
|
32
|
+
const line = raw.trimEnd();
|
|
33
|
+
if (/^```/.test(line)) {
|
|
34
|
+
flushPara();
|
|
35
|
+
closeList();
|
|
36
|
+
html += inCode ? "</code></pre>" : "<pre><code>";
|
|
37
|
+
inCode = !inCode;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (inCode) {
|
|
41
|
+
html += esc(raw) + "\n";
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
45
|
+
flushPara();
|
|
46
|
+
closeList();
|
|
47
|
+
const level = line.match(/^#+/)![0].length;
|
|
48
|
+
html += `<h${level}>${inline(line.replace(/^#+\s*/, ""))}</h${level}>`;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (/^[-*]\s+/.test(line)) {
|
|
52
|
+
flushPara();
|
|
53
|
+
if (!inList) {
|
|
54
|
+
html += "<ul>";
|
|
55
|
+
inList = true;
|
|
56
|
+
}
|
|
57
|
+
html += `<li>${inline(line.replace(/^[-*]\s+/, ""))}</li>`;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (line === "") {
|
|
61
|
+
flushPara();
|
|
62
|
+
closeList();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
para.push(line);
|
|
66
|
+
}
|
|
67
|
+
flushPara();
|
|
68
|
+
closeList();
|
|
69
|
+
if (inCode) html += "</code></pre>";
|
|
70
|
+
return html;
|
|
71
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { mediaMetadata } from "../../media-types";
|
|
2
|
+
|
|
3
|
+
export interface MediaAsset {
|
|
4
|
+
url: string;
|
|
5
|
+
name: string;
|
|
6
|
+
type?: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
modified?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
alt?: string;
|
|
12
|
+
author?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function assetFile(url: string): string {
|
|
16
|
+
return decodeURIComponent(url.split("/").pop() ?? "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function modifiedMonth(value?: string): string {
|
|
20
|
+
if (!value || !Number.isFinite(Date.parse(value))) return "";
|
|
21
|
+
return new Date(value).toISOString().slice(0, 7);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CURATED = new Set(['Images', 'Audio', 'Video', 'Documents', 'Spreadsheets', 'Archives']);
|
|
25
|
+
export function filterMedia(assets: MediaAsset[], search: string, type: string, month: string): MediaAsset[] {
|
|
26
|
+
return assets.filter(asset => asset.name.toLowerCase().includes(search.trim().toLowerCase())
|
|
27
|
+
&& (!type || asset.type === type || assetCategory(asset) === type) && (!month || modifiedMonth(asset.modified) === month));
|
|
28
|
+
}
|
|
29
|
+
function assetCategory(asset: MediaAsset): string {
|
|
30
|
+
const category = mediaMetadata(asset.name)?.category ?? '';
|
|
31
|
+
if (!category) return 'Other';
|
|
32
|
+
return CURATED.has(category) ? category : 'Other';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mediaSize(size?: number): string {
|
|
36
|
+
if (typeof size !== "number" || !Number.isFinite(size) || size < 0) return "—";
|
|
37
|
+
if (size < 1024) return `${size} B`;
|
|
38
|
+
return size < 1024 * 1024 ? `${Number((size / 1024).toFixed(1))} KB` : `${Number((size / (1024 * 1024)).toFixed(1))} MB`;
|
|
39
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MenuItem } from "../../manifest/menu";
|
|
2
|
+
|
|
3
|
+
export function menuRows(items: MenuItem[], parentId: string | null = null, depth = 1): { item: MenuItem; parentId: string | null; depth: number; previous?: string; next?: string }[] {
|
|
4
|
+
return items.flatMap((item, index) => [
|
|
5
|
+
{ item, parentId, depth, previous: items[index - 1]?.id, next: items[index + 1]?.id },
|
|
6
|
+
...menuRows(item.children, item.id, depth + 1),
|
|
7
|
+
]);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function editMenuItem(items: MenuItem[], id: string, update: (item: MenuItem) => MenuItem | null): MenuItem[] {
|
|
11
|
+
return items.flatMap((item) => {
|
|
12
|
+
if (item.id === id) { const next = update(item); return next ? [next] : []; }
|
|
13
|
+
return [{ ...item, children: editMenuItem(item.children, id, update) }];
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function moveMenuItem(items: MenuItem[], id: string, targetId: string | null, position: "before" | "after" | "inside", maxDepth: number): MenuItem[] {
|
|
18
|
+
const source = menuRows(items).find((row) => row.item.id === id)?.item;
|
|
19
|
+
if (!source) throw new Error("Menu item not found");
|
|
20
|
+
if (targetId && menuRows([source]).some((row) => row.item.id === targetId)) throw new Error("An item cannot be moved into itself or its children");
|
|
21
|
+
const next = editMenuItem(items, id, () => null);
|
|
22
|
+
let found = targetId === null;
|
|
23
|
+
function insert(list: MenuItem[]): MenuItem[] {
|
|
24
|
+
return list.flatMap((item) => {
|
|
25
|
+
if (item.id === targetId) {
|
|
26
|
+
found = true;
|
|
27
|
+
if (position === "inside") return [{ ...item, children: [...item.children, source!] }];
|
|
28
|
+
return position === "before" ? [source!, item] : [item, source!];
|
|
29
|
+
}
|
|
30
|
+
return [{ ...item, children: insert(item.children) }];
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const result = targetId === null ? [...next, source] : insert(next);
|
|
34
|
+
if (!found) throw new Error("Drop target no longer exists");
|
|
35
|
+
if (menuRows(result).some((row) => row.depth > maxDepth)) throw new Error(`This move exceeds the ${maxDepth}-level limit`);
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import MarkdownIt from "markdown-it";
|
|
2
|
+
import { MarkdownParser, defaultMarkdownParser, defaultMarkdownSerializer, schema } from "prosemirror-markdown";
|
|
3
|
+
import type { Node } from "prosemirror-model";
|
|
4
|
+
|
|
5
|
+
const tokenizer = new MarkdownIt({ html: true });
|
|
6
|
+
const parser = new MarkdownParser(schema, tokenizer, defaultMarkdownParser.tokens);
|
|
7
|
+
export { schema };
|
|
8
|
+
export const serializeMarkdown = (doc: Node) => defaultMarkdownSerializer.serialize(doc);
|
|
9
|
+
|
|
10
|
+
export function safeMarkdownUrl(url: string): boolean {
|
|
11
|
+
return !/[\s\u0000-\u001f\u007f\\]/.test(url) && !url.startsWith("//") && (!/^[a-z][a-z\d+.-]*:/i.test(url) || /^(https?:|mailto:)/i.test(url));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseVisualMarkdown(source: string): Node | null {
|
|
15
|
+
try {
|
|
16
|
+
const env: { references?: unknown } = {};
|
|
17
|
+
tokenizer.parse(source, env);
|
|
18
|
+
if (env.references) return null;
|
|
19
|
+
const doc = parser.parse(source);
|
|
20
|
+
let safe = true;
|
|
21
|
+
doc.descendants(node => {
|
|
22
|
+
if (node.type.name === "image" && !safeMarkdownUrl(node.attrs.src)) safe = false;
|
|
23
|
+
if (node.marks.some(mark => mark.type.name === "link" && !safeMarkdownUrl(mark.attrs.href))) safe = false;
|
|
24
|
+
});
|
|
25
|
+
if (!safe || tokenizer.render(source) !== tokenizer.render(serializeMarkdown(doc))) return null;
|
|
26
|
+
return doc;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { cloneJson, newKey, regenerateKeys, setPath } from "../../schema/paths";
|
|
2
|
+
import { isPlainObject, objectDef } from "../../schema/validate";
|
|
3
|
+
import type { ContentDocument, DocumentDef, FieldDef, Schema } from "../../schema/types";
|
|
4
|
+
|
|
5
|
+
export interface SelectedField {
|
|
6
|
+
docId: string;
|
|
7
|
+
path: string;
|
|
8
|
+
sectionKey?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function sectionList(values: Record<string, unknown>): Record<string, unknown>[] {
|
|
12
|
+
return Array.isArray(values.sections) ? (values.sections as Record<string, unknown>[]) : [];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sectionTypeDef(schema: Schema, type: unknown) {
|
|
16
|
+
return schema.objects.find((object) => object.name === type && object.type === "section");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sectionLabel(schema: Schema, type: unknown): string {
|
|
20
|
+
return sectionTypeDef(schema, type)?.title ?? String(type ?? "Section");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function fieldForPath(schema: Schema, docType: DocumentDef, values: Record<string, unknown>, path: string): FieldDef | null {
|
|
24
|
+
if (!path) return null;
|
|
25
|
+
if (path.startsWith("sections.")) {
|
|
26
|
+
const [scope, key, ...rest] = path.split(".");
|
|
27
|
+
if (scope !== "sections" || !key) return null;
|
|
28
|
+
const section = sectionList(values).find((entry) => entry._key === key);
|
|
29
|
+
const def = section ? sectionTypeDef(schema, section._type) : undefined;
|
|
30
|
+
return def ? lookupField(schema, def.fields, rest) : null;
|
|
31
|
+
}
|
|
32
|
+
return lookupField(schema, docType.fields, path.split("."));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function lookupField(schema: Schema, fields: FieldDef[], segments: string[]): FieldDef | null {
|
|
36
|
+
let list = fields;
|
|
37
|
+
for (let index = 0; index < segments.length; index++) {
|
|
38
|
+
const field = list.find((entry) => entry.key === segments[index]);
|
|
39
|
+
if (!field) return null;
|
|
40
|
+
if (index === segments.length - 1) return field;
|
|
41
|
+
if (field.type === "object") {
|
|
42
|
+
list = field.of ? objectDef(schema, field.of).fields : field.fields ?? [];
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (field.type === "array") {
|
|
46
|
+
index++;
|
|
47
|
+
if (index >= segments.length) return null;
|
|
48
|
+
const item = field.items?.find((entry) => entry.type === "object");
|
|
49
|
+
if (!item || item.type !== "object") return null;
|
|
50
|
+
list = item.name ? objectDef(schema, item.name).fields : item.fields ?? [];
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function itemFields(schema: Schema, field: FieldDef): FieldDef[] {
|
|
59
|
+
const item = field.items?.find((entry) => entry.type === "object");
|
|
60
|
+
if (!item || item.type !== "object") return [];
|
|
61
|
+
return item.name ? objectDef(schema, item.name).fields : item.fields ?? [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function objectFields(schema: Schema, field: FieldDef): FieldDef[] {
|
|
65
|
+
return field.of ? objectDef(schema, field.of).fields : field.fields ?? [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function defaultValue(field: FieldDef): unknown {
|
|
69
|
+
if (field.initialValue !== undefined) return regenerateKeys(cloneJson(field.initialValue));
|
|
70
|
+
if (field.type === "array") return [];
|
|
71
|
+
if (field.type === "boolean") return false;
|
|
72
|
+
if (field.type === "number") return 0;
|
|
73
|
+
if (field.type === "color") return "#000000";
|
|
74
|
+
if (field.type === "object") return {};
|
|
75
|
+
if (field.type === "reference") return undefined;
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function blankItem(fields: FieldDef[]): Record<string, unknown> {
|
|
80
|
+
const item: Record<string, unknown> = { _key: newKey() };
|
|
81
|
+
for (const field of fields) {
|
|
82
|
+
const value = defaultValue(field);
|
|
83
|
+
if (value !== undefined) item[field.key] = value;
|
|
84
|
+
}
|
|
85
|
+
return item;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function moveItem(list: unknown[], index: number, delta: number): unknown[] {
|
|
89
|
+
const next = [...list];
|
|
90
|
+
const target = index + delta;
|
|
91
|
+
if (target < 0 || target >= next.length) return next;
|
|
92
|
+
[next[index], next[target]] = [next[target], next[index]];
|
|
93
|
+
return next;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function duplicateItem(list: unknown[], index: number): unknown[] {
|
|
97
|
+
const item = list[index];
|
|
98
|
+
const copy = isPlainObject(item) ? regenerateKeys(cloneJson(item)) : item;
|
|
99
|
+
return [...list.slice(0, index + 1), copy, ...list.slice(index + 1)];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function replaceAt(values: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
|
|
103
|
+
const next = cloneJson(values);
|
|
104
|
+
if (!setPath(next, path, value)) return values;
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function referenceOptions(documents: ContentDocument[], to: string[]) {
|
|
109
|
+
return documents.filter((doc) => to.includes(doc.type));
|
|
110
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readStorage, writeStorage } from "./storage";
|
|
2
|
+
import type { StockKeys } from "./images";
|
|
3
|
+
|
|
4
|
+
export interface RigelUserPref {
|
|
5
|
+
sidebarCollapsed?: boolean;
|
|
6
|
+
editorExperience?: "sidebar" | "inline";
|
|
7
|
+
previewSplit: number;
|
|
8
|
+
mediaView: "grid" | "list";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RigelSettings {
|
|
12
|
+
theme: "system" | "light" | "dark";
|
|
13
|
+
language: "en" | "es" | "ru";
|
|
14
|
+
|
|
15
|
+
devMode: boolean;
|
|
16
|
+
highContrast: boolean;
|
|
17
|
+
|
|
18
|
+
apiKeys: StockKeys;
|
|
19
|
+
userPref: RigelUserPref;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const prefs = (theme: "system" | "light" | "dark" = "system", language: "en" | "es" | "ru" = "en"): Pick<RigelSettings, "theme" | "language" | "devMode" | "highContrast"> => ({
|
|
23
|
+
theme, language, devMode: false, highContrast: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const DEFAULTS: RigelSettings = {
|
|
27
|
+
...prefs(),
|
|
28
|
+
apiKeys: {},
|
|
29
|
+
userPref: { previewSplit: 0.5, mediaView: "grid", sidebarCollapsed: false, editorExperience: "sidebar" },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const PROVIDERS = ["pexels", "pixabay", "unsplash"] as const;
|
|
33
|
+
|
|
34
|
+
export function getSettings(): RigelSettings {
|
|
35
|
+
try {
|
|
36
|
+
const saved = JSON.parse(readStorage("settings") ?? "{}");
|
|
37
|
+
const legacyKeys = readLegacyKeys();
|
|
38
|
+
const keys = Object.fromEntries(PROVIDERS.filter((key) => typeof saved?.apiKeys?.[key] === "string").map((key) => [key, saved.apiKeys[key]]));
|
|
39
|
+
const mergedKeys = { ...legacyKeys, ...keys };
|
|
40
|
+
const legacyPref = readLegacyPref();
|
|
41
|
+
return {
|
|
42
|
+
theme: ["system", "light", "dark"].includes(saved?.theme) ? saved.theme : DEFAULTS.theme,
|
|
43
|
+
language: ["en", "es", "ru"].includes(saved?.language) ? saved.language : DEFAULTS.language,
|
|
44
|
+
devMode: typeof saved?.devMode === "boolean" ? saved.devMode : DEFAULTS.devMode,
|
|
45
|
+
highContrast: typeof saved?.highContrast === "boolean" ? saved.highContrast : DEFAULTS.highContrast,
|
|
46
|
+
apiKeys: mergedKeys,
|
|
47
|
+
userPref: {
|
|
48
|
+
sidebarCollapsed: saved?.userPref?.sidebarCollapsed === true,
|
|
49
|
+
editorExperience: saved?.userPref?.editorExperience === "inline" ? "inline" : "sidebar",
|
|
50
|
+
previewSplit: typeof saved?.userPref?.previewSplit === "number" && Number.isFinite(saved.userPref.previewSplit) && saved.userPref.previewSplit >= 0.2 && saved.userPref.previewSplit <= 0.8
|
|
51
|
+
? saved.userPref.previewSplit
|
|
52
|
+
: typeof saved?.previewSplit === "number" && Number.isFinite(saved.previewSplit) && saved.previewSplit >= 0.2 && saved.previewSplit <= 0.8
|
|
53
|
+
? saved.previewSplit
|
|
54
|
+
: DEFAULTS.userPref.previewSplit,
|
|
55
|
+
mediaView: ["grid", "list"].includes(saved?.userPref?.mediaView) ? saved.userPref.mediaView
|
|
56
|
+
: legacyPref.mediaView ?? DEFAULTS.userPref.mediaView,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return { ...DEFAULTS, apiKeys: readLegacyKeys(), userPref: { ...DEFAULTS.userPref, ...readLegacyPref() } };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readLegacyKeys(): StockKeys {
|
|
65
|
+
try {
|
|
66
|
+
const value = JSON.parse(readStorage("keys") ?? "{}");
|
|
67
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
68
|
+
return Object.fromEntries(PROVIDERS.filter((key) => typeof value[key] === "string").map((key) => [key, value[key]]));
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readLegacyPref(): Partial<RigelUserPref> {
|
|
75
|
+
try {
|
|
76
|
+
const value = JSON.parse(readStorage("mediaView") ?? "null");
|
|
77
|
+
return value === "grid" || value === "list" ? { mediaView: value } : {};
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function setSettings(s: Partial<RigelSettings>) {
|
|
84
|
+
const next = { ...getSettings(), ...s, userPref: { ...getSettings().userPref, ...s.userPref } };
|
|
85
|
+
writeStorage("settings", next);
|
|
86
|
+
clearLegacy();
|
|
87
|
+
localStorage.removeItem("rigel.user");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function clearLegacy() {
|
|
91
|
+
for (const name of ["keys", "mediaView"] as const) {
|
|
92
|
+
localStorage.removeItem(`rigelcms.${name}`);
|
|
93
|
+
localStorage.removeItem(`rigel.${name}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getKeys(): StockKeys {
|
|
98
|
+
return getSettings().apiKeys;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function setKeys(keys: StockKeys) {
|
|
102
|
+
setSettings({ apiKeys: keys });
|
|
103
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function readStorage(name: "settings" | "keys" | "mediaView"): string | null {
|
|
2
|
+
return localStorage.getItem(`rigelcms.${name}`) ?? localStorage.getItem(`rigel.${name}`);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function writeStorage(name: "settings" | "keys" | "mediaView", value: unknown) {
|
|
6
|
+
localStorage.setItem(`rigelcms.${name}`, JSON.stringify(value));
|
|
7
|
+
localStorage.removeItem(`rigel.${name}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function eraseData() {
|
|
11
|
+
const keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i));
|
|
12
|
+
for (const key of keys) {
|
|
13
|
+
if (key && (key.startsWith("rigelcms.") || ["rigel.settings", "rigel.keys", "rigel.user"].includes(key))) {
|
|
14
|
+
localStorage.removeItem(key);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function createCopyStore<T>(initial: T) {
|
|
2
|
+
let snapshot = JSON.stringify(initial);
|
|
3
|
+
let draft = $state<T>(initial);
|
|
4
|
+
|
|
5
|
+
const dirty = $derived(JSON.stringify(draft) !== snapshot);
|
|
6
|
+
|
|
7
|
+
function touch(next: T) {
|
|
8
|
+
draft = next;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function commit() {
|
|
12
|
+
snapshot = JSON.stringify(draft);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function reset(next: T) {
|
|
16
|
+
snapshot = JSON.stringify(next);
|
|
17
|
+
draft = next;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
get draft() {
|
|
22
|
+
return draft;
|
|
23
|
+
},
|
|
24
|
+
get dirty() {
|
|
25
|
+
return dirty;
|
|
26
|
+
},
|
|
27
|
+
touch,
|
|
28
|
+
commit,
|
|
29
|
+
reset,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CopyStore<T> = ReturnType<typeof createCopyStore<T>>;
|