From da3699167b56f360d7512b9d2945a2b103672dc3 Mon Sep 17 00:00:00 2001 From: Chaoscaot Date: Thu, 29 May 2025 12:35:58 +0200 Subject: [PATCH] feat: Add frontmatter editor and enhance page management with YAML support; update dependencies and improve UI interactions --- package.json | 2 + .../pages/pages/EditorWithTabs.svelte | 25 +++- .../pages/pages/FrontmatterEditor.svelte | 122 +++++++++++++++++ .../moderator/pages/pages/Pages.svelte | 128 ++++++++++++++++++ .../moderator/pages/pages/page.svelte.ts | 81 +++++++++-- src/components/repo/page.ts | 54 +++++--- 6 files changed, 379 insertions(+), 33 deletions(-) create mode 100644 src/components/moderator/pages/pages/FrontmatterEditor.svelte diff --git a/package.json b/package.json index efbe2e9..8770b66 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@internationalized/date": "^3.8.1", "@lucide/svelte": "^0.488.0", "@types/color": "^4.2.0", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.15.23", "@types/three": "^0.170.0", "@typescript-eslint/eslint-plugin": "^8.33.0", @@ -76,6 +77,7 @@ "flowbite": "^2.5.2", "flowbite-svelte": "^0.47.4", "flowbite-svelte-icons": "^2.2.0", + "js-yaml": "^4.1.0", "qs": "^6.14.0", "sharp": "^0.33.5", "svelte-awesome": "^3.3.5", diff --git a/src/components/moderator/pages/pages/EditorWithTabs.svelte b/src/components/moderator/pages/pages/EditorWithTabs.svelte index 6495cbb..a9e17da 100644 --- a/src/components/moderator/pages/pages/EditorWithTabs.svelte +++ b/src/components/moderator/pages/pages/EditorWithTabs.svelte @@ -9,6 +9,9 @@ import "easymde/dist/easymde.min.css"; import { json } from "@codemirror/lang-json"; import { materialDark } from "@ddietr/codemirror-themes/theme/material-dark"; + import FrontmatterEditor from "./FrontmatterEditor.svelte"; + import { slide } from "svelte/transition"; + import { Button } from "@components/ui/button"; let codemirrorParent: HTMLElement | undefined = $state(); let easyMdeParent: HTMLElement | undefined = $state(); @@ -90,10 +93,24 @@ {/each} -
- - diff --git a/src/components/moderator/pages/pages/FrontmatterEditor.svelte b/src/components/moderator/pages/pages/FrontmatterEditor.svelte new file mode 100644 index 0000000..0c889e4 --- /dev/null +++ b/src/components/moderator/pages/pages/FrontmatterEditor.svelte @@ -0,0 +1,122 @@ + + +
+ + Frontmatter + + + + +
+ {#each Object.entries(manager.selectedPage?.frontmatter || {}) as [key, value]} +
+
+ { + const newKey = (e.target as HTMLInputElement).value; + if (newKey !== key) { + manager.selectedPage!.frontmatter[newKey] = manager.selectedPage!.frontmatter[key]; + delete manager.selectedPage?.frontmatter[key]; + manager.selectedPage!.dirty = true; + } + }} + class="px-2 py-1 border rounded text-sm flex-shrink-0 w-32 bg-neutral-900" + placeholder="Key" + /> + : + {#if Array.isArray(value)} + Array ({value.length} items) + {:else if value instanceof Date || key === "created"} + { + const dateValue = (e.target as HTMLInputElement).value; + manager.selectedPage!.frontmatter[key] = dateValue ? new Date(dateValue) : ""; + manager.selectedPage!.dirty = true; + }} + class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900" + /> + {:else} + (manager.selectedPage!.dirty = true)} + class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900" + placeholder="Value" + /> + {/if} + +
+ {#if Array.isArray(value)} +
+ {#each value as item, index} +
+ [{index}] + (manager.selectedPage!.dirty = true)} + class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900" + placeholder="Array item" + /> + +
+ {/each} + +
+ {/if} +
+ {/each} +
+ + +
+
+
diff --git a/src/components/moderator/pages/pages/Pages.svelte b/src/components/moderator/pages/pages/Pages.svelte index 9f359ae..c71a8ec 100644 --- a/src/components/moderator/pages/pages/Pages.svelte +++ b/src/components/moderator/pages/pages/Pages.svelte @@ -1,15 +1,143 @@
+
+ + + {#snippet child({ props })} + + {/snippet} + + + + + + No Branches Found. + + {#each manager.branches as branch} + { + if (manager.anyUnsavedChanges()) { + if (!confirm("You have unsaved changes. Are you sure you want to switch branches?")) { + return; + } + } + + manager.branch = branch; + manager.pages = []; + branchSelectOpen = false; + }} + > + + {branch} + + {/each} + + + + + + + + + {#snippet child({ props })} + + {/snippet} + + + {#await manager.imagesLoad} +

Loading images...

+ {:then images} +
+
+ { + const file = e.target?.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = async (event) => { + const base64 = event.target?.result?.toString().split(",")[1]; + if (base64) { + await $pageRepo.createImage(file.name, base64, manager.branch); + manager.reloadImages(); + } + }; + reader.readAsDataURL(file); + }} + /> + +
+
+ {#each images as image} + + {/each} +
+
+ {/await} +
+
+ +
+ {#await manager.pagesLoad}

Loading pages...

{:then pages} diff --git a/src/components/moderator/pages/pages/page.svelte.ts b/src/components/moderator/pages/pages/page.svelte.ts index dab6b51..dca0162 100644 --- a/src/components/moderator/pages/pages/page.svelte.ts +++ b/src/components/moderator/pages/pages/page.svelte.ts @@ -2,10 +2,11 @@ import { base64ToBytes } from "@components/admin/util"; import { pageRepo } from "@components/repo/page"; import type { ListPage, PageList } from "@components/types/page"; import { get } from "svelte/store"; +import yaml from "js-yaml"; export class OpenEditPage { public content: string = ""; - public frontmatter: { [key: string]: string } = $state({}); + public frontmatter: { [key: string]: string | string[] | Date } = $state({}); public dirty: boolean = $state(false); public readonly fileType: string; @@ -16,7 +17,7 @@ export class OpenEditPage { public readonly pageTitle: string, public readonly sha: string, public readonly originalContent: string, - private readonly path: string + public readonly path: string ) { this.fileType = this.path.split(".").pop() || "md"; @@ -24,6 +25,24 @@ export class OpenEditPage { this.frontmatter = this.parseFrontmatter(originalContent); } + public async save(): Promise { + if (!this.dirty) { + return; + } + + let contentToSave = ""; + if (this.frontmatter) { + contentToSave += "---\n"; + contentToSave += yaml.dump(this.frontmatter); + contentToSave += "---\n\n"; + } + contentToSave += this.content; + const encodedContent = btoa(new TextEncoder().encode(contentToSave).reduce((data, byte) => data + String.fromCharCode(byte), "")); + await get(pageRepo).updatePage(this.pageId, this.sha, encodedContent, this.manager.branch); + this.dirty = false; + this.manager.reloadImages(); + } + public focus(): boolean { let index = this.manager.pages.indexOf(this); @@ -35,18 +54,35 @@ export class OpenEditPage { return false; } - private parseFrontmatter(content: string): { [key: string]: string } { - const frontmatter: { [key: string]: string } = {}; + private parseFrontmatter(content: string): { [key: string]: string | string[] | Date } { const lines = content.split("\n"); + let inFrontmatter = false; + const frontmatterLines: string[] = []; for (const line of lines) { - const match = line.match(/^\s*([a-zA-Z0-9_-]+):\s*(.*)$/); - if (match) { - frontmatter[match[1]] = match[2].trim(); + if (line.trim() === "---") { + if (inFrontmatter) { + break; // End of frontmatter + } + inFrontmatter = true; + continue; + } + if (inFrontmatter) { + frontmatterLines.push(line); } } - return frontmatter; + if (frontmatterLines.length === 0) { + return {}; + } + + try { + // You'll need to install js-yaml: npm install js-yaml @types/js-yaml + return (yaml.load(frontmatterLines.join("\n")) || {}) as { [key: string]: string | string[] | Date }; + } catch (error) { + console.error("Failed to parse YAML frontmatter:", error); + return {}; + } } private removeFrontmatter(content: string): string { @@ -75,11 +111,34 @@ export interface DirTree { } export class PageManager { + public reloadImages() { + this.updater = this.updater + 1; + } public branch: string = $state("master"); public pages: OpenEditPage[] = $state([]); + public branches: string[] = $state([]); + + constructor() { + this.reloadBranches(); + } + + public reloadBranches() { + get(pageRepo) + .getBranches() + .then((branches) => { + this.branches = branches; + }); + } + + private updater = $state(0); public openPageIndex: number = $state(-1); - public pagesLoad = $derived(get(pageRepo).listPages(this.branch).then(this.convertToTree)); + public pagesLoad = $derived(get(pageRepo).listPages(this.branch).then(this.convertToTree).then(this._t(this.updater))); + public imagesLoad = $derived(get(pageRepo).listImages(this.branch).then(this._t(this.updater))); + + private _t(n: number): (v: T) => T { + return (v: T) => v; + } public selectedPage = $derived(this.openPageIndex >= 0 ? this.pages[this.openPageIndex] : undefined); @@ -158,6 +217,10 @@ export class PageManager { await get(pageRepo).createFile(path, this.branch, newPageName, newPageName); this.branch = this.branch; } + + public anyUnsavedChanges() { + return this.pages.some((page) => page.dirty); + } } export const manager = $state(new PageManager()); diff --git a/src/components/repo/page.ts b/src/components/repo/page.ts index f42b1d8..f3373b2 100644 --- a/src/components/repo/page.ts +++ b/src/components/repo/page.ts @@ -17,27 +17,26 @@ * along with this program. If not, see . */ -import type {Page, PageList} from "@type/page.ts"; -import {fetchWithToken, tokenStore} from "./repo.ts"; -import {PageListSchema, PageSchema} from "@type/page.ts"; -import {bytesToBase64} from "../admin/util.ts"; -import {z} from "zod"; -import {derived} from "svelte/store"; +import type { Page, PageList } from "@type/page.ts"; +import { fetchWithToken, tokenStore } from "./repo.ts"; +import { PageListSchema, PageSchema } from "@type/page.ts"; +import { bytesToBase64 } from "../admin/util.ts"; +import { z } from "zod"; +import { derived } from "svelte/store"; export class PageRepo { - constructor(private token: string) { - } + constructor(private token: string) {} public async listPages(branch: string = "master"): Promise { return await fetchWithToken(this.token, `/page?branch=${branch}`) - .then(value => value.json()) + .then((value) => value.json()) .then(PageListSchema.parse) - .then(value => value.map(value1 => ({...value1, path: value1.path.replace("src/content/", "")}))); + .then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") }))); } public async getPage(id: number, branch: string = "master"): Promise { return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`) - .then(value => value.json()) + .then((value) => value.json()) .then(PageSchema.parse); } @@ -46,42 +45,57 @@ export class PageRepo { method: "PUT", body: JSON.stringify({ content: bytesToBase64(new TextEncoder().encode(content)), - sha, message, + sha, + message, }), }); } public async getBranches(): Promise { return await fetchWithToken(this.token, "/page/branch") - .then(value => value.json()) - .then(value => z.array(z.string()).parse(value)); + .then((value) => value.json()) + .then((value) => z.array(z.string()).parse(value)); } public async createBranch(branch: string): Promise { - await fetchWithToken(this.token, "/page/branch", {method: "POST", body: JSON.stringify({branch})}); + await fetchWithToken(this.token, "/page/branch", { method: "POST", body: JSON.stringify({ branch }) }); } public async deleteBranch(branch: string): Promise { - await fetchWithToken(this.token, "/page/branch", {method: "DELETE", body: JSON.stringify({branch})}); + await fetchWithToken(this.token, "/page/branch", { method: "DELETE", body: JSON.stringify({ branch }) }); } public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise { - await fetchWithToken(this.token, `/page?branch=${branch}`, {method: "POST", body: JSON.stringify({path, slug, title})}); + await fetchWithToken(this.token, `/page?branch=${branch}`, { method: "POST", body: JSON.stringify({ path, slug, title }) }); } public async merge(branch: string, message: string): Promise { await fetchWithToken(this.token, "/page/branch/merge", { method: "POST", - body: JSON.stringify({branch, message}), + body: JSON.stringify({ branch, message }), }); } public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise { await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, { method: "DELETE", - body: JSON.stringify({message, sha}), + body: JSON.stringify({ message, sha }), + }); + } + + public async listImages(branch: string = "master"): Promise { + return await fetchWithToken(this.token, `/page/images?branch=${branch}`) + .then((value) => value.json()) + .then(PageListSchema.parse) + .then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") }))); + } + + public async createImage(name: string, data: string, branch: string = "master"): Promise { + await fetchWithToken(this.token, `/page/images?branch=${branch}`, { + method: "POST", + body: JSON.stringify({ name, data }), }); } } -export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token)); \ No newline at end of file +export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token));