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:
214
src/components/Markdowns/PatchCards.js
Normal file
214
src/components/Markdowns/PatchCards.js
Normal 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;
|
||||
Reference in New Issue
Block a user