feat(admin): background markdown editor with character cap

Phase 8c Task 5. Server Action caps at 40000 chars on write, owner-
only via requireOwner(). Client component: one textarea, live
counter, explicit Save button (no auto-save), repo URL rendered as
a 'see also' link rather than a tool call.
This commit is contained in:
Dave 2026-09-21 11:48:56 +02:00
parent 5c6f82cb74
commit 8551a6f893
2 changed files with 172 additions and 0 deletions

View file

@ -0,0 +1,44 @@
"use server";
import { revalidatePath } from "next/cache";
import { getAdminPb } from "@/lib/pb/admin";
import { requireSession } from "@/lib/auth/session";
import type { ProjectRecord } from "@/lib/pb/types";
const FIELD_CAP = 40_000;
/** Owner-only entry point. Same pattern as log-work.ts. */
async function requireOwner() {
const session = await requireSession();
if (session.role !== "owner") throw new Error("Not found");
return session;
}
/**
* Save the project's background markdown.
*
* The 40000 field cap is the trip-wire: at that point the doc is too big
* for whole-doc prompting and we should revisit section-aware retrieval.
* Until then, the cap is the only validation we do here — anything the
* developer writes is the developer's responsibility.
*/
export async function setProjectBackgroundMdAction(input: {
projectId: string;
backgroundMd: string;
}): Promise<void> {
await requireOwner();
const text = (input.backgroundMd ?? "").slice(0, FIELD_CAP);
const pb = await getAdminPb();
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(input.projectId);
await pb.collection("projects").update(project.id, {
background_md: text,
});
revalidatePath(`/admin/projects/${input.projectId}`);
}

View file

@ -0,0 +1,128 @@
"use client";
import { useState, useTransition } from "react";
import { Save } from "lucide-react";
import { setProjectBackgroundMdAction } from "@/app/actions/project-background";
const FIELD_CAP = 40_000;
/**
* The per-project background markdown editor.
*
* One textarea, live character counter, no preview pane. A preview pane
* invites half-maintained doc; the editor is the source of truth and the
* wizard is the consumer. Save is its own button — auto-save on every
* keystroke would hammer the admin pb with no upside.
*
* The helper text mentions the GitHub link as a "see also" — the URL is
* not turned into a tool call; the developer is expected to embed file
* paths in the markdown itself.
*/
export function BackgroundMdEditor({
projectId,
initial,
repoUrl,
}: {
projectId: string;
initial: string;
repoUrl: string;
}) {
const [value, setValue] = useState(initial);
const [pending, startTransition] = useTransition();
const [saved, setSaved] = useState<boolean>(initial.length > 0);
const [error, setError] = useState<string | null>(null);
const dirty = value !== initial;
const over = value.length > FIELD_CAP;
function save() {
setError(null);
startTransition(async () => {
try {
await setProjectBackgroundMdAction({
projectId,
backgroundMd: value,
});
setSaved(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not save");
}
});
}
const field =
"w-full rounded-lg border border-border-subtle bg-surface px-3 py-2 font-mono text-xs text-text-secondary outline-none focus:border-brand";
return (
<section className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle p-4">
<div>
<h2 className="text-sm font-semibold text-text-primary">
Background notes for the assistant
</h2>
<p className="mt-0.5 text-xs text-text-muted">
Plain markdown describing the app: what each section does today,
where it is allowed to go, what you would push back on. Sent into
every wizard prompt so the assistant answers clients as if it
knew the codebase.
</p>
</div>
<textarea
rows={12}
value={value}
onChange={(event) => {
setValue(event.target.value);
setSaved(false);
}}
placeholder={"## Quotes\nPriced in src/services/quote/create.ts.\nDiscount cap 15% without owner approval.\n"}
className={field}
/>
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className={over ? "text-accent" : ""}>
<span className="tabular">{value.length}</span> /{" "}
<span className="tabular">{FIELD_CAP}</span> characters
</span>
{over && (
<span>
Trim this — anything past the cap is not sent into prompts.
</span>
)}
{repoUrl && (
<span className="ml-auto">
See also:{" "}
<a
href={repoUrl}
target="_blank"
rel="noreferrer"
className="text-text-secondary underline-offset-2 hover:underline"
>
{repoUrl.replace(/^https?:\/\//, "")}
</a>
</span>
)}
</div>
<div className="flex items-center gap-3 border-t border-border-subtle pt-3">
<button
type="button"
disabled={pending || !dirty || over}
onClick={save}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>
<Save size={14} strokeWidth={2} />
{pending ? "Saving…" : "Save"}
</button>
{saved && !dirty && (
<span className="text-xs text-text-muted">Saved.</span>
)}
{error && (
<span role="alert" className="text-xs text-accent">
{error}
</span>
)}
</div>
</section>
);
}