Merge pull request 'Add schema-driven frontmatter editor' (#24) from feature/frontmatter-schema-editor into master
SteamWarCI Build successful
SteamWarCI Build successful
Reviewed-on: #24 Reviewed-by: D4rkr34lm <dark@steamwar.de>
This commit was merged in pull request #24.
This commit is contained in:
@@ -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">
|
||||
<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="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' : ''}"
|
||||
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)}
|
||||
>
|
||||
<File class="h-4 w-4 mr-2" />
|
||||
{tab.pageTitle}
|
||||
{#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="mx-4 hover:bg-neutral-700 transition-all rounded {isActive ? '' : 'opacity-0'} group-hover:opacity-100 cursor-pointer"
|
||||
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 /></span
|
||||
}}><X class="size-4" /></span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="flex-1 flex flex-col">
|
||||
|
||||
<div class="flex min-h-0 flex-1 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>
|
||||
<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="flex-1">
|
||||
|
||||
<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">
|
||||
{#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="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;
|
||||
}
|
||||
|
||||
<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-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
|
||||
{:else if field.kind === "date"}
|
||||
<Input
|
||||
id={`frontmatter-${field.key}`}
|
||||
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;
|
||||
value={dateInputValue(value)}
|
||||
onchange={(event) => {
|
||||
const nextValue = (event.currentTarget as HTMLInputElement).value;
|
||||
setField(field.key, nextValue ? new Date(`${nextValue}T00:00:00.000Z`) : "");
|
||||
}}
|
||||
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}
|
||||
{: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">
|
||||
<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>
|
||||
<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
|
||||
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>
|
||||
<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
|
||||
id={`frontmatter-${field.key}`}
|
||||
type="text"
|
||||
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}
|
||||
<p class="text-xs text-muted-foreground">{field.description}</p>
|
||||
{/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>
|
||||
|
||||
{#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>
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addCustomField}>
|
||||
<Plus class="mr-2 size-4" />
|
||||
Add custom field
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto py-2">
|
||||
{#await manager.pagesLoad}
|
||||
<p>Loading pages...</p>
|
||||
<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 {
|
||||
if (!this.supportsFrontmatter()) {
|
||||
return content;
|
||||
}
|
||||
|
||||
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 (lines[0]?.trim() !== "---") {
|
||||
return content;
|
||||
}
|
||||
|
||||
return result.join("\n").trim();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user