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
529 lines
16 KiB
TypeScript
529 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|