Files
full_encoach_platform/frontend/src/pages/admin/ResourceManager.tsx
Yamen Ahmad 75ee0f1fe0 fix(ui): actionable 413/504/401 toasts + VPS nginx deploy template
Production QA reported "Submit failed 413" on /generation and plain
"Upload failed" (no detail) on resource upload. Root cause is the
outer reverse-proxy nginx on the VPS — still on default
`client_max_body_size 1m` — rejecting requests before they reach
Odoo. The inner docker-nginx and Odoo limits were already raised in
earlier commits; only the VPS proxy was left unpatched.

UI side
- Add `describeApiError(err, fallback, context)` helper in
  lib/api-client.ts. Maps common HTTP codes to human-readable,
  actionable toast descriptions (413 → "ask ops to raise nginx
  client_max_body_size", 504 → "server took too long, retry", 502
  → "Odoo is restarting", 401 → "sign in again", 4xx/5xx → server
  error body + status code).
- Use it in GenerationPage submit, ResourceManager upload,
  TeacherLibrary upload, CustomExamCreate save/publish. Replaces
  four slightly-different ad-hoc mappings with one source of truth.

Deploy side
- Add deploy/nginx-vps.conf.example: full TLS reverse-proxy template
  with the body/timeout knobs that must match Odoo's limit_request
  (128 MB) and limit_time_real (900 s).
- Add docs/DEPLOY_413_504_FIX.md: step-by-step remediation guide for
  the team lead covering the 3 layers that can block large bodies
  (outer nginx, Cloudflare, Odoo worker) and how to verify each.

Made-with: Cursor
2026-04-21 09:09:23 +04:00

