feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
This commit is contained in:
353
frontend/src/pages/admin/IeltsContentPool.tsx
Normal file
353
frontend/src/pages/admin/IeltsContentPool.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIeltsContentPool, useIeltsAutoAssemble, useIeltsSkills } from "@/hooks/queries/useExamTemplates";
|
||||
import { ieltsExamService } from "@/services/ielts-exam.service";
|
||||
import type { ContentPoolFilters, ContentPoolItem, ExamDifficulty, IELTSSkill, IELTSSkillConfig } from "@/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const SKILLS: { value: IELTSSkill; label: string }[] = [
|
||||
{ value: "listening", label: "Listening" },
|
||||
{ value: "reading", label: "Reading" },
|
||||
{ value: "writing", label: "Writing" },
|
||||
{ value: "speaking", label: "Speaking" },
|
||||
];
|
||||
|
||||
function difficultyLabel(d: ExamDifficulty): string {
|
||||
return d.charAt(0).toUpperCase() + d.slice(1);
|
||||
}
|
||||
|
||||
export default function IeltsContentPool() {
|
||||
const { examId: examIdParam } = useParams<{ examId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const examId = Number(examIdParam);
|
||||
|
||||
const [skill, setSkill] = useState<IELTSSkill>(() => (searchParams.get("skill") as IELTSSkill) || "listening");
|
||||
const [part, setPart] = useState<number>(() => Number(searchParams.get("part")) || 1);
|
||||
const [difficultyIdx, setDifficultyIdx] = useState(1);
|
||||
const difficulties: ExamDifficulty[] = ["easy", "medium", "hard"];
|
||||
const difficulty = difficulties[difficultyIdx] ?? "medium";
|
||||
const [topicInput, setTopicInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [pendingHybrid, setPendingHybrid] = useState<ContentPoolItem[]>([]);
|
||||
|
||||
const assemblyMode = useMemo(() => {
|
||||
if (!Number.isFinite(examId)) return "manual";
|
||||
return sessionStorage.getItem(`ielts_assembly_${examId}`) ?? "manual";
|
||||
}, [examId]);
|
||||
|
||||
const filters: ContentPoolFilters = useMemo(
|
||||
() => ({
|
||||
skill,
|
||||
part,
|
||||
difficulty,
|
||||
topics: topicInput
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
search: search.trim() || undefined,
|
||||
}),
|
||||
[skill, part, difficulty, topicInput, search],
|
||||
);
|
||||
|
||||
const poolQ = useIeltsContentPool(Number.isFinite(examId) ? examId : 0, filters);
|
||||
const skillsQ = useIeltsSkills(Number.isFinite(examId) ? examId : 0);
|
||||
const autoAssemble = useIeltsAutoAssemble();
|
||||
|
||||
const items = useMemo(() => {
|
||||
const raw = poolQ.data;
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (raw && typeof raw === "object" && Array.isArray((raw as { data?: unknown }).data)) {
|
||||
return (raw as { data: ContentPoolItem[] }).data;
|
||||
}
|
||||
return [] as ContentPoolItem[];
|
||||
}, [poolQ.data]);
|
||||
|
||||
const skillsData = useMemo(() => {
|
||||
const raw = skillsQ.data;
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (raw && typeof raw === "object" && Array.isArray((raw as { data?: unknown }).data)) {
|
||||
return (raw as { data: IELTSSkillConfig[] }).data;
|
||||
}
|
||||
return [];
|
||||
}, [skillsQ.data]);
|
||||
|
||||
const requiredTotal = useMemo(() => {
|
||||
const cfg = skillsData.find((s) => s.skill === skill);
|
||||
if (!cfg?.parts?.length) {
|
||||
if (skill === "listening") return 40;
|
||||
if (skill === "reading") return 40;
|
||||
if (skill === "writing") return 2;
|
||||
return 3;
|
||||
}
|
||||
return cfg.parts.reduce((a, p) => a + p.question_count, 0);
|
||||
}, [skillsData, skill]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const sp = new URLSearchParams(prev);
|
||||
sp.set("skill", skill);
|
||||
sp.set("part", String(part));
|
||||
return sp;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [skill, part, setSearchParams]);
|
||||
|
||||
const didAutoRun = useRef(false);
|
||||
useEffect(() => {
|
||||
if (assemblyMode !== "auto" || !Number.isFinite(examId) || didAutoRun.current) return;
|
||||
didAutoRun.current = true;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await autoAssemble.mutateAsync(examId);
|
||||
if (cancelled) return;
|
||||
const flat = (Array.isArray(res) ? res : []).flatMap((r) => r.selected_items?.map((i) => i.id) ?? []);
|
||||
setSelected(flat);
|
||||
} catch {
|
||||
toast.error("Auto-assembly could not run.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [assemblyMode, autoAssemble, examId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (assemblyMode !== "hybrid" || !Number.isFinite(examId)) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await ieltsExamService.suggest(examId);
|
||||
if (cancelled) return;
|
||||
const rows = Array.isArray(res) ? res : [];
|
||||
const forSkill = rows.find((r) => r.skill === skill && r.part === part);
|
||||
setPendingHybrid(forSkill?.selected_items ?? []);
|
||||
} catch {
|
||||
setPendingHybrid([]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [assemblyMode, examId, part, skill]);
|
||||
|
||||
const toggleSelect = useCallback(
|
||||
(id: number) => {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
const map = new Map(items.map((i) => [i.id, i]));
|
||||
return selected.map((id) => map.get(id)).filter(Boolean) as ContentPoolItem[];
|
||||
}, [items, selected]);
|
||||
|
||||
const poolDisabled = assemblyMode === "auto";
|
||||
|
||||
const saveContinue = async () => {
|
||||
try {
|
||||
await ieltsExamService.saveQuestions(examId, part, selected);
|
||||
toast.success("Selections saved.");
|
||||
navigate(`/admin/exam/ielts/${examId}/validate`);
|
||||
} catch {
|
||||
toast.error("Save failed.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!Number.isFinite(examId) || examId <= 0) {
|
||||
return <p className="p-6 text-destructive">Invalid exam.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-[1400px] mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Content pool</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Assembly: <span className="text-foreground font-medium capitalize">{assemblyMode}</span>
|
||||
{assemblyMode === "auto" ? " — pool is read-only; system selections apply." : null}
|
||||
{assemblyMode === "hybrid" ? " — review suggestions on the right." : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Browse pool</CardTitle>
|
||||
<CardDescription>Filter and preview candidate items.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Skill</Label>
|
||||
<Select value={skill} onValueChange={(v) => setSkill(v as IELTSSkill)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SKILLS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Part</Label>
|
||||
<Select value={String(part)} onValueChange={(v) => setPart(Number(v))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
Part {n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Difficulty ({difficultyLabel(difficulty)})</Label>
|
||||
<Slider min={0} max={2} step={1} value={[difficultyIdx]} onValueChange={(v) => setDifficultyIdx(v[0] ?? 1)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="topics">Topic tags</Label>
|
||||
<Input
|
||||
id="topics"
|
||||
placeholder="education, environment …"
|
||||
value={topicInput}
|
||||
onChange={(e) => setTopicInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search">Search</Label>
|
||||
<Input id="search" placeholder="Keyword in stem" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{poolQ.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[min(520px,55vh)] pr-3">
|
||||
<div className={`space-y-3 ${poolDisabled ? "opacity-50 pointer-events-none" : ""}`}>
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} className="border-muted">
|
||||
<CardHeader className="py-3 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{difficultyLabel(item.difficulty)}</Badge>
|
||||
<span className="text-xs text-muted-foreground">Used {item.usage_count}×</span>
|
||||
</div>
|
||||
<CardTitle className="text-sm font-normal leading-snug">{item.preview_text}</CardTitle>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.topic_tags.map((t) => (
|
||||
<Badge key={t} variant="secondary" className="text-xs">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected.includes(item.id) ? "secondary" : "default"}
|
||||
disabled={poolDisabled}
|
||||
onClick={() => toggleSelect(item.id)}
|
||||
>
|
||||
{selected.includes(item.id) ? "Remove" : "Add"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{items.length === 0 ? <p className="text-sm text-muted-foreground">No items match these filters.</p> : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Selected questions</CardTitle>
|
||||
<CardDescription>
|
||||
{selected.length}/{requiredTotal} selected
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ScrollArea className="h-[min(400px,45vh)]">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{selectedItems.map((i) => (
|
||||
<li key={i.id} className="rounded-md border p-2 flex justify-between gap-2">
|
||||
<span className="line-clamp-2">{i.preview_text}</span>
|
||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => toggleSelect(i.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
{selectedItems.length === 0 ? <li className="text-muted-foreground">Nothing selected yet.</li> : null}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
|
||||
{assemblyMode === "hybrid" && pendingHybrid.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Suggestions</p>
|
||||
{pendingHybrid.map((item) => (
|
||||
<div key={item.id} className="flex items-start justify-between gap-2 rounded-md border p-2 text-sm">
|
||||
<span className="line-clamp-3">{item.preview_text}</span>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!selected.includes(item.id)) setSelected((s) => [...s, item.id]);
|
||||
setPendingHybrid((p) => p.filter((x) => x.id !== item.id));
|
||||
}}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setPendingHybrid((p) => p.filter((x) => x.id !== item.id))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button className="w-full" type="button" onClick={saveContinue}>
|
||||
Save & continue
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user