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:
parent
5c6f82cb74
commit
8551a6f893
2 changed files with 172 additions and 0 deletions
44
src/app/actions/project-background.ts
Normal file
44
src/app/actions/project-background.ts
Normal 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}`);
|
||||
}
|
||||
128
src/components/admin/background-md-editor.tsx
Normal file
128
src/components/admin/background-md-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue