- ApiKeyCreationModal: required Alias input (reuse alias = renew, with banner); align roles to backend allowlist (guest -> user, default user); fix copy bug (generatedKey.key). - MarkdownContent + PatchCards: show author / created / last modified (+ by whom); formatDateTime helper (null -> "—"). Branched off fix/buildconfig-cachebust (carries the contenthash + BuildConfig fix already deployed to prod). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
9.6 KiB
JavaScript
232 lines
9.6 KiB
JavaScript
import React, { useState } from "react";
|
|
import { Plus, Pencil, Trash2, Save, Layers, User, Clock } from "lucide-react";
|
|
import { formatDateTime } from "../../lib/utils";
|
|
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="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-border/40 px-5 py-1.5 font-mono text-[11px] text-muted-foreground">
|
|
<span className="inline-flex items-center gap-1">
|
|
<User className="h-3 w-3 text-secondary" />
|
|
{patch.author || "—"}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{formatDateTime(patch.created_at)}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
edited {formatDateTime(patch.updated_at)}
|
|
{patch.last_modified_by
|
|
? ` by ${patch.last_modified_by}`
|
|
: ""}
|
|
</span>
|
|
</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;
|