Files
encoach_frontend_new_v2/src/pages/admin/AIPromptEditor.tsx
Yamen Ahmad b02c2e7526 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
2026-04-19 14:16:32 +04:00

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>
);
}