Add schema-driven frontmatter editor #24

Merged
Chaoscaot merged 1 commits from feature/frontmatter-schema-editor into master 2026-05-07 16:05:09 +02:00
10 changed files with 1127 additions and 245 deletions
Showing only changes of commit 3b7aafd56e - Show all commits
@@ -1,7 +1,6 @@
<script lang="ts">
import { Separator } from "@components/ui/separator";
import { manager, OpenEditPage } from "./page.svelte";
import { File, X } from "lucide-svelte";
import { File, FileCode2, FileText, Save, X } from "lucide-svelte";
import { onMount } from "svelte";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
@@ -61,56 +60,144 @@
});
easyMde.codemirror.on("change", () => {
if (manager.selectedPage?.content !== easyMde?.value()) {
manager.selectedPage!.dirty = true;
if (!manager.selectedPage) {
return;
}
manager.selectedPage!.content = easyMde?.value() || "";
if (manager.selectedPage.content !== easyMde?.value()) {
manager.selectedPage.dirty = true;
}
manager.selectedPage.content = easyMde?.value() || "";
});
return () => {
view?.destroy();
easyMde?.toTextArea();
};
});
</script>
<div class="flex flex-col h-full w-full">
<div class="h-8 flex">
{#each manager.pages as tab, index}
{@const isActive = manager.openPageIndex === index}
<button
class="flex pl-4 border-r group items-center hover:bg-neutral-800 transition-colors cursor-pointer h-full {isActive
? 'text-primary bg-neutral-900'
: 'text-muted-foreground'} {tab.dirty ? 'italic' : ''}"
onclick={() => (manager.openPageIndex = index)}
>
<File class="h-4 w-4 mr-2" />
{tab.pageTitle}
<span
class="mx-4 hover:bg-neutral-700 transition-all rounded {isActive ? '' : 'opacity-0'} group-hover:opacity-100 cursor-pointer"
onclick={(e) => {
e.stopPropagation();
manager.closePage(index);
}}><X /></span
<div class="flex h-full min-h-0 w-full flex-col bg-neutral-950">
<div class="flex h-11 shrink-0 items-end overflow-x-auto border-b border-neutral-800 bg-neutral-950 px-2">
{#if manager.pages.length === 0}
<div class="flex h-full items-center px-2 text-sm text-muted-foreground">No file open</div>
{:else}
{#each manager.pages as tab, index}
{@const isActive = manager.openPageIndex === index}
<button
class="group flex h-9 max-w-64 items-center gap-2 rounded-t-md border border-b-0 px-3 text-sm transition-colors {isActive
? 'border-neutral-800 bg-neutral-900 text-foreground'
: 'border-transparent text-muted-foreground hover:bg-neutral-900/70'} {tab.dirty ? 'italic' : ''}"
onclick={() => (manager.openPageIndex = index)}
>
</button>
{/each}
</div>
<Separator />
<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 tab.fileType === "json"}
<FileCode2 class="size-4 shrink-0" />
{:else if tab.fileType === "md" || tab.fileType === "mdx"}
<FileText class="size-4 shrink-0" />
{:else}
<File class="size-4 shrink-0" />
{/if}
<span class="truncate">{tab.pageTitle}</span>
{#if tab.dirty}
<span class="size-1.5 shrink-0 rounded-full bg-primary"></span>
{/if}
<span
class="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-neutral-800 group-hover:opacity-100 {isActive ? 'opacity-100' : ''}"
onclick={(e) => {
e.stopPropagation();
manager.closePage(index);
}}><X class="size-4" /></span
>
</button>
{/each}
{/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 class="flex min-h-0 flex-1 flex-col">
{#if manager.selectedPage}
<header class="flex shrink-0 items-center justify-between gap-4 border-b border-neutral-800 bg-neutral-900/70 px-4 py-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<h2 class="truncate text-base font-semibold">{manager.selectedPage.pageTitle}</h2>
{#if manager.selectedPage.dirty}
<span class="rounded-full border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs text-primary">Unsaved</span>
{/if}
</div>
<p class="mt-1 truncate text-xs text-muted-foreground">{manager.selectedPage.path}</p>
</div>
<Button disabled={!(manager.selectedPage?.dirty ?? false)} onclick={() => manager.selectedPage?.save()}>
<Save class="mr-2 size-4" />
Speichern
</Button>
</header>
{/if}
<div class="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_26rem]">
<main class="min-h-0 overflow-hidden border-r border-neutral-800 bg-neutral-950">
<div class="flex h-full min-h-0 flex-col">
<div class="shrink-0 border-b border-neutral-800 px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Content</div>
<div class="relative min-h-0 flex-1 overflow-hidden">
<div bind:this={codemirrorParent} class="hidden h-full"></div>
<div bind:this={easyMdeWrapper} class="hidden h-full">
<textarea bind:this={easyMdeParent}></textarea>
</div>
{#if !manager.selectedPage}
<div class="absolute inset-0 flex items-center justify-center bg-neutral-950 p-8">
<div class="max-w-sm text-center">
<div class="mx-auto mb-4 flex size-12 items-center justify-center rounded-md border border-neutral-800 bg-neutral-900">
<FileText class="size-6 text-muted-foreground" />
</div>
<h2 class="text-lg font-semibold">Select a page</h2>
<p class="mt-2 text-sm text-muted-foreground">Open a markdown, MDX, or JSON file from the repository tree to start editing.</p>
</div>
</div>
{/if}
</div>
</div>
</main>
{#if manager.selectedPage?.supportsFrontmatter() && manager.selectedPage.frontmatterSchema}
<aside class="min-h-0 overflow-y-auto bg-neutral-950" transition:slide>
<FrontmatterEditor />
</aside>
{/if}
</div>
</div>
</div>
<style>
:global(.EasyMDEContainer) {
height: 100%;
display: flex;
min-height: 0;
flex-direction: column;
background: rgb(10 10 10);
}
:global(.EasyMDEContainer .CodeMirror) {
flex: 1;
min-height: 0;
height: 100%;
border: 0;
background: rgb(10 10 10);
color: rgb(245 245 245);
}
:global(.EasyMDEContainer .editor-toolbar) {
border: 0;
border-bottom: 1px solid rgb(38 38 38);
background: rgb(23 23 23);
}
:global(.EasyMDEContainer .editor-toolbar button) {
color: rgb(212 212 212) !important;
}
:global(.EasyMDEContainer .editor-toolbar button:hover),
:global(.EasyMDEContainer .editor-toolbar button.active) {
background: rgb(38 38 38);
border-color: rgb(64 64 64);
}
</style>
@@ -1,122 +1,309 @@
<script lang="ts">
import { X } from "lucide-svelte";
import { Button } from "@components/ui/button";
import { Input } from "@components/ui/input";
import { Label } from "@components/ui/label";
import { Checkbox } from "@components/ui/checkbox";
import { Plus, X } from "lucide-svelte";
import yaml from "js-yaml";
import { manager } from "./page.svelte";
import { slide } from "svelte/transition";
import type { FrontmatterFieldSchema } from "../../../../content/frontmatter-editor-schemas";
import EventSelector from "./frontmatter/EventSelector.svelte";
import ImageFrontmatterSelector from "./frontmatter/ImageFrontmatterSelector.svelte";
import ViewConfigEditor from "./frontmatter/ViewConfigEditor.svelte";
function markDirty() {
if (manager.selectedPage) {
manager.selectedPage.dirty = true;
}
}
function setField(key: string, value: unknown) {
if (!manager.selectedPage) {
return;
}
manager.selectedPage.frontmatter[key] = value;
markDirty();
}
function removeField(key: string) {
if (!manager.selectedPage) {
return;
}
delete manager.selectedPage.frontmatter[key];
markDirty();
}
function getFieldValue(field: FrontmatterFieldSchema) {
const existingValue = manager.selectedPage?.frontmatter[field.key];
if (existingValue !== undefined) {
return existingValue;
}
if (field.defaultValue !== undefined) {
return field.defaultValue;
}
switch (field.kind) {
case "boolean":
return false;
case "number":
return "";
case "string-array":
return [];
case "object":
return {};
default:
return "";
}
}
function dateInputValue(value: unknown) {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value.toISOString().split("T")[0];
}
if (typeof value === "string") {
return value.split("T")[0];
}
return "";
}
function yamlValue(value: unknown) {
if (value === undefined || value === null || value === "") {
return "";
}
return yaml.dump(value).trim();
}
function setYamlField(key: string, value: string) {
if (!value.trim()) {
setField(key, {});
return;
}
try {
setField(key, yaml.load(value));
} catch (error) {
alert(`Invalid YAML for ${key}: ${error instanceof Error ? error.message : String(error)}`);
}
}
function arrayValue(key: string): string[] {
const value = manager.selectedPage?.frontmatter[key];
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function setArrayItem(key: string, index: number, value: string) {
const values = arrayValue(key);
values[index] = value;
setField(key, values);
}
function addArrayItem(key: string) {
setField(key, [...arrayValue(key), ""]);
}
function removeArrayItem(key: string, index: number) {
setField(
key,
arrayValue(key).filter((_, itemIndex) => itemIndex !== index)
);
}
function renameCustomField(oldKey: string, newKey: string) {
if (!manager.selectedPage || !newKey || newKey === oldKey) {
return;
}
if (Object.hasOwn(manager.selectedPage.frontmatter, newKey)) {
alert(`A frontmatter field named "${newKey}" already exists.`);
return;
}
manager.selectedPage.frontmatter[newKey] = manager.selectedPage.frontmatter[oldKey];
delete manager.selectedPage.frontmatter[oldKey];
markDirty();
}
function addCustomField() {
if (!manager.selectedPage) {
return;
}
let index = 1;
let key = "customField";
while (Object.hasOwn(manager.selectedPage.frontmatter, key)) {
index += 1;
key = `customField${index}`;
}
setField(key, "");
}
function schemaKeys(schema = manager.selectedPage?.frontmatterSchema) {
return new Set(schema?.fields.map((field) => field.key) ?? []);
}
</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;
{#if manager.selectedPage?.frontmatterSchema}
{@const schema = manager.selectedPage.frontmatterSchema}
{@const customEntries = Object.entries(manager.selectedPage.frontmatter).filter(([key]) => !schemaKeys(schema).has(key))}
<details class="group" open>
<summary class="sticky top-0 z-10 flex cursor-pointer items-center justify-between border-b border-neutral-800 bg-neutral-950/95 px-4 py-3 backdrop-blur hover:bg-neutral-900">
<div>
<p class="text-sm font-semibold">{schema.label} Frontmatter</p>
<p class="text-xs text-muted-foreground">{schema.collection}</p>
</div>
<svg class="h-4 w-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="grid grid-cols-1 gap-4 bg-neutral-950 p-4">
{#each schema.fields as field (field.key)}
{@const value = getFieldValue(field)}
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<Label for={`frontmatter-${field.key}`} class="text-sm">
{field.label}
{#if field.required}
<span class="text-red-400">*</span>
{/if}
</Label>
{#if field.collection}
<span class="rounded border border-neutral-700 px-2 py-0.5 text-xs text-muted-foreground">{field.collection}</span>
{/if}
</div>
{#if field.key === "eventId"}
<EventSelector value={value as number | string | null | undefined} onSelect={(eventId) => setField(field.key, eventId)} />
{:else if field.key === "viewConfig"}
<ViewConfigEditor value={value} eventId={manager.selectedPage.frontmatter.eventId as number | string | null | undefined} onChange={(viewConfig) => setField(field.key, viewConfig)} />
{:else if field.kind === "image"}
<ImageFrontmatterSelector value={String(value ?? "")} onSelect={(path) => setField(field.key, path)} />
{:else if field.kind === "boolean"}
<label class="flex h-9 items-center gap-3 rounded-md border border-neutral-800 bg-neutral-900 px-3">
<Checkbox checked={Boolean(value)} onCheckedChange={(checked) => setField(field.key, checked === true)} />
<span class="text-sm">{Boolean(value) ? "True" : "False"}</span>
</label>
{:else if field.kind === "number"}
<Input
id={`frontmatter-${field.key}`}
type="number"
value={typeof value === "number" ? value : ""}
onchange={(event) => {
const nextValue = (event.currentTarget as HTMLInputElement).value;
setField(field.key, nextValue === "" ? "" : Number(nextValue));
}}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
/>
{:else if field.kind === "date"}
<Input
id={`frontmatter-${field.key}`}
type="date"
value={dateInputValue(value)}
onchange={(event) => {
const nextValue = (event.currentTarget as HTMLInputElement).value;
setField(field.key, nextValue ? new Date(`${nextValue}T00:00:00.000Z`) : "");
}}
/>
{:else if field.kind === "text"}
<textarea
id={`frontmatter-${field.key}`}
class="min-h-20 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
maxlength={field.maxLength}
value={String(value ?? "")}
oninput={(event) => setField(field.key, (event.currentTarget as HTMLTextAreaElement).value)}
></textarea>
{:else if field.kind === "string-array"}
<div class="space-y-2 rounded-md border border-neutral-800 bg-neutral-900 p-2">
{#each arrayValue(field.key) as item, index}
<div class="flex items-center gap-2">
<Input value={item} oninput={(event) => setArrayItem(field.key, index, (event.currentTarget as HTMLInputElement).value)} />
<Button type="button" variant="ghost" size="icon" onclick={() => removeArrayItem(field.key, index)}>
<X class="size-4" />
</Button>
</div>
{/each}
<Button type="button" variant="outline" size="sm" onclick={() => addArrayItem(field.key)}>
<Plus class="mr-2 size-4" />
Add item
</Button>
</div>
{:else if field.kind === "object"}
<textarea
id={`frontmatter-${field.key}`}
class="min-h-28 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={yamlValue(value)}
onchange={(event) => setYamlField(field.key, (event.currentTarget as HTMLTextAreaElement).value)}
></textarea>
{:else}
<input
<Input
id={`frontmatter-${field.key}`}
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"
maxlength={field.maxLength}
placeholder={field.kind === "reference" ? `Reference to ${field.collection}` : field.kind === "image" ? "Image path" : ""}
value={String(value ?? "")}
oninput={(event) => setField(field.key, (event.currentTarget as HTMLInputElement).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>
{#if field.description}
<p class="text-xs text-muted-foreground">{field.description}</p>
{/if}
</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>
{/each}
{#if customEntries.length > 0}
<div class="space-y-3 border-t border-neutral-800 pt-4">
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Custom fields</p>
{#each customEntries as [key, value] (key)}
<div class="rounded-md border border-neutral-800 bg-neutral-900 p-2">
<div class="grid gap-2">
<Input value={key} onchange={(event) => renameCustomField(key, (event.currentTarget as HTMLInputElement).value)} />
{#if Array.isArray(value)}
<div class="space-y-2">
{#each arrayValue(key) as item, index}
<div class="flex items-center gap-2">
<Input value={item} oninput={(event) => setArrayItem(key, index, (event.currentTarget as HTMLInputElement).value)} />
<Button type="button" variant="ghost" size="icon" onclick={() => removeArrayItem(key, index)}>
<X class="size-4" />
</Button>
</div>
{/each}
<Button type="button" variant="outline" size="sm" onclick={() => addArrayItem(key)}>
<Plus class="mr-2 size-4" />
Add item
</Button>
</div>
{:else if typeof value === "object" && value !== null}
<textarea
class="min-h-24 w-full rounded-md border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={yamlValue(value)}
onchange={(event) => setYamlField(key, (event.currentTarget as HTMLTextAreaElement).value)}
></textarea>
{:else}
<Input value={String(value ?? "")} onchange={(event) => setField(key, (event.currentTarget as HTMLInputElement).value)} />
{/if}
<Button type="button" variant="ghost" size="icon" onclick={() => removeField(key)}>
<X class="size-4" />
</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>
{/if}
<div>
<Button type="button" variant="outline" size="sm" onclick={addCustomField}>
<Plus class="mr-2 size-4" />
Add custom field
</Button>
</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>
</details>
{/if}
@@ -1,13 +1,12 @@
<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 { Check, ChevronsUpDown, RefreshCw, FileImage, Plus, GitBranch, Upload } 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";
@@ -18,16 +17,40 @@
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">
<div class="flex min-h-0 flex-grow flex-col bg-neutral-950">
<ResizablePaneGroup direction="horizontal" class="min-h-0 flex-grow">
<ResizablePane defaultSize={24} minSize={18} maxSize={36}>
<aside class="flex h-full min-h-0 flex-col border-r border-neutral-800 bg-neutral-950">
<div class="border-b border-neutral-800 p-3">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<h1 class="text-base font-semibold leading-none">Pages</h1>
<p class="mt-1 text-xs text-muted-foreground">Content repository</p>
</div>
<Button
size="icon"
variant="outline"
onclick={async () => {
const branchName = prompt("Enter branch name:");
if (branchName) {
await $pageRepo.createBranch(branchName);
manager.reloadBranches();
}
}}
>
<Plus class="size-4" />
</Button>
</div>
<div class="grid grid-cols-[1fr_auto_auto] gap-2">
<Popover bind:open={branchSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between flex-1" {...props} role="combobox">
{manager.branch}
<Button variant="outline" class="min-w-0 justify-between" {...props} role="combobox">
<span class="flex min-w-0 items-center gap-2">
<GitBranch class="size-4 shrink-0 text-muted-foreground" />
<span class="truncate">{manager.branch}</span>
</span>
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
@@ -62,23 +85,23 @@
</Command>
</PopoverContent>
</Popover>
<Button size="icon" variant="outline" onclick={() => manager.reloadImages()}>
<RefreshCw />
<Button size="icon" variant="outline" onclick={() => manager.reloadImages()} title="Refresh images">
<RefreshCw class="size-4" />
</Button>
<Popover bind:open={imageSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button size="icon" variant="outline" {...props}>
<FileImage />
<Button size="icon" variant="outline" {...props} title="Images">
<FileImage class="size-4" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent side="right" class="w-[1000px] h-screen overflow-y-auto">
<PopoverContent side="right" class="h-screen w-[960px] max-w-[calc(100vw-2rem)] overflow-y-auto p-0">
{#await manager.imagesLoad}
<p>Loading images...</p>
<p class="p-4 text-sm text-muted-foreground">Loading images...</p>
{:then images}
<div class="flex flex-col gap-2">
<div class="p-2">
<div class="sticky top-0 z-10 border-b border-neutral-800 bg-neutral-950/95 p-3 backdrop-blur">
<input
type="file"
accept="image/*"
@@ -100,13 +123,14 @@
}}
/>
<Button onclick={() => fileInput?.click()} class="w-full">
<Plus class="mr-2 size-4" />
<Upload class="mr-2 size-4" />
Upload Image
</Button>
</div>
<div class="grid grid-cols-4 gap-2 p-2">
<div class="grid grid-cols-3 gap-3 p-3 xl:grid-cols-4">
{#each images as image}
<button
class="overflow-hidden rounded-md border border-neutral-800 bg-neutral-900 text-left transition-colors hover:border-primary"
onclick={() => {
const backs = (manager.selectedPage?.path?.match(/\//g)?.length || 1) - 1;
@@ -116,7 +140,8 @@
imageSelectOpen = false;
}}
>
<img src={image.downloadUrl} alt={image.name} class="w-full h-auto object-cover" />
<img src={image.downloadUrl} alt={image.name} class="aspect-video w-full object-cover" />
<div class="truncate px-2 py-1.5 text-xs text-muted-foreground">{image.name}</div>
</button>
{/each}
</div>
@@ -124,31 +149,22 @@
{/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>
</div>
<Separator />
{#await manager.pagesLoad}
<p>Loading pages...</p>
{:then pages}
{#each Object.values(pages.dirs) as page}
<PagesList {page} path={page.name + "/"} />
{/each}
{/await}
</div>
<div class="min-h-0 flex-1 overflow-y-auto py-2">
{#await manager.pagesLoad}
<p class="px-4 py-3 text-sm text-muted-foreground">Loading pages...</p>
{:then pages}
{#each Object.values(pages.dirs) as page}
<PagesList {page} path={page.name + "/"} />
{/each}
{/await}
</div>
</aside>
</ResizablePane>
<ResizableHandle />
<ResizablePane defaultSize={80}>
<ResizablePane defaultSize={76}>
<EditorWithTabs />
</ResizablePane>
</ResizablePaneGroup>
@@ -1,5 +1,5 @@
<script lang="ts">
import { ChevronDown, ChevronRight, Folder, FolderPlus, FileJson, FileText, File, FilePlus } from "lucide-svelte";
import { ChevronDown, ChevronRight, Folder, FileJson, FileText, File, FilePlus } from "lucide-svelte";
import type { DirTree } from "./page.svelte";
import PagesList from "./PagesList.svelte";
import { slide } from "svelte/transition";
@@ -55,19 +55,23 @@
}
</script>
<button class={`group flex flex-row justify-between h-full w-full hover:bg-neutral-700 pl-${4 * depth}`} onclick={() => (open = !open)}>
<div class="flex flex-row items-center">
<button
class="group flex h-8 w-full items-center justify-between px-2 text-left text-sm text-neutral-200 transition-colors hover:bg-neutral-900"
style={`padding-left: ${0.75 + depth * 0.85}rem`}
onclick={() => (open = !open)}
>
<div class="flex min-w-0 flex-row items-center">
{#if open}
<ChevronDown class="w-6 h-6" />
<ChevronDown class="mr-1 size-4 shrink-0 text-muted-foreground" />
{:else}
<ChevronRight class="w-6 h-6" />
<ChevronRight class="mr-1 size-4 shrink-0 text-muted-foreground" />
{/if}
<Folder class="mr-2 w-4 h-4" />
{page.name}/
<Folder class="mr-2 size-4 shrink-0 text-amber-400" />
<span class="truncate">{page.name}/</span>
</div>
<div class="flex-row items-center hidden group-hover:flex">
<Button variant="ghost" size="sm" class="p-0 m-0 h-6 w-6" onclick={startNewPageCreate}>
<FilePlus class="w-3 h-3" />
<div class="hidden flex-row items-center group-hover:flex">
<Button variant="ghost" size="sm" class="m-0 size-6 p-0" onclick={startNewPageCreate} title="New page">
<FilePlus class="size-3.5" />
</Button>
</div>
</button>
@@ -76,39 +80,43 @@
<div transition:slide={{ duration: 200, axis: "y" }}>
<div>
{#if newPage}
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`}>
<div class="flex h-8 w-full flex-row items-center bg-neutral-900 px-2 py-1" style={`padding-left: ${0.75 + (depth + 1) * 0.85}rem`}>
{#if newPageName.endsWith(".json")}
<FileJson class="mr-2 w-4 h-4" />
<FileJson class="mr-2 size-4 shrink-0 text-sky-400" />
{:else if newPageName.endsWith(".md") || newPageName.endsWith(".mdx")}
<FileText class="mr-2 w-4 h-4" />
<FileText class="mr-2 size-4 shrink-0 text-emerald-400" />
{:else}
<File class="mr-2 w-4 h-4" />
<File class="mr-2 size-4 shrink-0 text-muted-foreground" />
{/if}
<form onsubmit={createNewPage}>
<form onsubmit={createNewPage} class="min-w-0 flex-1">
<input
type="text"
bind:value={newPageName}
bind:this={newPageInput}
onblur={() => (newPage = false)}
placeholder="New page name"
class="flex-grow bg-transparent border-none outline-none text-white"
class="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1 text-sm text-white outline-none focus:border-primary"
/>
</form>
</button>
</div>
{/if}
{#each Object.values(page.dirs) as subPage (subPage.name)}
<PagesList page={subPage} depth={depth + 1} path={path + subPage.name + "/"} />
{/each}
{#each Object.values(page.files) as file (file.id)}
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`} onclick={() => manager.openPage(file.id)}>
<button
class="flex h-8 w-full min-w-0 flex-row items-center px-2 py-1 text-left text-sm text-muted-foreground transition-colors hover:bg-neutral-900 hover:text-foreground"
style={`padding-left: ${0.75 + (depth + 1) * 0.85}rem`}
onclick={() => manager.openPage(file.id)}
>
{#if file.name.endsWith(".json")}
<FileJson class="mr-2 w-4 h-4" />
<FileJson class="mr-2 size-4 shrink-0 text-sky-400" />
{:else if file.name.endsWith(".md") || file.name.endsWith(".mdx")}
<FileText class="mr-2 w-4 h-4" />
<FileText class="mr-2 size-4 shrink-0 text-emerald-400" />
{:else}
<File class="mr-2 w-4 h-4" />
<File class="mr-2 size-4 shrink-0" />
{/if}
{file.name}
<span class="truncate">{file.name}</span>
</button>
{/each}
</div>
@@ -0,0 +1,92 @@
<script lang="ts">
import { Button } from "@components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { eventRepo } from "@repo/event";
import type { ShortEvent } from "@type/event";
import { CalendarDays, Check, ChevronsUpDown } from "lucide-svelte";
const {
value,
onSelect,
}: {
value: number | string | null | undefined;
onSelect: (eventId: number) => void;
} = $props();
let open = $state(false);
let eventsFuture = $state($eventRepo.listEvents());
function selectedEvent(events: ShortEvent[]) {
const eventId = Number(value);
return events.find((event) => event.id === eventId);
}
function formatDate(timestamp: number) {
return new Date(timestamp).toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
function sortEvents(events: ShortEvent[]) {
return [...events].sort((a, b) => b.start - a.start);
}
</script>
<Popover bind:open>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="w-full justify-between" {...props} role="combobox">
{#await eventsFuture}
Loading events...
{:then events}
{@const event = selectedEvent(events)}
{#if event}
<span class="truncate">{event.name} #{event.id}</span>
{:else}
<span class="text-muted-foreground">Select event</span>
{/if}
{:catch}
<span class="text-red-400">Could not load events</span>
{/await}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-[36rem] max-w-[calc(100vw-2rem)] p-0">
<Command>
<CommandInput placeholder="Search events..." />
<CommandList>
<CommandEmpty>No events found.</CommandEmpty>
<CommandGroup>
{#await eventsFuture}
<div class="p-3 text-sm text-muted-foreground">Loading events...</div>
{:then events}
{#each sortEvents(events) as event (event.id)}
<CommandItem
value={`${event.name} ${event.id}`}
onSelect={() => {
onSelect(event.id);
open = false;
}}
>
<Check class="mr-2 size-4 {Number(value) === event.id ? '' : 'text-transparent'}" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm">{event.name}</div>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarDays class="size-3" />
#{event.id} · {formatDate(event.start)}
</div>
</div>
</CommandItem>
{/each}
{:catch error}
<div class="p-3 text-sm text-red-400">{error instanceof Error ? error.message : "Failed to load events."}</div>
{/await}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
@@ -0,0 +1,74 @@
<script lang="ts">
import { Button } from "@components/ui/button";
import { Input } from "@components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Image, Search } from "lucide-svelte";
import { manager } from "../page.svelte";
const {
value,
onSelect,
}: {
value: string | null | undefined;
onSelect: (path: string) => void;
} = $props();
let open = $state(false);
let search = $state("");
function imagePath(path: string) {
const backs = (manager.selectedPage?.path?.match(/\//g)?.length || 1) - 1;
return [...Array(backs).fill(".."), path.replace("src/", "")].join("/");
}
</script>
<div class="flex gap-2">
<Input value={value ?? ""} placeholder="Image path" oninput={(event) => onSelect((event.currentTarget as HTMLInputElement).value)} />
<Popover bind:open>
<PopoverTrigger>
{#snippet child({ props })}
<Button type="button" variant="outline" size="icon" {...props}>
<Image class="size-4" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="h-[34rem] w-[56rem] max-w-[calc(100vw-2rem)] overflow-hidden p-0" side="bottom">
<div class="flex items-center gap-2 border-b border-neutral-800 p-3">
<Search class="size-4 text-muted-foreground" />
<Input bind:value={search} placeholder="Search images..." class="border-0 bg-transparent shadow-none focus-visible:ring-0" />
</div>
<div class="h-[30rem] overflow-y-auto p-3">
{#await manager.imagesLoad}
<p class="text-sm text-muted-foreground">Loading images...</p>
{:then images}
{@const filteredImages = images.filter((image) => image.name.toLowerCase().includes(search.toLowerCase()) || image.path.toLowerCase().includes(search.toLowerCase()))}
{#if filteredImages.length === 0}
<p class="text-sm text-muted-foreground">No images found.</p>
{:else}
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
{#each filteredImages as image (image.id)}
{@const path = imagePath(image.path)}
<button
type="button"
class="group overflow-hidden rounded-md border border-neutral-800 bg-neutral-900 text-left transition-colors hover:border-primary"
onclick={() => {
onSelect(path);
open = false;
}}
>
<img src={image.downloadUrl} alt={image.name} class="aspect-video w-full bg-neutral-950 object-cover" />
<div class="space-y-1 p-2">
<div class="truncate text-xs font-medium">{image.name}</div>
<div class="truncate text-[11px] text-muted-foreground">{path}</div>
</div>
</button>
{/each}
</div>
{/if}
{:catch error}
<p class="text-sm text-red-400">{error instanceof Error ? error.message : "Failed to load images."}</p>
{/await}
</div>
</PopoverContent>
</Popover>
</div>
@@ -0,0 +1,299 @@
<script lang="ts">
import { Button } from "@components/ui/button";
import { Input } from "@components/ui/input";
import { Label } from "@components/ui/label";
import { eventRepo } from "@repo/event";
import type { EventFight, ExtendedEvent, ResponseGroups } from "@type/event";
import { Plus, Trash2 } from "lucide-svelte";
type StageType = "GROUP" | "ELEMINATION" | "DOUBLE_ELEMINATION";
type StageConfig = {
name: string;
view:
| { type: "GROUP"; groups: number[]; roundRows?: number }
| { type: "ELEMINATION"; finalFight: number }
| { type: "DOUBLE_ELEMINATION"; winnersFinalFight: number; losersFinalFight: number; grandFinalFight: number };
};
type ViewConfig = Record<string, StageConfig>;
const {
value,
eventId,
onChange,
}: {
value: unknown;
eventId: number | string | null | undefined;
onChange: (value: ViewConfig) => void;
} = $props();
let eventFuture: Promise<ExtendedEvent> | undefined = $state();
const config = $derived(normalizeConfig(value));
const selectedEventId = $derived(Number(eventId));
$effect(() => {
if (Number.isFinite(selectedEventId) && selectedEventId > 0) {
eventFuture = $eventRepo.getEvent(selectedEventId.toString());
} else {
eventFuture = undefined;
}
});
function normalizeConfig(raw: unknown): ViewConfig {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
return raw as ViewConfig;
}
function cloneConfig(value: ViewConfig): ViewConfig {
return JSON.parse(JSON.stringify(value)) as ViewConfig;
}
function nextConfig(mutator: (draft: ViewConfig) => void) {
const draft = cloneConfig(config);
mutator(draft);
onChange(draft);
}
function addStage() {
nextConfig((draft) => {
let index = Object.keys(draft).length + 1;
let key = `stage${index}`;
while (Object.hasOwn(draft, key)) {
index += 1;
key = `stage${index}`;
}
draft[key] = {
name: "New stage",
view: { type: "GROUP", groups: [], roundRows: 1 },
};
});
}
function renameStage(oldKey: string, newKey: string) {
const cleanKey = newKey.trim();
if (!cleanKey || cleanKey === oldKey) {
return;
}
nextConfig((draft) => {
if (Object.hasOwn(draft, cleanKey)) {
alert(`A view config stage named "${cleanKey}" already exists.`);
return;
}
draft[cleanKey] = draft[oldKey];
delete draft[oldKey];
});
}
function removeStage(key: string) {
nextConfig((draft) => {
delete draft[key];
});
}
function setStageName(key: string, name: string) {
nextConfig((draft) => {
draft[key].name = name;
});
}
function setStageType(key: string, type: StageType, event?: ExtendedEvent) {
nextConfig((draft) => {
if (type === "GROUP") {
draft[key].view = { type, groups: [], roundRows: 1 };
} else if (type === "ELEMINATION") {
draft[key].view = { type, finalFight: event?.fights[0]?.id ?? 0 };
} else {
draft[key].view = {
type,
winnersFinalFight: event?.fights[0]?.id ?? 0,
losersFinalFight: event?.fights[0]?.id ?? 0,
grandFinalFight: event?.fights[0]?.id ?? 0,
};
}
});
}
function setGroupSelection(key: string, groupId: number, selected: boolean) {
nextConfig((draft) => {
const view = draft[key].view;
if (view.type !== "GROUP") {
return;
}
view.groups = selected ? [...new Set([...view.groups, groupId])] : view.groups.filter((id) => id !== groupId);
});
}
function setRoundRows(key: string, value: string) {
nextConfig((draft) => {
const view = draft[key].view;
if (view.type === "GROUP") {
view.roundRows = Math.max(1, Number(value) || 1);
}
});
}
function setFightParam(key: string, param: "finalFight" | "winnersFinalFight" | "losersFinalFight" | "grandFinalFight", fightId: string) {
nextConfig((draft) => {
const view = draft[key].view as Record<string, unknown>;
view[param] = Number(fightId);
});
}
function groupLabel(group: ResponseGroups) {
return `${group.name} #${group.id}`;
}
function fightLabel(fight: EventFight) {
const start = new Date(fight.start).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
return `#${fight.id} ${fight.blueTeam.kuerzel} vs ${fight.redTeam.kuerzel} · ${start}`;
}
</script>
<div class="space-y-3 rounded-md border border-neutral-800 bg-neutral-900 p-3">
{#if !selectedEventId}
<p class="text-sm text-muted-foreground">Select an eventId first to edit the view config with event groups and fights.</p>
{:else if eventFuture}
{#await eventFuture}
<p class="text-sm text-muted-foreground">Loading event context...</p>
{:then event}
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium">{event.event.name}</p>
<p class="text-xs text-muted-foreground">{event.groups.length} groups · {event.fights.length} fights</p>
</div>
<Button type="button" variant="outline" size="sm" onclick={addStage}>
<Plus class="mr-2 size-4" />
Add stage
</Button>
</div>
{#if Object.entries(config).length === 0}
<div class="rounded-md border border-dashed border-neutral-700 p-4 text-sm text-muted-foreground">No stages configured.</div>
{/if}
{#each Object.entries(config) as [key, stage] (key)}
<div class="space-y-3 rounded-md border border-neutral-800 bg-neutral-950 p-3">
<div class="flex items-center justify-between gap-3 border-b border-neutral-800 pb-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{stage.name || key}</p>
<p class="text-xs text-muted-foreground">{stage.view.type}</p>
</div>
<Button type="button" variant="outline" size="sm" onclick={() => removeStage(key)}>
<Trash2 class="mr-2 size-4" />
Remove stage
</Button>
</div>
<div class="grid gap-2">
<div class="space-y-1">
<Label>Key</Label>
<Input value={key} onchange={(event) => renameStage(key, (event.currentTarget as HTMLInputElement).value)} />
</div>
<div class="space-y-1">
<Label>Name</Label>
<Input value={stage.name} oninput={(event) => setStageName(key, (event.currentTarget as HTMLInputElement).value)} />
</div>
<div class="space-y-1">
<Label>Type</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={stage.view.type}
onchange={(changeEvent) => setStageType(key, (changeEvent.currentTarget as HTMLSelectElement).value as StageType, event)}
>
<option value="GROUP">Group</option>
<option value="ELEMINATION">Elimination</option>
<option value="DOUBLE_ELEMINATION">Double elimination</option>
</select>
</div>
</div>
{#if stage.view.type === "GROUP"}
<div class="space-y-2">
<div class="grid gap-2">
<div>
<Label>Groups</Label>
<div class="mt-2 grid gap-2">
{#each event.groups as group (group.id)}
<label class="flex items-center gap-2 rounded border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm">
<input
type="checkbox"
checked={stage.view.groups.includes(group.id)}
onchange={(changeEvent) => setGroupSelection(key, group.id, (changeEvent.currentTarget as HTMLInputElement).checked)}
/>
<span>{groupLabel(group)}</span>
</label>
{/each}
</div>
</div>
<div class="space-y-1">
<Label>Round rows</Label>
<Input type="number" min="1" value={stage.view.roundRows ?? 1} onchange={(event) => setRoundRows(key, (event.currentTarget as HTMLInputElement).value)} />
</div>
</div>
</div>
{:else if stage.view.type === "ELEMINATION"}
<div class="space-y-1">
<Label>Final fight</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={stage.view.finalFight.toString()}
onchange={(event) => setFightParam(key, "finalFight", (event.currentTarget as HTMLSelectElement).value)}
>
{#each event.fights as fight (fight.id)}
<option value={fight.id.toString()}>{fightLabel(fight)}</option>
{/each}
</select>
</div>
{:else}
<div class="grid gap-2">
<div class="space-y-1">
<Label>Winners final</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={stage.view.winnersFinalFight.toString()}
onchange={(event) => setFightParam(key, "winnersFinalFight", (event.currentTarget as HTMLSelectElement).value)}
>
{#each event.fights as fight (fight.id)}
<option value={fight.id.toString()}>{fightLabel(fight)}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label>Losers final</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={stage.view.losersFinalFight.toString()}
onchange={(event) => setFightParam(key, "losersFinalFight", (event.currentTarget as HTMLSelectElement).value)}
>
{#each event.fights as fight (fight.id)}
<option value={fight.id.toString()}>{fightLabel(fight)}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label>Grand final</Label>
<select
class="h-9 w-full rounded-md border border-neutral-800 bg-neutral-900 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={stage.view.grandFinalFight.toString()}
onchange={(event) => setFightParam(key, "grandFinalFight", (event.currentTarget as HTMLSelectElement).value)}
>
{#each event.fights as fight (fight.id)}
<option value={fight.id.toString()}>{fightLabel(fight)}</option>
{/each}
</select>
</div>
</div>
{/if}
</div>
{/each}
{:catch error}
<p class="text-sm text-red-400">{error instanceof Error ? error.message : "Failed to load event context."}</p>
{/await}
{/if}
</div>
@@ -3,13 +3,18 @@ import { pageRepo } from "@components/repo/page";
import type { ListPage, PageList } from "@components/types/page";
import { get } from "svelte/store";
import yaml from "js-yaml";
import { getMarkdownFrontmatterSchema, type FrontmatterCollectionSchema } from "../../../../content/frontmatter-editor-schemas";
type FrontmatterValue = string | string[] | number | boolean | Date | Record<string, unknown> | unknown[] | null;
export class OpenEditPage {
public content: string = "";
public frontmatter: { [key: string]: string | string[] | Date } = $state({});
public frontmatter: { [key: string]: FrontmatterValue } = $state({});
public dirty: boolean = $state(false);
public readonly fileType: string;
public readonly collection: string | undefined;
public readonly frontmatterSchema: FrontmatterCollectionSchema | undefined;
public constructor(
private manager: PageManager,
@@ -20,6 +25,8 @@ export class OpenEditPage {
public readonly path: string
) {
this.fileType = this.path.split(".").pop() || "md";
this.collection = this.resolveContentCollection();
this.frontmatterSchema = getMarkdownFrontmatterSchema(this.collection);
this.content = this.removeFrontmatter(originalContent);
this.frontmatter = this.parseFrontmatter(originalContent);
@@ -31,7 +38,7 @@ export class OpenEditPage {
}
let contentToSave = "";
if (this.frontmatter) {
if (this.hasFrontmatter()) {
contentToSave += "---\n";
contentToSave += yaml.dump(this.frontmatter);
contentToSave += "---\n\n";
@@ -54,31 +61,49 @@ export class OpenEditPage {
return false;
}
private parseFrontmatter(content: string): { [key: string]: string | string[] | Date } {
public supportsFrontmatter(): boolean {
return (this.fileType === "md" || this.fileType === "mdx") && !!this.collection;
}
private hasFrontmatter(): boolean {
return this.supportsFrontmatter() && Object.keys(this.frontmatter).length > 0;
}
private resolveContentCollection(): string | undefined {
const match = this.path.match(/^src\/content\/([^/]+)\//);
return match?.[1];
}
private parseFrontmatter(content: string): { [key: string]: FrontmatterValue } {
if (!this.supportsFrontmatter()) {
return {};
}
const lines = content.split("\n");
let inFrontmatter = false;
const frontmatterLines: string[] = [];
for (const line of lines) {
if (line.trim() === "---") {
if (inFrontmatter) {
break; // End of frontmatter
}
inFrontmatter = true;
continue;
}
if (inFrontmatter) {
frontmatterLines.push(line);
}
if (lines[0]?.trim() !== "---") {
return {};
}
if (frontmatterLines.length === 0) {
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === "---") {
inFrontmatter = false;
break;
}
inFrontmatter = true;
frontmatterLines.push(line);
}
if (inFrontmatter || 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 };
return (yaml.load(frontmatterLines.join("\n")) || {}) as { [key: string]: FrontmatterValue };
} catch (error) {
console.error("Failed to parse YAML frontmatter:", error);
return {};
@@ -86,21 +111,21 @@ export class OpenEditPage {
}
private removeFrontmatter(content: string): string {
const lines = content.split("\n");
let inFrontmatter = false;
const result: string[] = [];
for (const line of lines) {
if (line.trim() === "---") {
inFrontmatter = !inFrontmatter;
continue;
}
if (!inFrontmatter) {
result.push(line);
}
if (!this.supportsFrontmatter()) {
return content;
}
return result.join("\n").trim();
const lines = content.split("\n");
if (lines[0]?.trim() !== "---") {
return content;
}
const endIndex = lines.slice(1).findIndex((line) => line.trim() === "---");
if (endIndex === -1) {
return content;
}
return lines.slice(endIndex + 2).join("\n").trim();
}
}
@@ -136,7 +161,7 @@ export class PageManager {
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 {
private _t<T>(_n: number): (v: T) => T {
return (v: T) => v;
}
+1
View File
@@ -21,6 +21,7 @@ import { defineCollection, reference, z } from "astro:content";
import { docsLoader } from "@astrojs/starlight/loaders";
import { docsSchema } from "@astrojs/starlight/schema";
import { EventViewConfigSchema } from "@components/event/types";
export { markdownFrontmatterSchemas } from "./frontmatter-editor-schemas";
export const pagesSchema = z.object({
title: z.string().min(1).max(80),
+93
View File
@@ -0,0 +1,93 @@
export type FrontmatterFieldKind = "string" | "text" | "number" | "boolean" | "date" | "string-array" | "reference" | "image" | "object";
export interface FrontmatterFieldSchema {
key: string;
label: string;
kind: FrontmatterFieldKind;
required?: boolean;
maxLength?: number;
collection?: string;
defaultValue?: unknown;
description?: string;
}
export interface FrontmatterCollectionSchema {
collection: string;
label: string;
fields: FrontmatterFieldSchema[];
}
export const markdownFrontmatterSchemas: Record<string, FrontmatterCollectionSchema> = {
pages: {
collection: "pages",
label: "Pages",
fields: [
{ key: "title", label: "Title", kind: "string", required: true, maxLength: 80 },
{ key: "description", label: "Description", kind: "text", required: true, maxLength: 120 },
{ key: "image", label: "Image", kind: "image" },
{ key: "slugs", label: "Slugs", kind: "object", description: "Language to slug mapping, for example { de: 'seite', en: 'page' }." },
],
},
help: {
collection: "help",
label: "Help",
fields: [
{ key: "title", label: "Title", kind: "string", required: true, maxLength: 80 },
{ key: "description", label: "Description", kind: "text", required: true, maxLength: 120 },
{ key: "tags", label: "Tags", kind: "string-array", required: true },
{ key: "related", label: "Related", kind: "string-array", collection: "help", description: "IDs of related help pages." },
],
},
rules: {
collection: "rules",
label: "Rules",
fields: [
{ key: "translationKey", label: "Translation key", kind: "string", required: true },
{ key: "mode", label: "Mode", kind: "reference", collection: "modes" },
],
},
announcements: {
collection: "announcements",
label: "Announcements",
fields: [
{ key: "title", label: "Title", kind: "string", required: true },
{ key: "description", label: "Description", kind: "text", required: true },
{ key: "author", label: "Author", kind: "string" },
{ key: "image", label: "Image", kind: "image" },
{ key: "tags", label: "Tags", kind: "string-array", required: true },
{ key: "created", label: "Created", kind: "date", required: true },
{ key: "key", label: "Key", kind: "string", required: true },
],
},
events: {
collection: "events",
label: "Events",
fields: [
{ key: "eventId", label: "Event ID", kind: "number", required: true },
{ key: "image", label: "Image", kind: "image" },
{ key: "mode", label: "Mode", kind: "reference", collection: "modes" },
{ key: "hideTeamSize", label: "Hide team size", kind: "boolean", defaultValue: false },
{ key: "verantwortlich", label: "Verantwortlich", kind: "string" },
{ key: "viewConfig", label: "View config", kind: "object" },
],
},
docs: {
collection: "docs",
label: "Docs",
fields: [
{ key: "title", label: "Title", kind: "string", required: true },
{ key: "description", label: "Description", kind: "text" },
{ key: "sidebar", label: "Sidebar", kind: "object" },
{ key: "template", label: "Template", kind: "string" },
{ key: "draft", label: "Draft", kind: "boolean" },
],
},
};
export function getMarkdownFrontmatterSchema(collection: string | undefined): FrontmatterCollectionSchema | undefined {
if (!collection) {
return undefined;
}
return markdownFrontmatterSchemas[collection];
}