feat: Add frontmatter editor and enhance page management with YAML support; update dependencies and improve UI interactions
Some checks failed
SteamWarCI Build failed

This commit is contained in:
2025-05-29 12:35:58 +02:00
parent 10ff84d410
commit da3699167b
6 changed files with 379 additions and 33 deletions

View File

@ -23,6 +23,7 @@
"@internationalized/date": "^3.8.1", "@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.488.0", "@lucide/svelte": "^0.488.0",
"@types/color": "^4.2.0", "@types/color": "^4.2.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.15.23", "@types/node": "^22.15.23",
"@types/three": "^0.170.0", "@types/three": "^0.170.0",
"@typescript-eslint/eslint-plugin": "^8.33.0", "@typescript-eslint/eslint-plugin": "^8.33.0",
@ -76,6 +77,7 @@
"flowbite": "^2.5.2", "flowbite": "^2.5.2",
"flowbite-svelte": "^0.47.4", "flowbite-svelte": "^0.47.4",
"flowbite-svelte-icons": "^2.2.0", "flowbite-svelte-icons": "^2.2.0",
"js-yaml": "^4.1.0",
"qs": "^6.14.0", "qs": "^6.14.0",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"svelte-awesome": "^3.3.5", "svelte-awesome": "^3.3.5",

View File

