feat: dark-tech UI redesign + markdown patch cards

Redesign the frontend with a dark-tech theme: add Tailwind + PostCSS,
design tokens, and shadcn-style primitives (Button/Card/Input/Dialog/
DropdownMenu/Tabs/ScrollArea/etc.); restyle the app shell, navigation,
sidebar tree, content view, markdown rendering, editors, modals and
settings panels. Behavior/props unchanged; Font Awesome replaced with
lucide-react.

Add the patch cards feature UI: patch-queries hooks and a PatchCards
component rendered below the markdown body, with an Add Patch button
and create/edit dialog.

Fix tree expandability: folders with an index page now expand on name
click (and navigate), and the chevron+folder icon is one larger toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 17:28:13 +01:00
parent 045c7c51d6
commit 952387d50f
54 changed files with 4503 additions and 1765 deletions

View File

@@ -0,0 +1,214 @@
import React, { useState } from "react";
import { Plus, Pencil, Trash2, Save, Layers } from "lucide-react";
import MarkdownView from "./MarkdownView";
import PermissionGuard from "../PermissionGuard";
import {
usePatches,
useCreatePatch,
useUpdatePatch,
useDeletePatch,
} from "../../utils/queries/patch-queries";
import { Button } from "../ui/button";
import { Input, Textarea, Label } from "../ui/input";
import { Spinner } from "../ui/misc";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../ui/dialog";
const PatchCards = ({ markdownId }) => {
const { data: patches, isLoading, isError } = usePatches(markdownId);
const createPatch = useCreatePatch();
const updatePatch = useUpdatePatch();
const deletePatch = useDeletePatch();
// editor dialog state — `editing` is null for "create", or the patch object
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const openCreate = () => {
setEditing(null);
setTitle("");
setContent("");
setOpen(true);
};
const openEdit = (patch) => {
setEditing(patch);
setTitle(patch.title || "");
setContent(patch.content || "");
setOpen(true);
};
const handleSave = () => {
if (!content.trim()) {
alert("Patch content cannot be empty");
return;
}
const payload = { title: title.trim() || null, content };
if (editing) {
updatePatch.mutate(
{ id: editing.id, data: payload },
{
onSuccess: () => setOpen(false),
onError: () => alert("Failed to update patch"),
}
);
} else {
createPatch.mutate(
{ markdown_id: markdownId, ...payload },
{
onSuccess: () => setOpen(false),
onError: () => alert("Failed to create patch"),
}
);
}
};
const handleDelete = (patch) => {
if (!window.confirm("Delete this patch card?")) return;
deletePatch.mutate(
{ id: patch.id, markdownId },
{ onError: () => alert("Failed to delete patch") }
);
};
// Non-admins on a restricted parent get an error here — fail silently so
// the main markdown page is never broken by patches.
if (isError) return null;
const list = patches || [];
const saving = createPatch.isPending || updatePatch.isPending;
return (
<section className="mt-10">
{(isLoading || list.length > 0) && (
<div className="mb-4 flex items-center gap-2">
<Layers className="h-4 w-4 text-secondary" />
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Patch Cards
{list.length > 0 && (
<span className="ml-2 text-secondary">
{list.length}
</span>
)}
</h2>
<div className="h-px flex-1 bg-border" />
</div>
)}
{isLoading && <Spinner label="Loading patches" />}
<div className="space-y-5">
{list.map((patch, i) => (
<div
key={patch.id}
className="group relative rounded-lg border border-border bg-card/60 shadow-[0_0_24px_-16px_hsl(var(--secondary)/0.6)]"
>
<div className="flex items-center justify-between gap-3 border-b border-border/70 px-5 py-2.5">
<span className="font-mono text-xs font-semibold uppercase tracking-wide text-secondary">
{patch.title || `Patch ${i + 1}`}
</span>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
title="Edit"
onClick={() => openEdit(patch)}
className="grid h-7 w-7 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<PermissionGuard rolesRequired={["admin"]}>
<button
type="button"
title="Delete"
onClick={() => handleDelete(patch)}
className="grid h-7 w-7 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</PermissionGuard>
</div>
</PermissionGuard>
</div>
<div className="px-5 py-4">
<MarkdownView content={{ markdown: patch.content }} />
</div>
</div>
))}
</div>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<Button
variant="outline"
size="sm"
onClick={openCreate}
className="mt-5 w-full border-dashed text-muted-foreground hover:text-secondary"
>
<Plus className="h-4 w-4" /> Add Patch
</Button>
</PermissionGuard>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editing ? "Edit Patch Card" : "New Patch Card"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="patch-title">
Title (optional)
</Label>
<Input
id="patch-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Update 2026-05"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="patch-content">
Content (markdown)
</Label>
<Textarea
id="patch-content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write markdown…"
className="min-h-[220px] font-mono text-xs"
/>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setOpen(false)}
disabled={saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
<Save className="h-4 w-4" />
{saving ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
};
export default PatchCards;