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",
"@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",

View File

@ -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}
</div>
<Separator />
<div>
<div bind:this={codemirrorParent} class="hidden"></div>
<div bind:this={easyMdeWrapper} class="hidden">
<textarea bind:this={easyMdeParent}></textarea>
<div class="flex-1 flex flex-col">
{#if manager.selectedPage}
<div class="flex items-center justify-end p-2">
<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>

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">
import { ResizablePane, ResizablePaneGroup } from "@components/ui/resizable";
import { Separator } from "@components/ui/separator";
import { manager } from "./page.svelte";
import ResizableHandle from "@components/ui/resizable/resizable-handle.svelte";
import PagesList from "./PagesList.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>
<div class="flex-grow flex flex-col">
<ResizablePaneGroup direction="horizontal" class="flex-grow">
<ResizablePane defaultSize={20}>
<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}
<p>Loading pages...</p>
{:then pages}

View File

@ -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<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 {
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<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());

View File

@ -17,27 +17,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<PageList> {
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<Page> {
return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`)
.then(value => value.json())
.then((value) => value.json())
.then(PageSchema.parse);
}
@ -46,40 +45,55 @@ export class PageRepo {
method: "PUT",
body: JSON.stringify({
content: bytesToBase64(new TextEncoder().encode(content)),
sha, message,
sha,
message,
}),
});
}
public async getBranches(): Promise<string[]> {
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<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> {
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> {
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> {
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<void> {
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<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 }),
});
}
}