Addresses the QA notes on the end-to-end approval flow. Highlights:
Backend
- encoach_ai/generation_submit: create encoach.approval.request on submit so
submitted exams actually reach the approver queue (previously only the
workflow id was stored on the exam, leaving approvers with an empty inbox).
Initial exam status flips to pending_approval instead of draft.
- encoach_exam_template/approval_workflows:
* /api/approval-users filters out students (user_type='student',
op_student_id set, share=True) so the approver dropdown only lists staff.
* _ser_request enriches requests with target_name / target_status and the
current stage approver for UI badges.
* list_requests supports mine=1 / requester=1 so approvers only see queue
items awaiting their action.
* approve_request / reject_request now transition the underlying
encoach.exam.custom to published / rejected on final approval.
- encoach.exam.custom.status: add pending_approval and rejected states to
match the approval workflow transitions.
Frontend UX
- GenerationPage: rubric field shows "Auto-graded — no rubric" for Listening
and Reading (rubrics only apply to Writing / Speaking). Divider dropdowns
now carry explicit help text explaining None / Line / Space / Page Break
and where to write prompts. Selecting an official exam structure
auto-populates the required tasks/sections/parts, the delete button is
hidden for essential tasks, and submit is blocked if the user dropped
below the structure's required count.
- RubricsPage: "Add Criterion" is now a dropdown with IELTS-specific
presets (Task Achievement, Coherence & Cohesion, Fluency & Coherence, …)
keyed off the selected skill, plus a "Custom (blank row)" fallback.
- AdminCourses: added tooltips and helper copy clarifying Difficulty vs
CEFR Level in the Create Course dialog.
- CustomExamCreate: validate the whole form before Save/Publish, surface
backend error messages (413/504/401) instead of a generic toast, read the
exam id from the correct response field, and avoid marking Publish as
failed when only the optional Save-as-Template step errors.
- ResourceManager / TeacherLibrary: upload toast now shows a concrete
reason (HTTP 413 / 504 / 401) instead of a generic "Upload failed".
Config (504 / 413 / resource upload fixes)
- odoo.conf + odoo-docker.conf: raise limit_time_cpu to 600s,
limit_time_real to 900s, limit_request to 128 MB, and memory caps so
/api/exam/generation/submit and multipart resource uploads stop hitting
504 / 413 during QA.
- frontend/Dockerfile (nginx): add client_max_body_size 128m, bump
proxy_read_timeout / proxy_send_timeout to 900s, and disable request
buffering so large AI/resource payloads stream through the proxy.
Made-with: Cursor
524 lines
24 KiB
TypeScript
524 lines
24 KiB
TypeScript
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 { 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: unknown) => {
|
||
const e = err as { status?: number; message?: string };
|
||
const status = e?.status;
|
||
let description = e?.message || "Upload failed";
|
||
if (status === 413) description = "File is too large for the server (HTTP 413). Try a smaller file or ask an admin to raise the upload limit.";
|
||
else if (status === 401) description = "Session expired — please sign in again.";
|
||
else if (status === 504) description = "Server timed out while saving the file (HTTP 504). Try a smaller file or retry.";
|
||
toast({ title: "Upload failed", description, 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 = await resourcesService.download(row.resourceId!);
|
||
const url = URL.createObjectURL(blob); const a = document.createElement("a");
|
||
a.href = url; a.download = 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>
|
||
);
|
||
}
|