feat(frontend): Phase 2/3 hardening release
Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
the project-manager PNG in a separate commit).
Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.
Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.
Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
smoke tests, npm scripts test:e2e / test:e2e:install.
Made-with: Cursor
This commit is contained in:
528
src/pages/admin/AIPromptEditor.tsx
Normal file
528
src/pages/admin/AIPromptEditor.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAIPrompt,
|
||||
useAIPromptKeys,
|
||||
useAIPromptVersions,
|
||||
useActivateAIPrompt,
|
||||
useCreateAIPrompt,
|
||||
useRenderAIPrompt,
|
||||
} from "@/hooks/queries/useAIPrompts";
|
||||
import type { AIPromptSummary } from "@/types/ai-prompt";
|
||||
import { CheckCircle2, FileText, History, Play, PlusCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function SelectedKeyPanel({
|
||||
selectedKey,
|
||||
onSelectVersion,
|
||||
activePromptId,
|
||||
}: {
|
||||
selectedKey: string;
|
||||
onSelectVersion: (id: number) => void;
|
||||
activePromptId: number | null;
|
||||
}) {
|
||||
const { data, isLoading } = useAIPromptVersions(selectedKey);
|
||||
const activate = useActivateAIPrompt();
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-32 w-full" />;
|
||||
}
|
||||
const versions = data ?? [];
|
||||
if (!versions.length) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No versions yet for this prompt.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Author</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{versions.map((v) => (
|
||||
<TableRow
|
||||
key={v.id}
|
||||
className={activePromptId === v.id ? "bg-muted/50" : undefined}
|
||||
>
|
||||
<TableCell className="font-mono">v{v.version}</TableCell>
|
||||
<TableCell>{v.title}</TableCell>
|
||||
<TableCell>{v.author_name || "—"}</TableCell>
|
||||
<TableCell>
|
||||
{v.is_active ? (
|
||||
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Archived</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="space-x-2 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onSelectVersion(v.id)}
|
||||
>
|
||||
<FileText className="mr-1 h-3 w-3" />
|
||||
Open
|
||||
</Button>
|
||||
{!v.is_active ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await activate.mutateAsync(v.id);
|
||||
toast.success(`Activated v${v.version}`);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Activate failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={activate.isPending}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Activate
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptDetail({ promptId }: { promptId: number }) {
|
||||
const { data, isLoading } = useAIPrompt(promptId);
|
||||
const render = useRenderAIPrompt();
|
||||
|
||||
// Variables detected from the prompt body — keep local state keyed by name
|
||||
// so the editor can show one input per placeholder.
|
||||
const [sampleValues, setSampleValues] = useState<Record<string, string>>({});
|
||||
const [rendered, setRendered] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setRendered("");
|
||||
setSampleValues({});
|
||||
}, [promptId]);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <Skeleton className="h-40 w-full" />;
|
||||
}
|
||||
|
||||
const variables = data.variables ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{data.title}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({data.key} v{data.version})
|
||||
</span>
|
||||
</h3>
|
||||
{data.description ? (
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{data.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs uppercase tracking-wide">Template</Label>
|
||||
<ScrollArea className="bg-muted/30 mt-1 max-h-64 rounded border">
|
||||
<pre className="p-3 font-mono text-sm whitespace-pre-wrap">
|
||||
{data.content}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{variables.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs uppercase tracking-wide">
|
||||
Sample variables
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{variables.map((v) => (
|
||||
<div key={v} className="space-y-1">
|
||||
<Label className="text-xs">{v}</Label>
|
||||
<Input
|
||||
value={sampleValues[v] ?? ""}
|
||||
onChange={(e) =>
|
||||
setSampleValues((prev) => ({
|
||||
...prev,
|
||||
[v]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`Sample value for {${v}}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const r = await render.mutateAsync({
|
||||
id: data.id,
|
||||
variables: sampleValues,
|
||||
});
|
||||
setRendered(r.rendered);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Render failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={render.isPending}
|
||||
>
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
Render preview
|
||||
</Button>
|
||||
{rendered ? (
|
||||
<ScrollArea className="bg-background mt-2 max-h-48 rounded border">
|
||||
<pre className="p-3 font-mono text-sm whitespace-pre-wrap">
|
||||
{rendered}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewVersionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultKey,
|
||||
defaultContent,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
defaultKey: string;
|
||||
defaultContent: string;
|
||||
}) {
|
||||
const create = useCreateAIPrompt();
|
||||
const [key, setKey] = useState(defaultKey);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [content, setContent] = useState(defaultContent);
|
||||
const [activate, setActivate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setKey(defaultKey);
|
||||
setContent(defaultContent);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setActivate(true);
|
||||
}
|
||||
}, [open, defaultKey, defaultContent]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-2xl"
|
||||
description="Create a new prompt version; the previous active version will be archived."
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New prompt version</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Key</Label>
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value.trim())}
|
||||
placeholder="exam.mcq.generate"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Lowercase dotted identifier. Reuse an existing key to bump its
|
||||
version.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="MCQ generation — CEFR B2 — v6"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description (optional)</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="What changed? What should editors know?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Template</Label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
placeholder="You are an exam author. Generate {count} MCQs for {topic}…"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activate}
|
||||
onChange={(e) => setActivate(e.target.checked)}
|
||||
/>
|
||||
Activate this version immediately
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!key || !title || !content.trim()) {
|
||||
toast.error("Key, title, and content are required");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
key,
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
activate,
|
||||
});
|
||||
toast.success("Prompt version created");
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Create failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={create.isPending}
|
||||
>
|
||||
Save version
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AIPromptEditor() {
|
||||
const [search, setSearch] = useState("");
|
||||
const { data, isLoading } = useAIPromptKeys({ search, page: 1, size: 50 });
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<number | null>(null);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
|
||||
const items: AIPromptSummary[] = data?.items ?? [];
|
||||
const activePromptId = useMemo(() => {
|
||||
if (!selectedKey) return null;
|
||||
const match = items.find((it) => it.key === selectedKey);
|
||||
return match?.id ?? null;
|
||||
}, [items, selectedKey]);
|
||||
|
||||
// Autoselect first key on load.
|
||||
useEffect(() => {
|
||||
if (!selectedKey && items.length) {
|
||||
setSelectedKey(items[0].key);
|
||||
setSelectedPromptId(items[0].id);
|
||||
}
|
||||
}, [items, selectedKey]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
AI prompt library
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Versioned, auditable templates that the AI pipelines render at
|
||||
runtime. Non-engineers can iterate here without shipping code.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<PlusCircle className="mr-1 h-4 w-4" />
|
||||
New version
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prompt keys</CardTitle>
|
||||
<CardDescription>
|
||||
Each row shows the latest version of a prompt key. Click a row to
|
||||
inspect all versions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
placeholder="Search keys (e.g. exam.mcq)"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No prompts yet. Click <strong>New version</strong> to add one.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Key</TableHead>
|
||||
<TableHead>Latest</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Variables</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((p) => (
|
||||
<TableRow
|
||||
key={p.id}
|
||||
className={
|
||||
selectedKey === p.key
|
||||
? "bg-muted/40 cursor-pointer"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedKey(p.key);
|
||||
setSelectedPromptId(p.id);
|
||||
}}
|
||||
>
|
||||
<TableCell className="font-mono text-xs">{p.key}</TableCell>
|
||||
<TableCell>
|
||||
v{p.version}{" "}
|
||||
{p.total_versions && p.total_versions > 1 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
(of {p.total_versions})
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>{p.title}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.variables.slice(0, 4).map((v) => (
|
||||
<Badge key={v} variant="outline" className="text-xs">
|
||||
{v}
|
||||
</Badge>
|
||||
))}
|
||||
{p.variables.length > 4 ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{p.variables.length - 4}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.is_active ? (
|
||||
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">No active</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedKey ? (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<History className="mr-1 inline h-4 w-4" />
|
||||
Versions of {selectedKey}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SelectedKeyPanel
|
||||
selectedKey={selectedKey}
|
||||
onSelectVersion={setSelectedPromptId}
|
||||
activePromptId={selectedPromptId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview</CardTitle>
|
||||
<CardDescription>
|
||||
Inspect template body and try a dry-run render.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedPromptId ? (
|
||||
<PromptDetail promptId={selectedPromptId} />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select a version from the list.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<NewVersionDialog
|
||||
open={newOpen}
|
||||
onOpenChange={setNewOpen}
|
||||
defaultKey={selectedKey ?? ""}
|
||||
defaultContent=""
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user