641 lines
35 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 } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Upload, Search, FileText, Video, Link2, Download, Trash2,
CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image,
Tag, Plus, Pencil, X, CalendarDays,
} 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 { describeApiError } from "@/lib/api-client";
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" />,
};
const TAG_COLORS = [
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
"#ec4899", "#06b6d4", "#f97316", "#6366f1", "#14b8a6",
];
function pickColor() {
return TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)];
}
// ── Reusable Tag Picker with inline create ──────────────────────────
function TagPicker({
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 tag = await resourcesService.createTag({ name, color: pickColor() });
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>
);
}
// ── Edit Resource Dialog ────────────────────────────────────────────
function EditResourceDialog({
resource, open, onOpenChange, allTags, onTagCreated,
}: {
resource: Resource;
open: boolean;
onOpenChange: (open: boolean) => void;
allTags: ResourceTag[];
onTagCreated: (tag: ResourceTag) => void;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const [name, setName] = useState(resource.name);
const [type, setType] = useState(resource.type || resource.resource_type || "document");
const [status, setStatus] = useState(resource.review_status || "approved");
const [tagIds, setTagIds] = useState<number[]>(resource.tag_ids ?? resource.tags?.map((t) => t.id) ?? []);
const [editSubjectId, setEditSubjectId] = useState<string>(resource.subject_id ? String(resource.subject_id) : "none");
const [editDomainId, setEditDomainId] = useState<string>(resource.domain_id ? String(resource.domain_id) : "none");
const [editTopicIds, setEditTopicIds] = useState<number[]>(resource.topic_ids ?? []);
const [editObjectiveIds, setEditObjectiveIds] = useState<number[]>(resource.learning_objective_ids ?? []);
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => resourcesService.update(resource.id, data as Partial<Resource>),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["resources"] });
toast({ title: "Resource updated" });
onOpenChange(false);
},
onError: () => toast({ title: "Error", description: "Failed to update", variant: "destructive" }),
});
const handleSave = () => {
updateMutation.mutate({
name,
type,
subject_id: editSubjectId !== "none" ? Number(editSubjectId) : null,
domain_id: editDomainId !== "none" ? Number(editDomainId) : null,
topic_ids: editTopicIds,
learning_objective_ids: editObjectiveIds,
review_status: status,
tag_ids: tagIds,
});
};
const toggleTag = (id: number) => setTagIds((prev) => prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[540px] max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>Edit Resource</DialogTitle></DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2"><Label>Name</Label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="document">Document</SelectItem>
<SelectItem value="interactive">Interactive</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem>
<SelectItem value="article">Article</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as "pending" | "approved" | "rejected")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<TaxonomyCascade
subjectId={editSubjectId} onSubjectChange={setEditSubjectId}
domainId={editDomainId} onDomainChange={setEditDomainId}
topicIds={editTopicIds} onTopicIdsChange={setEditTopicIds}
objectiveIds={editObjectiveIds} onObjectiveIdsChange={setEditObjectiveIds}
/>
<TagPicker allTags={allTags} selectedIds={tagIds} onToggle={toggleTag} onTagCreated={onTagCreated} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={updateMutation.isPending || !name.trim()}>
{updateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── Main Component ──────────────────────────────────────────────────
export default function ResourceManager() {
const { toast } = useToast();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("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<string>("all");
const [showUpload, setShowUpload] = useState(false);
const [uploadType, setUploadType] = useState<string>("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 [showTagManager, setShowTagManager] = useState(false);
const [newTagName, setNewTagName] = useState("");
const [newTagColor, setNewTagColor] = useState(TAG_COLORS[0]);
const [editingTag, setEditingTag] = useState<ResourceTag | null>(null);
const [editingResource, setEditingResource] = useState<Resource | null>(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, review_status: statusFilter === "all" ? undefined : statusFilter, 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,
review_status: statusFilter === "all" ? undefined : statusFilter,
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 deleteMutation = useMutation({
mutationFn: (id: number) => resourcesService.delete(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource deleted" }); },
onError: () => toast({ title: "Error", description: "Failed to delete", variant: "destructive" }),
});
const uploadMutation = useMutation({
mutationFn: (formData: FormData) => resourcesService.upload(formData),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); },
onError: (err) => {
// describeApiError surfaces status code + actionable next step so QA /
// ops can tell 413 (payload limit) from 504 (timeout) from 401 (session
// expired) without opening DevTools. Default "Upload failed" was
// reported as non-actionable during the Apr-2026 deployment.
toast({
title: "Upload failed",
description: describeApiError(err, "Upload failed", "file"),
variant: "destructive",
});
},
});
const createTagMutation = useMutation({
mutationFn: (data: { name: string; color: string }) => resourcesService.createTag(data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resource-tags"] }); setNewTagName(""); toast({ title: "Tag created" }); },
onError: () => toast({ title: "Error", description: "Failed to create tag", variant: "destructive" }),
});
const updateTagMutation = useMutation({
mutationFn: ({ id, ...data }: { id: number; name: string; color: string }) => resourcesService.updateTag(id, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resource-tags"] }); qc.invalidateQueries({ queryKey: ["resources"] }); setEditingTag(null); toast({ title: "Tag updated" }); },
onError: () => toast({ title: "Error", description: "Failed to update tag", variant: "destructive" }),
});
const deleteTagMutation = useMutation({
mutationFn: (id: number) => resourcesService.deleteTag(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resource-tags"] }); qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Tag deleted" }); },
onError: () => toast({ title: "Error", description: "Failed to delete tag", 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 handleInlineTagCreated = () => qc.invalidateQueries({ queryKey: ["resource-tags"] });
const clearFilters = () => {
setSearch(""); setTypeFilter("all"); setStatusFilter("all");
setSubjectFilter("all"); setTagFilter("all"); setDateFrom(""); setDateTo("");
};
const hasActiveFilters = search || typeFilter !== "all" || statusFilter !== "all" || subjectFilter !== "all" || tagFilter !== "all" || dateFrom || dateTo;
const isLoading = loadingResources || loadingMaterials;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Resource Manager</h1>
<p className="text-muted-foreground">All learning content uploaded resources and course materials available for AI generation.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowTagManager(true)}><Tag className="mr-2 h-4 w-4" /> Manage Tags</Button>
<Dialog open={showUpload} onOpenChange={(o) => { setShowUpload(o); if (!o) { setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); } }}>
<DialogTrigger asChild><Button><Upload className="mr-2 h-4 w-4" /> Upload Resource</Button></DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader><DialogTitle>Upload New Resource</DialogTitle></DialogHeader>
<form onSubmit={handleUpload} className="space-y-4 pt-4">
<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="link">Link</SelectItem>
<SelectItem value="document">Document</SelectItem>
<SelectItem value="interactive">Interactive</SelectItem>
</SelectContent>
</Select>
</div>
<TaxonomyCascade
subjectId={uploadSubjectId} onSubjectChange={setUploadSubjectId}
domainId={uploadDomainId} onDomainChange={setUploadDomainId}
topicIds={uploadTopicIds} onTopicIdsChange={setUploadTopicIds}
objectiveIds={uploadObjectiveIds} onObjectiveIdsChange={setUploadObjectiveIds}
/>
<TagPicker
allTags={tags ?? []}
selectedIds={uploadTagIds}
onToggle={toggleUploadTag}
onTagCreated={(tag) => { handleInlineTagCreated(); toggleUploadTag(tag.id); }}
/>
<div className="space-y-2"><Label>File</Label><Input name="file" type="file" /></div>
<Button type="submit" className="w-full" disabled={uploadMutation.isPending}>
{uploadMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} Upload
</Button>
</form>
</DialogContent>
</Dialog>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("all")}>
<CardContent className="pt-6 text-center"><p className="text-2xl font-bold">{resources.length + courseMaterials.length}</p><p className="text-sm text-muted-foreground">Total Content Items</p></CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("resources")}>
<CardContent className="pt-6 text-center"><p className="text-2xl font-bold">{resources.length}</p><p className="text-sm text-muted-foreground">Library Resources</p></CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("materials")}>
<CardContent className="pt-6 text-center"><p className="text-2xl font-bold">{courseMaterials.length}</p><p className="text-sm text-muted-foreground">Course Materials</p></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 all content..." 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="article">Article</SelectItem><SelectItem value="document">Document</SelectItem><SelectItem value="audio">Audio</SelectItem>
</SelectContent>
</Select>
{activeTab !== "materials" && (
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[130px]"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem><SelectItem value="approved">Approved</SelectItem><SelectItem value="rejected">Rejected</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="resources">Library Resources ({resources.length})</TabsTrigger>
<TabsTrigger value="materials">Course Materials ({courseMaterials.length})</TabsTrigger>
</TabsList>
<TabsContent value="all">
<ContentTable resources={resources} materials={courseMaterials} showSource onDeleteResource={(id) => { if (window.confirm("Delete this resource?")) deleteMutation.mutate(id); }} onEditResource={setEditingResource} deletePending={deleteMutation.isPending} toast={toast} />
</TabsContent>
<TabsContent value="resources">
<ContentTable resources={resources} materials={[]} onDeleteResource={(id) => { if (window.confirm("Delete this resource?")) deleteMutation.mutate(id); }} onEditResource={setEditingResource} deletePending={deleteMutation.isPending} toast={toast} />
</TabsContent>
<TabsContent value="materials">
<ContentTable resources={[]} materials={courseMaterials} showCourseInfo onDeleteResource={() => {}} onEditResource={() => {}} deletePending={false} toast={toast} />
</TabsContent>
</Tabs>
)}
</CardContent>
</Card>
{/* Tag Manager Dialog */}
<Dialog open={showTagManager} onOpenChange={(o) => { setShowTagManager(o); if (!o) setEditingTag(null); }}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader><DialogTitle className="flex items-center gap-2"><Tag className="h-5 w-5" /> Manage Resource Tags</DialogTitle></DialogHeader>
<div className="space-y-4 pt-2">
<div className="flex gap-2">
<Input placeholder="New tag name…" value={newTagName} onChange={(e) => setNewTagName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newTagName.trim()) createTagMutation.mutate({ name: newTagName.trim(), color: newTagColor }); }} className="flex-1" />
<div className="flex gap-1">
{TAG_COLORS.slice(0, 5).map((c) => (
<button key={c} className="h-8 w-8 rounded-full border-2 transition-transform" style={{ backgroundColor: c, borderColor: newTagColor === c ? "#000" : "transparent", transform: newTagColor === c ? "scale(1.2)" : "scale(1)" }} onClick={() => setNewTagColor(c)} />
))}
</div>
<Button size="sm" disabled={!newTagName.trim() || createTagMutation.isPending} onClick={() => createTagMutation.mutate({ name: newTagName.trim(), color: newTagColor })}>
{createTagMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{(tags ?? []).length === 0 && <p className="text-sm text-muted-foreground text-center py-4">No tags yet. Create one above.</p>}
{(tags ?? []).map((t) => (
<div key={t.id} className="flex items-center justify-between p-2 rounded-lg border hover:bg-accent/50">
{editingTag?.id === t.id ? (
<div className="flex items-center gap-2 flex-1">
<Input value={editingTag.name} onChange={(e) => setEditingTag({ ...editingTag, name: e.target.value })} className="h-8 text-sm flex-1" />
<div className="flex gap-1">
{TAG_COLORS.slice(0, 5).map((c) => (
<button key={c} className="h-6 w-6 rounded-full border-2" style={{ backgroundColor: c, borderColor: editingTag.color === c ? "#000" : "transparent" }} onClick={() => setEditingTag({ ...editingTag, color: c })} />
))}
</div>
<Button size="sm" variant="ghost" onClick={() => updateTagMutation.mutate({ id: editingTag.id, name: editingTag.name, color: editingTag.color })} disabled={updateTagMutation.isPending}><CheckCircle2 className="h-4 w-4 text-green-600" /></Button>
<Button size="sm" variant="ghost" onClick={() => setEditingTag(null)}><X className="h-4 w-4" /></Button>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: t.color }} />
<span className="text-sm font-medium">{t.name}</span>
<Badge variant="outline" className="text-xs">{t.resource_count ?? 0}</Badge>
</div>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => setEditingTag(t)}><Pencil className="h-3 w-3" /></Button>
<Button size="sm" variant="ghost" onClick={() => { if (window.confirm(`Delete tag "${t.name}"?`)) deleteTagMutation.mutate(t.id); }} disabled={deleteTagMutation.isPending}><Trash2 className="h-3 w-3 text-destructive" /></Button>
</div>
</>
)}
</div>
))}
</div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setShowTagManager(false)}>Done</Button></DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Resource Dialog */}
{editingResource && (
<EditResourceDialog
resource={editingResource}
open={!!editingResource}
onOpenChange={(o) => { if (!o) setEditingResource(null); }}
allTags={tags ?? []}
onTagCreated={handleInlineTagCreated}
/>
)}
</div>
);
}
// ── Content Table ───────────────────────────────────────────────────
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;
}
function ContentTable({
resources, materials, showSource, showCourseInfo, onDeleteResource, onEditResource, deletePending, toast,
}: {
resources: Resource[]; materials: CourseMaterialItem[];
showSource?: boolean; showCourseInfo?: boolean;
onDeleteResource: (id: number) => void;
onEditResource: (r: Resource) => void;
deletePending: boolean;
toast: ReturnType<typeof useToast>["toast"];
}) {
type UnifiedRow = {
key: string; name: string; type: string; source: "resource" | "material";
context: string; tags?: { id: number; name: string; color: string }[];
status?: string; createdAt?: string; resourceId?: number; raw?: Resource;
};
const rows: UnifiedRow[] = [
...resources.map((r): UnifiedRow => ({
key: `r-${r.id}`, name: r.name, type: r.resource_type, source: "resource",
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, raw: r,
})),
...materials.map((m): UnifiedRow => ({
key: `m-${m.id}`, name: m.name, type: m.type, source: "material",
context: showCourseInfo || showSource ? `${m.course_name} ${m.chapter_name}` : m.chapter_name || "",
})),
];
const formatDate = (iso?: string) => {
if (!iso) return "";
try { return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } catch { return ""; }
};
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 === "resource" ? "secondary" : "default"} className="text-xs">
{row.source === "resource" ? "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 === "resource" && row.resourceId && (
<>
<Button variant="ghost" size="icon" title="Edit" onClick={() => row.raw && onEditResource(row.raw)}><Pencil className="h-4 w-4" /></Button>
<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={() => onDeleteResource(row.resourceId!)} disabled={deletePending}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</>
)}
</div>
</TableCell>
</TableRow>
);
})}
{rows.length === 0 && (<TableRow><TableCell colSpan={showSource ? 8 : 7} className="text-center py-8 text-muted-foreground">No content found.</TableCell></TableRow>)}
</TableBody>
</Table>
);
}