From 3b7aafd56eb057476d291873e12c8e4f716d26a6 Mon Sep 17 00:00:00 2001 From: Chaoscaot Date: Thu, 7 May 2026 15:56:58 +0200 Subject: [PATCH] Add schema-driven frontmatter editor - Render frontmatter fields from content schemas - Add specialized selectors for events, images, and view config - Refresh the pages editor layout and tab bar --- .../pages/pages/EditorWithTabs.svelte | 173 ++++++-- .../pages/pages/FrontmatterEditor.svelte | 403 +++++++++++++----- .../moderator/pages/pages/Pages.svelte | 98 +++-- .../moderator/pages/pages/PagesList.svelte | 52 ++- .../pages/frontmatter/EventSelector.svelte | 92 ++++ .../ImageFrontmatterSelector.svelte | 74 ++++ .../pages/frontmatter/ViewConfigEditor.svelte | 299 +++++++++++++ .../moderator/pages/pages/page.svelte.ts | 87 ++-- src/content/config.ts | 1 + src/content/frontmatter-editor-schemas.ts | 93 ++++ 10 files changed, 1127 insertions(+), 245 deletions(-) create mode 100644 src/components/moderator/pages/pages/frontmatter/EventSelector.svelte create mode 100644 src/components/moderator/pages/pages/frontmatter/ImageFrontmatterSelector.svelte create mode 100644 src/components/moderator/pages/pages/frontmatter/ViewConfigEditor.svelte create mode 100644 src/content/frontmatter-editor-schemas.ts diff --git a/src/components/moderator/pages/pages/EditorWithTabs.svelte b/src/components/moderator/pages/pages/EditorWithTabs.svelte index a9e17da..e8587a4 100644 --- a/src/components/moderator/pages/pages/EditorWithTabs.svelte +++ b/src/components/moderator/pages/pages/EditorWithTabs.svelte @@ -1,7 +1,6 @@ -
-
- {#each manager.pages as tab, index} - {@const isActive = manager.openPageIndex === index} - - {/each} -
- -
- {#if manager.selectedPage} -
- -
-
- {#if manager.selectedPage.path.startsWith("src/content/announcements/")} -
- -
- {/if} -
+ {#if tab.fileType === "json"} + + {:else if tab.fileType === "md" || tab.fileType === "mdx"} + + {:else} + + {/if} + {tab.pageTitle} + {#if tab.dirty} + + {/if} + { + e.stopPropagation(); + manager.closePage(index); + }}> + + {/each} {/if} -
- - + +
+ {#if manager.selectedPage} +
+
+
+

{manager.selectedPage.pageTitle}

+ {#if manager.selectedPage.dirty} + Unsaved + {/if} +
+

{manager.selectedPage.path}

+
+ +
+ {/if} + +
+
+
+
Content
+
+ + + + {#if !manager.selectedPage} +
+
+
+ +
+

Select a page

+

Open a markdown, MDX, or JSON file from the repository tree to start editing.

+
+
+ {/if} +
+
+ + {#if manager.selectedPage?.supportsFrontmatter() && manager.selectedPage.frontmatterSchema} + + {/if}
+ + diff --git a/src/components/moderator/pages/pages/FrontmatterEditor.svelte b/src/components/moderator/pages/pages/FrontmatterEditor.svelte index 0c889e4..248d1f2 100644 --- a/src/components/moderator/pages/pages/FrontmatterEditor.svelte +++ b/src/components/moderator/pages/pages/FrontmatterEditor.svelte @@ -1,122 +1,309 @@ -
- - 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; +{#if manager.selectedPage?.frontmatterSchema} + {@const schema = manager.selectedPage.frontmatterSchema} + {@const customEntries = Object.entries(manager.selectedPage.frontmatter).filter(([key]) => !schemaKeys(schema).has(key))} + +
+ +
+

{schema.label} Frontmatter

+

{schema.collection}

+
+ + + +
+ +
+ {#each schema.fields as field (field.key)} + {@const value = getFieldValue(field)} +
+
+ + {#if field.collection} + {field.collection} + {/if} +
+ + {#if field.key === "eventId"} + setField(field.key, eventId)} /> + {:else if field.key === "viewConfig"} + setField(field.key, viewConfig)} /> + {:else if field.kind === "image"} + setField(field.key, path)} /> + {:else if field.kind === "boolean"} + + {:else if field.kind === "number"} + { + 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"} + { + const nextValue = (event.currentTarget as HTMLInputElement).value; + setField(field.key, nextValue ? new Date(`${nextValue}T00:00:00.000Z`) : ""); + }} + /> + {:else if field.kind === "text"} + + {:else if field.kind === "string-array"} +
+ {#each arrayValue(field.key) as item, index} +
+ setArrayItem(field.key, index, (event.currentTarget as HTMLInputElement).value)} /> + +
+ {/each} + +
+ {:else if field.kind === "object"} + {:else} - (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} - + + {#if field.description} +

{field.description}

+ {/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 customEntries.length > 0} +
+

Custom fields

+ {#each customEntries as [key, value] (key)} +
+
+ renameCustomField(key, (event.currentTarget as HTMLInputElement).value)} /> + {#if Array.isArray(value)} +
+ {#each arrayValue(key) as item, index} +
+ setArrayItem(key, index, (event.currentTarget as HTMLInputElement).value)} /> + +
+ {/each} + +
+ {:else if typeof value === "object" && value !== null} + + {:else} + setField(key, (event.currentTarget as HTMLInputElement).value)} /> + {/if} +
- {/each} - -
- {/if} +
+ {/each} +
+ {/if} + +
+
- {/each} -
- -
-
-
+
+{/if} diff --git a/src/components/moderator/pages/pages/Pages.svelte b/src/components/moderator/pages/pages/Pages.svelte index c71a8ec..593f592 100644 --- a/src/components/moderator/pages/pages/Pages.svelte +++ b/src/components/moderator/pages/pages/Pages.svelte @@ -1,13 +1,12 @@ -
- - -
-
+
+ + + - + diff --git a/src/components/moderator/pages/pages/PagesList.svelte b/src/components/moderator/pages/pages/PagesList.svelte index 5a730fd..524189b 100644 --- a/src/components/moderator/pages/pages/PagesList.svelte +++ b/src/components/moderator/pages/pages/PagesList.svelte @@ -1,5 +1,5 @@ -
@@ -76,39 +80,43 @@
{#if newPage} - +
{/if} {#each Object.values(page.dirs) as subPage (subPage.name)} {/each} {#each Object.values(page.files) as file (file.id)} - {/each}
diff --git a/src/components/moderator/pages/pages/frontmatter/EventSelector.svelte b/src/components/moderator/pages/pages/frontmatter/EventSelector.svelte new file mode 100644 index 0000000..0ee874b --- /dev/null +++ b/src/components/moderator/pages/pages/frontmatter/EventSelector.svelte @@ -0,0 +1,92 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + + + + No events found. + + {#await eventsFuture} +
Loading events...
+ {:then events} + {#each sortEvents(events) as event (event.id)} + { + onSelect(event.id); + open = false; + }} + > + +
+
{event.name}
+
+ + #{event.id} · {formatDate(event.start)} +
+
+
+ {/each} + {:catch error} +
{error instanceof Error ? error.message : "Failed to load events."}
+ {/await} +
+
+
+
+
diff --git a/src/components/moderator/pages/pages/frontmatter/ImageFrontmatterSelector.svelte b/src/components/moderator/pages/pages/frontmatter/ImageFrontmatterSelector.svelte new file mode 100644 index 0000000..4512775 --- /dev/null +++ b/src/components/moderator/pages/pages/frontmatter/ImageFrontmatterSelector.svelte @@ -0,0 +1,74 @@ + + +
+ onSelect((event.currentTarget as HTMLInputElement).value)} /> + + + {#snippet child({ props })} + + {/snippet} + + +
+ + +
+
+ {#await manager.imagesLoad} +

Loading images...

+ {:then images} + {@const filteredImages = images.filter((image) => image.name.toLowerCase().includes(search.toLowerCase()) || image.path.toLowerCase().includes(search.toLowerCase()))} + {#if filteredImages.length === 0} +

No images found.

+ {:else} +
+ {#each filteredImages as image (image.id)} + {@const path = imagePath(image.path)} + + {/each} +
+ {/if} + {:catch error} +

{error instanceof Error ? error.message : "Failed to load images."}

+ {/await} +
+
+
+
diff --git a/src/components/moderator/pages/pages/frontmatter/ViewConfigEditor.svelte b/src/components/moderator/pages/pages/frontmatter/ViewConfigEditor.svelte new file mode 100644 index 0000000..05c325a --- /dev/null +++ b/src/components/moderator/pages/pages/frontmatter/ViewConfigEditor.svelte @@ -0,0 +1,299 @@ + + +
+ {#if !selectedEventId} +

Select an eventId first to edit the view config with event groups and fights.

+ {:else if eventFuture} + {#await eventFuture} +

Loading event context...

+ {:then event} +
+
+

{event.event.name}

+

{event.groups.length} groups · {event.fights.length} fights

+
+ +
+ + {#if Object.entries(config).length === 0} +
No stages configured.
+ {/if} + + {#each Object.entries(config) as [key, stage] (key)} +
+
+
+

{stage.name || key}

+

{stage.view.type}

+
+ +
+
+
+ + renameStage(key, (event.currentTarget as HTMLInputElement).value)} /> +
+
+ + setStageName(key, (event.currentTarget as HTMLInputElement).value)} /> +
+
+ + +
+
+ + {#if stage.view.type === "GROUP"} +
+
+
+ +
+ {#each event.groups as group (group.id)} + + {/each} +
+
+
+ + setRoundRows(key, (event.currentTarget as HTMLInputElement).value)} /> +
+
+
+ {:else if stage.view.type === "ELEMINATION"} +
+ + +
+ {:else} +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} +
+ {/each} + {:catch error} +

{error instanceof Error ? error.message : "Failed to load event context."}

+ {/await} + {/if} +
diff --git a/src/components/moderator/pages/pages/page.svelte.ts b/src/components/moderator/pages/pages/page.svelte.ts index 5914023..e9f4328 100644 --- a/src/components/moderator/pages/pages/page.svelte.ts +++ b/src/components/moderator/pages/pages/page.svelte.ts @@ -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 | 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(n: number): (v: T) => T { + private _t(_n: number): (v: T) => T { return (v: T) => v; } diff --git a/src/content/config.ts b/src/content/config.ts index c502cbc..294275f 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -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), diff --git a/src/content/frontmatter-editor-schemas.ts b/src/content/frontmatter-editor-schemas.ts new file mode 100644 index 0000000..457ef3a --- /dev/null +++ b/src/content/frontmatter-editor-schemas.ts @@ -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 = { + 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]; +}