Files
full_encoach_platform/frontend/src/pages/teacher/TeacherLibrary.tsx
Yamen Ahmad 35ccc0dfb2 feat(platform): ship AI fallback stack and entity-scoped course planning
Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform.

Made-with: Cursor
2026-04-26 02:34:52 +04:00

523 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Search, Upload, FileText, Video, Link2, Download, Trash2,
CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image,
Library, Plus, CalendarDays, X,
} from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { resourcesService } from "@/services/resources.service";
import { describeApiError } from "@/lib/api-client";
import { coursewareService } from "@/services/courseware.service";
import { taxonomyService } from "@/services/taxonomy.service";
import { useToast } from "@/hooks/use-toast";
import { TaxonomyCascade } from "@/components/TaxonomyCascade";
import type { Resource, ResourceTag } from "@/types";
const statusBadge: Record<string, { variant: "default" | "secondary" | "destructive"; icon: React.ReactNode }> = {
approved: { variant: "default", icon: <CheckCircle2 className="h-3 w-3 mr-1" /> },
pending: { variant: "secondary", icon: <Clock className="h-3 w-3 mr-1" /> },
rejected: { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" /> },
};
const typeIcons: Record<string, React.ReactNode> = {
pdf: <FileText className="h-4 w-4 text-red-500" />,
video: <Video className="h-4 w-4 text-blue-500" />,
link: <Link2 className="h-4 w-4 text-green-500" />,
document: <FileText className="h-4 w-4 text-orange-500" />,
interactive: <FileText className="h-4 w-4 text-purple-500" />,
article: <FileText className="h-4 w-4 text-indigo-500" />,
audio: <Music className="h-4 w-4 text-yellow-500" />,
image: <Image className="h-4 w-4 text-pink-500" />,
};
interface CourseMaterialItem {
id: number;
name: string;
type: string;
course_name: string;
chapter_name: string;
source: string;
file_url: string | null;
url: string | null;
description: string | null;
course_id: number;
}
export default function TeacherLibrary() {
const { toast } = useToast();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [subjectFilter, setSubjectFilter] = useState<string>("all");
const [tagFilter, setTagFilter] = useState<string>("all");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [showUpload, setShowUpload] = useState(false);
const [uploadType, setUploadType] = useState("pdf");
const [uploadSubjectId, setUploadSubjectId] = useState<string>("none");
const [uploadDomainId, setUploadDomainId] = useState<string>("none");
const [uploadTopicIds, setUploadTopicIds] = useState<number[]>([]);
const [uploadObjectiveIds, setUploadObjectiveIds] = useState<number[]>([]);
const [uploadTagIds, setUploadTagIds] = useState<number[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
const { data: subjects } = useQuery({
queryKey: ["taxonomy", "subjects"],
queryFn: () => taxonomyService.listSubjects(),
});
const { data: tags } = useQuery({
queryKey: ["resource-tags"],
queryFn: () => resourcesService.listTags(),
});
const { data: resourcesData, isLoading: loadingResources } = useQuery({
queryKey: ["resources", "list", { search, resource_type: typeFilter === "all" ? undefined : typeFilter, subject_id: subjectFilter === "all" ? undefined : subjectFilter, tag_id: tagFilter === "all" ? undefined : tagFilter, date_from: dateFrom || undefined, date_to: dateTo || undefined }],
queryFn: () => resourcesService.list({
search: search || undefined,
resource_type: typeFilter === "all" ? undefined : typeFilter,
subject_id: subjectFilter === "all" ? undefined : Number(subjectFilter),
tag_id: tagFilter === "all" ? undefined : Number(tagFilter),
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
}),
});
const resources = resourcesData?.items ?? [];
const { data: materialsData, isLoading: loadingMaterials } = useQuery({
queryKey: ["materials", "search", { search, type: typeFilter }],
queryFn: () => coursewareService.searchMaterials({
search: search || undefined,
type: typeFilter === "all" ? undefined : typeFilter,
}),
});
const courseMaterials = materialsData?.items ?? [];
const uploadMutation = useMutation({
mutationFn: (formData: FormData) => resourcesService.upload(formData),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["resources"] });
toast({ title: "Resource uploaded successfully" });
setShowUpload(false);
setUploadTagIds([]);
setUploadSubjectId("none");
setUploadDomainId("none");
setUploadTopicIds([]);
setUploadObjectiveIds([]);
},
onError: (err) => {
toast({
title: "Upload failed",
description: describeApiError(err, "Upload failed", "file"),
variant: "destructive",
});
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => resourcesService.delete(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource deleted" }); },
onError: () => toast({ title: "Error", description: "Delete failed", variant: "destructive" }),
});
const handleUpload = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
if (uploadTagIds.length > 0) formData.set("tag_ids", uploadTagIds.join(","));
if (uploadDomainId !== "none") formData.set("domain_id", uploadDomainId);
if (uploadTopicIds.length > 0) formData.set("topic_ids", uploadTopicIds.join(","));
if (uploadObjectiveIds.length > 0) formData.set("learning_objective_ids", uploadObjectiveIds.join(","));
uploadMutation.mutate(formData);
};
const toggleUploadTag = (id: number) => {
setUploadTagIds((prev) => prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]);
};
const clearFilters = () => {
setSearch(""); setTypeFilter("all"); setSubjectFilter("all");
setTagFilter("all"); setDateFrom(""); setDateTo("");
};
const hasActiveFilters = search || typeFilter !== "all" || subjectFilter !== "all" || tagFilter !== "all" || dateFrom || dateTo;
const isLoading = loadingResources || loadingMaterials;
const formatDate = (iso?: string) => {
if (!iso) return "";
try { return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } catch { return ""; }
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Library className="h-6 w-6" /> Resource Library
</h1>
<p className="text-muted-foreground">Browse shared resources and your course materials. Upload new content or pick existing resources when building courses.</p>
</div>
<Button onClick={() => setShowUpload(true)}>
<Upload className="mr-2 h-4 w-4" /> Upload Resource
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("all")}>
<CardContent className="pt-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-primary/10"><Library className="h-5 w-5 text-primary" /></div>
<div>
<p className="text-2xl font-bold">{resources.length + courseMaterials.length}</p>
<p className="text-sm text-muted-foreground">Total Resources</p>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("library")}>
<CardContent className="pt-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-blue-500/10"><FileText className="h-5 w-5 text-blue-500" /></div>
<div>
<p className="text-2xl font-bold">{resources.length}</p>
<p className="text-sm text-muted-foreground">Shared Library</p>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("materials")}>
<CardContent className="pt-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-green-500/10"><BookOpen className="h-5 w-5 text-green-500" /></div>
<div>
<p className="text-2xl font-bold">{courseMaterials.length}</p>
<p className="text-sm text-muted-foreground">Course Materials</p>
</div>
</CardContent>
</Card>
</div>
{/* Filters + Table */}
<Card>
<CardHeader className="space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search resources…" value={search} onChange={e => setSearch(e.target.value)} className="pl-9" />
</div>
<Select value={subjectFilter} onValueChange={setSubjectFilter}>
<SelectTrigger className="w-[150px]"><SelectValue placeholder="Subject" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Subjects</SelectItem>
{(subjects ?? []).map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={tagFilter} onValueChange={setTagFilter}>
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Tag" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tags</SelectItem>
{(tags ?? []).map((t) => (
<SelectItem key={t.id} value={String(t.id)}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full inline-block" style={{ backgroundColor: t.color }} />
{t.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[130px]"><SelectValue placeholder="Type" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="document">Document</SelectItem>
<SelectItem value="article">Article</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CalendarDays className="h-4 w-4" />
<span>From</span>
<Input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="w-[150px] h-8 text-xs" />
<span>To</span>
<Input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="w-[150px] h-8 text-xs" />
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-muted-foreground">
<X className="h-3 w-3 mr-1" /> Clear filters
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
) : (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-4">
<TabsTrigger value="all">All ({resources.length + courseMaterials.length})</TabsTrigger>
<TabsTrigger value="library">Shared Library ({resources.length})</TabsTrigger>
<TabsTrigger value="materials">Course Materials ({courseMaterials.length})</TabsTrigger>
</TabsList>
<TabsContent value="all">
<ResourceTable resources={resources} materials={courseMaterials} showSource onDelete={(id) => { if (window.confirm("Delete?")) deleteMutation.mutate(id); }} deletePending={deleteMutation.isPending} toast={toast} formatDate={formatDate} />
</TabsContent>
<TabsContent value="library">
<ResourceTable resources={resources} materials={[]} onDelete={(id) => { if (window.confirm("Delete?")) deleteMutation.mutate(id); }} deletePending={deleteMutation.isPending} toast={toast} formatDate={formatDate} />
</TabsContent>
<TabsContent value="materials">
<ResourceTable resources={[]} materials={courseMaterials} showCourseInfo onDelete={() => {}} deletePending={false} toast={toast} formatDate={formatDate} />
</TabsContent>
</Tabs>
)}
</CardContent>
</Card>
{/* Upload Dialog */}
<Dialog open={showUpload} onOpenChange={(o) => { setShowUpload(o); if (!o) { setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); } }}>
<DialogContent className="sm:max-w-[540px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Plus className="h-5 w-5" /> Upload New Resource</DialogTitle>
</DialogHeader>
<form onSubmit={handleUpload} className="space-y-4 pt-2">
<input type="hidden" name="resource_type" value={uploadType} />
{uploadSubjectId !== "none" && <input type="hidden" name="subject_id" value={uploadSubjectId} />}
<div className="space-y-2"><Label>Name *</Label><Input name="name" placeholder="Resource title" required /></div>
<div className="space-y-2">
<Label>Type</Label>
<Select value={uploadType} onValueChange={setUploadType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="document">Document</SelectItem>
<SelectItem value="link">Link / URL</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem>
</SelectContent>
</Select>
</div>
<TaxonomyCascade
subjectId={uploadSubjectId} onSubjectChange={setUploadSubjectId}
domainId={uploadDomainId} onDomainChange={setUploadDomainId}
topicIds={uploadTopicIds} onTopicIdsChange={setUploadTopicIds}
objectiveIds={uploadObjectiveIds} onObjectiveIdsChange={setUploadObjectiveIds}
/>
<InlineTagPicker
allTags={tags ?? []}
selectedIds={uploadTagIds}
onToggle={toggleUploadTag}
onTagCreated={() => qc.invalidateQueries({ queryKey: ["resource-tags"] })}
/>
<div className="space-y-2"><Label>File</Label><Input name="file" type="file" ref={fileRef} /></div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setShowUpload(false)}>Cancel</Button>
<Button type="submit" disabled={uploadMutation.isPending}>
{uploadMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Upload
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
function ResourceTable({
resources, materials, showSource, showCourseInfo, onDelete, deletePending, toast, formatDate,
}: {
resources: Resource[];
materials: CourseMaterialItem[];
showSource?: boolean;
showCourseInfo?: boolean;
onDelete: (id: number) => void;
deletePending: boolean;
toast: ReturnType<typeof useToast>["toast"];
formatDate: (iso?: string) => string;
}) {
type Row = {
key: string; name: string; type: string; source: "library" | "course";
context: string; tags?: { id: number; name: string; color: string }[];
status?: string; createdAt?: string; resourceId?: number;
};
const rows: Row[] = [
...resources.map((r): Row => ({
key: `r-${r.id}`, name: r.name, type: r.resource_type, source: "library",
context: [r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])].filter(Boolean).join(" ") || "",
tags: r.tags ?? [], status: r.review_status, createdAt: r.created_at, resourceId: r.id,
})),
...materials.map((m): Row => ({
key: `m-${m.id}`, name: m.name, type: m.type, source: "course",
context: showCourseInfo || showSource ? `${m.course_name} ${m.chapter_name}` : m.chapter_name || "",
})),
];
if (rows.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
<Library className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p className="font-medium">No resources found</p>
<p className="text-sm">Upload a resource or add materials to your courses.</p>
</div>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
{showSource && <TableHead>Source</TableHead>}
<TableHead>Subject / Tags</TableHead>
<TableHead>Status</TableHead>
<TableHead>Uploaded</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const sb = row.status ? (statusBadge[row.status] ?? statusBadge.pending) : null;
return (
<TableRow key={row.key}>
<TableCell>
<div className="flex items-center gap-2">
{typeIcons[row.type] ?? <FileText className="h-4 w-4" />}
<span className="font-medium">{row.name}</span>
</div>
</TableCell>
<TableCell><Badge variant="outline" className="capitalize">{row.type}</Badge></TableCell>
{showSource && (
<TableCell>
<Badge variant={row.source === "library" ? "secondary" : "default"} className="text-xs">
{row.source === "library" ? "Library" : <><BookOpen className="h-3 w-3 mr-1 inline" />Course</>}
</Badge>
</TableCell>
)}
<TableCell>
<div className="flex flex-col gap-1">
{row.context && <span className="text-xs text-muted-foreground">{row.context}</span>}
{row.tags && row.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{row.tags.map((t) => (
<span key={t.id} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium text-white" style={{ backgroundColor: t.color }}>{t.name}</span>
))}
</div>
)}
{!row.context && (!row.tags || row.tags.length === 0) && <span className="text-muted-foreground"></span>}
</div>
</TableCell>
<TableCell>
{sb ? (
<Badge variant={sb.variant} className="flex items-center w-fit">{sb.icon}{row.status}</Badge>
) : (
<Badge variant="default" className="flex items-center w-fit"><CheckCircle2 className="h-3 w-3 mr-1" />indexed</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(row.createdAt)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{row.source === "library" && row.resourceId && (
<>
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
try {
const { blob, filename } = await resourcesService.download(row.resourceId!);
const url = URL.createObjectURL(blob); const a = document.createElement("a");
a.href = url; a.download = filename || row.name || "resource"; a.click(); URL.revokeObjectURL(url);
} catch { toast({ title: "Download failed", variant: "destructive" }); }
}}>
<Download className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" title="Delete" onClick={() => onDelete(row.resourceId!)} disabled={deletePending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
const INLINE_TAG_COLORS = ["#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4"];
function InlineTagPicker({
allTags, selectedIds, onToggle, onTagCreated,
}: {
allTags: ResourceTag[];
selectedIds: number[];
onToggle: (id: number) => void;
onTagCreated: (tag: ResourceTag) => void;
}) {
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const handleCreate = async () => {
const name = newName.trim();
if (!name) return;
setCreating(true);
try {
const color = INLINE_TAG_COLORS[Math.floor(Math.random() * INLINE_TAG_COLORS.length)];
const tag = await resourcesService.createTag({ name, color });
onTagCreated(tag);
onToggle(tag.id);
setNewName("");
} catch { /* ignore */ }
setCreating(false);
};
return (
<div className="space-y-2">
<Label>Tags</Label>
<div className="flex flex-wrap gap-2">
{allTags.map((t) => (
<Badge
key={t.id}
variant={selectedIds.includes(t.id) ? "default" : "outline"}
className="cursor-pointer select-none transition-colors"
style={selectedIds.includes(t.id) ? { backgroundColor: t.color, borderColor: t.color, color: "#fff" } : { borderColor: t.color, color: t.color }}
onClick={() => onToggle(t.id)}
>
{t.name}
</Badge>
))}
</div>
<div className="flex gap-2 items-center">
<Input
placeholder="New tag…"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleCreate(); } }}
className="h-8 text-sm flex-1"
/>
<Button type="button" size="sm" variant="outline" disabled={!newName.trim() || creating} onClick={handleCreate}>
{creating ? <Loader2 className="h-3 w-3 animate-spin" /> : <><Plus className="h-3 w-3 mr-1" />Add</>}
</Button>
</div>
</div>
);
}