@ -9,6 +9,9 @@
import "easymde/dist/easymde.min.css"; import "easymde/dist/easymde.min.css";
import { json } from "@codemirror/lang-json"; import { json } from "@codemirror/lang-json";
import { materialDark } from "@ddietr/codemirror-themes/theme/material-dark"; 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 codemirrorParent: HTMLElement | undefined = $state();
let easyMdeParent: HTMLElement | undefined = $state(); let easyMdeParent: HTMLElement | undefined = $state();
@ -90,10 +93,24 @@
{/each} {/each}
</div> </div>
<Separator /> <Separator />
<div> <div class="flex-1 flex flex-col">
<div bind:this={codemirrorParent} class="hidden"></div> {#if manager.selectedPage}
<div bind:this={easyMdeWrapper} class="hidden"> <div class="flex items-center justify-end p-2">
<textarea bind:this={easyMdeParent}></textarea> <Button disabled={!(manager.selectedPage?.dirty ?? false)} onclick={() => manager.selectedPage?.save()}>Speichern</Button>
</div>
<div class="flex gap-2 items-center">
{#if manager.selectedPage.path.startsWith("src/content/announcements/")}
<div class="border-b flex-1" transition:slide>
<FrontmatterEditor />
</div>
{/if}
</div>
{/if}
<div class="flex-1">
<div bind:this={codemirrorParent} class="hidden h-full"></div>
<div bind:this={easyMdeWrapper} class="hidden h-full">
<textarea bind:this={easyMdeParent}></textarea>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,122 @@
<script lang="ts">
import { X } from "lucide-svelte";
import { manager } from "./page.svelte";
import { slide } from "svelte/transition";
</script>
<details class="group">
<summary class="flex items-center justify-between p-3 cursor-pointer hover:bg-neutral-800">
<span class="font-medium">Frontmatter</span>
<svg class="w-4 h-4 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</summary>
<div class="p-3 border-t bg-neutral-900">
{#each Object.entries(manager.selectedPage?.frontmatter || {}) as [key, value]}
<div class="flex flex-col gap-2 mb-3 p-2 border rounded bg-neutral-800">
<div class="flex items-center gap-2">
<input
type="text"
value={key}
onchange={(e) => {
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"
/>
<span>:</span>
{#if Array.isArray(value)}
<span class="text-xs text-muted-foreground">Array ({value.length} items)</span>
{:else if value instanceof Date || key === "created"}
<input
type="date"
value={value instanceof Date ? value.toISOString().split("T")[0] : typeof value === "string" ? value : ""}
onchange={(e) => {
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}
<input
type="text"
bind:value={manager.selectedPage!.frontmatter[key]}
onchange={() => (manager.selectedPage!.dirty = true)}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
placeholder="Value"
/>
{/if}
<button
onclick={() => {
delete manager.selectedPage!.frontmatter[key];
manager.selectedPage!.dirty = true;
}}
class="text-red-500 hover:text-red-700 p-1"
>
<X class="w-4 h-4" />
</button>
</div>
{#if Array.isArray(value)}
<div class="ml-4 space-y-1">
{#each value as item, index}
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground w-6">[{index}]</span>
<input
type="text"
bind:value={manager.selectedPage!.frontmatter[key][index]}
onchange={() => (manager.selectedPage!.dirty = true)}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
placeholder="Array item"
/>
<button
onclick={() => {
manager.selectedPage!.frontmatter[key].splice(index, 1);
manager.selectedPage!.dirty = true;
}}
class="text-red-500 hover:text-red-700 p-1"
>
<X class="w-3 h-3" />
</button>
</div>
{/each}
<button
onclick={() => {
manager.selectedPage!.frontmatter[key].push("");
manager.selectedPage!.dirty = true;
}}
class="text-xs text-blue-500 hover:text-blue-700 ml-8"
>
+ Add item
</button>
</div>
{/if}
</div>
{/each}
<div class="flex gap-2">
<button
onclick={() => {
manager.selectedPage!.frontmatter[`new_key_${Object.keys(manager.selectedPage!.frontmatter).length}`] = "";
manager.selectedPage!.dirty = true;
}}
class="text-sm text-blue-500 hover:text-blue-700"
>
+ Add field
</button>
<button
onclick={() => {
manager.selectedPage!.frontmatter[`new_array_${Object.keys(manager.selectedPage!.frontmatter).length}`] = [];
manager.selectedPage!.dirty = true;
}}
class="text-sm text-green-500 hover:text-green-700"
>
+ Add array
</button>
</div>
</div>
</details>

View File

@ -1,15 +1,143 @@
<script lang="ts"> <script lang="ts">
import { ResizablePane, ResizablePaneGroup } from "@components/ui/resizable"; import { ResizablePane, ResizablePaneGroup } from "@components/ui/resizable";
import { Separator } from "@components/ui/separator";
import { manager } from "./page.svelte"; import { manager } from "./page.svelte";
import ResizableHandle from "@components/ui/resizable/resizable-handle.svelte"; import ResizableHandle from "@components/ui/resizable/resizable-handle.svelte";
import PagesList from "./PagesList.svelte"; import PagesList from "./PagesList.svelte";
import EditorWithTabs from "./EditorWithTabs.svelte"; import EditorWithTabs from "./EditorWithTabs.svelte";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Button } from "@components/ui/button";
import { Check, ChevronsUpDown, RefreshCw, FileImage, Plus } from "lucide-svelte";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { cn } from "@components/utils";
import { pageRepo } from "@components/repo/page";
let branchSelectOpen = $state(false);
let imageSelectOpen = $state(false);
let fileInput: HTMLInputElement | undefined = $state();
</script> </script>
<div class="flex-grow flex flex-col"> <div class="flex-grow flex flex-col">
<ResizablePaneGroup direction="horizontal" class="flex-grow"> <ResizablePaneGroup direction="horizontal" class="flex-grow">
<ResizablePane defaultSize={20}> <ResizablePane defaultSize={20}>
<div class="overflow-y-scroll"> <div class="overflow-y-scroll">
<div class="flex p-2 gap-2">
<Popover bind:open={branchSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between flex-1" {...props} role="combobox">
{manager.branch}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Branches..." />
<CommandList>
<CommandEmpty>No Branches Found.</CommandEmpty>
<CommandGroup>
{#each manager.branches as branch}
<CommandItem
value={branch}
onSelect={() => {
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;
}}
>
<Check class={cn("mr-2 size-4", branch !== manager.branch && "text-transparent")} />
{branch}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Button size="icon" variant="outline" onclick={() => manager.reloadImages()}>
<RefreshCw />
</Button>
<Popover bind:open={imageSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button size="icon" variant="outline" {...props}>
<FileImage />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent side="right" class="w-[1000px] h-screen overflow-y-auto">
{#await manager.imagesLoad}
<p>Loading images...</p>
{:then images}
<div class="flex flex-col gap-2">
<div class="p-2">
<input
type="file"
accept="image/*"
class="hidden"
bind:this={fileInput}
onchange={async (e) => {
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);
}}
/>
<Button onclick={() => fileInput?.click()} class="w-full">
<Plus class="mr-2 size-4" />
Upload Image
</Button>
</div>
<div class="grid grid-cols-4 gap-2 p-2">
{#each images as image}
<button
onclick={() => {
const backs = (manager.selectedPage?.path?.match(/\//g)?.length || 1) - 1;
const path = [...Array(backs).fill(".."), image.path.replace("src/", "")].join("/");
navigator.clipboard.writeText(path);
imageSelectOpen = false;
}}
>
<img src={image.downloadUrl} alt={image.name} class="w-full h-auto object-cover" />
</button>
{/each}
</div>
</div>
{/await}
</PopoverContent>
</Popover>
<Button
size="icon"
onclick={async () => {
const branchName = prompt("Enter branch name:");
if (branchName) {
await $pageRepo.createBranch(branchName);
manager.reloadBranches();
}
}}
>
<Plus />
</Button>
</div>
<Separator />
{#await manager.pagesLoad} {#await manager.pagesLoad}
<p>Loading pages...</p> <p>Loading pages...</p>
{:then pages} {:then pages}

View File

@ -2,10 +2,11 @@ import { base64ToBytes } from "@components/admin/util";
import { pageRepo } from "@components/repo/page"; import { pageRepo } from "@components/repo/page";
import type { ListPage, PageList } from "@components/types/page"; import type { ListPage, PageList } from "@components/types/page";
import { get } from "svelte/store"; import { get } from "svelte/store";
import yaml from "js-yaml";
export class OpenEditPage { export class OpenEditPage {
public content: string = ""; public content: string = "";
public frontmatter: { [key: string]: string } = $state({}); public frontmatter: { [key: string]: string | string[] | Date } = $state({});
public dirty: boolean = $state(false); public dirty: boolean = $state(false);
public readonly fileType: string; public readonly fileType: string;
@ -16,7 +17,7 @@ export class OpenEditPage {
public readonly pageTitle: string, public readonly pageTitle: string,
public readonly sha: string, public readonly sha: string,
public readonly originalContent: string, public readonly originalContent: string,
private readonly path: string public readonly path: string
) { ) {
this.fileType = this.path.split(".").pop() || "md"; this.fileType = this.path.split(".").pop() || "md";
@ -24,6 +25,24 @@ export class OpenEditPage {
this.frontmatter = this.parseFrontmatter(originalContent); this.frontmatter = this.parseFrontmatter(originalContent);
} }
public async save(): Promise<void> {
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 { public focus(): boolean {
let index = this.manager.pages.indexOf(this); let index = this.manager.pages.indexOf(this);
@ -35,18 +54,35 @@ export class OpenEditPage {
return false; return false;
} }
private parseFrontmatter(content: string): { [key: string]: string } { private parseFrontmatter(content: string): { [key: string]: string | string[] | Date } {
const frontmatter: { [key: string]: string } = {};
const lines = content.split("\n"); const lines = content.split("\n");
let inFrontmatter = false;
const frontmatterLines: string[] = [];
for (const line of lines) { for (const line of lines) {
const match = line.match(/^\s*([a-zA-Z0-9_-]+):\s*(.*)$/); if (line.trim() === "---") {
if (match) { if (inFrontmatter) {
frontmatter[match[1]] = match[2].trim(); 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 { private removeFrontmatter(content: string): string {
@ -75,11 +111,34 @@ export interface DirTree {
} }
export class PageManager { export class PageManager {
public reloadImages() {
this.updater = this.updater + 1;
}
public branch: string = $state("master"); public branch: string = $state("master");
public pages: OpenEditPage[] = $state([]); 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 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<T>(n: number): (v: T) => T {
return (v: T) => v;
}
public selectedPage = $derived(this.openPageIndex >= 0 ? this.pages[this.openPageIndex] : undefined); 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); await get(pageRepo).createFile(path, this.branch, newPageName, newPageName);
this.branch = this.branch; this.branch = this.branch;
} }
public anyUnsavedChanges() {
return this.pages.some((page) => page.dirty);
}
} }
export const manager = $state(new PageManager()); export const manager = $state(new PageManager());

View File

@ -17,27 +17,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type {Page, PageList} from "@type/page.ts"; import type { Page, PageList } from "@type/page.ts";
import {fetchWithToken, tokenStore} from "./repo.ts"; import { fetchWithToken, tokenStore } from "./repo.ts";
import {PageListSchema, PageSchema} from "@type/page.ts"; import { PageListSchema, PageSchema } from "@type/page.ts";
import {bytesToBase64} from "../admin/util.ts"; import { bytesToBase64 } from "../admin/util.ts";
import {z} from "zod"; import { z } from "zod";
import {derived} from "svelte/store"; import { derived } from "svelte/store";
export class PageRepo { export class PageRepo {
constructor(private token: string) { constructor(private token: string) {}
}
public async listPages(branch: string = "master"): Promise<PageList> { public async listPages(branch: string = "master"): Promise<PageList> {
return await fetchWithToken(this.token, `/page?branch=${branch}`) return await fetchWithToken(this.token, `/page?branch=${branch}`)
.then(value => value.json()) .then((value) => value.json())
.then(PageListSchema.parse) .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<Page> { public async getPage(id: number, branch: string = "master"): Promise<Page> {
return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`) return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`)
.then(value => value.json()) .then((value) => value.json())
.then(PageSchema.parse); .then(PageSchema.parse);
} }
@ -46,42 +45,57 @@ export class PageRepo {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
content: bytesToBase64(new TextEncoder().encode(content)), content: bytesToBase64(new TextEncoder().encode(content)),
sha, message, sha,
message,
}), }),
}); });
} }
public async getBranches(): Promise<string[]> { public async getBranches(): Promise<string[]> {
return await fetchWithToken(this.token, "/page/branch") return await fetchWithToken(this.token, "/page/branch")
.then(value => value.json()) .then((value) => value.json())
.then(value => z.array(z.string()).parse(value)); .then((value) => z.array(z.string()).parse(value));
} }
public async createBranch(branch: string): Promise<void> { public async createBranch(branch: string): Promise<void> {
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<void> { public async deleteBranch(branch: string): Promise<void> {
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<void> { public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise<void> {
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<void> { public async merge(branch: string, message: string): Promise<void> {
await fetchWithToken(this.token, "/page/branch/merge", { await fetchWithToken(this.token, "/page/branch/merge", {
method: "POST", 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<void> { public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise<void> {
await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, { await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify({message, sha}), body: JSON.stringify({ message, sha }),
});
}
public async listImages(branch: string = "master"): Promise<PageList> {
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<void> {
await fetchWithToken(this.token, `/page/images?branch=${branch}`, {
method: "POST",
body: JSON.stringify({ name, data }),
}); });
} }
} }
export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token)); export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token));