feat: Add LTI launch, lesson preview, course progress, bookmarks, and asset management features.

This commit is contained in:
2026-02-23 15:43:45 -03:00
parent f365e585a2
commit 7f7ea3d70c
45 changed files with 5250 additions and 697 deletions
+75 -32
View File
@@ -8,9 +8,9 @@ import { cmsApi, Asset } from "@/lib/api";
interface AssetPickerModalProps {
isOpen: boolean;
onClose: () => void;
courseId: string;
courseId?: string;
onSelect: (asset: Asset) => void;
filterType?: "image" | "file" | "all";
filterType?: "image" | "file" | "video" | "all";
}
export default function AssetPickerModal({
@@ -23,34 +23,46 @@ export default function AssetPickerModal({
const [assets, setAssets] = useState<Asset[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [viewMode, setViewMode] = useState<"course" | "global">(courseId ? "course" : "global");
const loadAssets = useCallback(async () => {
if (!isOpen) return;
setIsLoading(true);
try {
const data = await cmsApi.getCourseAssets(courseId);
const filters: any = {};
if (viewMode === "course" && courseId) {
filters.course_id = courseId;
}
if (searchTerm) {
filters.search = searchTerm;
}
const data = await cmsApi.getAssets(filters);
let filtered = data;
if (filterType === "image") {
filtered = data.filter(a => a.mimetype.startsWith("image/"));
} else if (filterType === "file") {
filtered = data.filter(a => !a.mimetype.startsWith("image/"));
filtered = data.filter(a => !a.mimetype.startsWith("image/") && !a.mimetype.startsWith("video/"));
} else if (filterType === "video") {
filtered = data.filter(a => a.mimetype.startsWith("video/"));
}
setAssets(filtered);
} catch (error) {
console.error("Failed to load assets for picker:", error);
} finally {
setIsLoading(false);
}
}, [courseId, isOpen, filterType]);
}, [courseId, isOpen, filterType, viewMode, searchTerm]);
useEffect(() => {
loadAssets();
const timer = setTimeout(() => {
loadAssets();
}, 300);
return () => clearTimeout(timer);
}, [loadAssets]);
const filteredAssets = assets.filter(asset =>
asset.filename.toLowerCase().includes(searchTerm.toLowerCase())
);
const getIcon = (mimetype: string) => {
if (mimetype.startsWith('image/')) return <ImageIcon className="w-5 h-5 text-blue-400" />;
if (mimetype.startsWith('video/')) return <Film className="w-5 h-5 text-purple-400" />;
@@ -62,47 +74,78 @@ export default function AssetPickerModal({
<Modal
isOpen={isOpen}
onClose={onClose}
title={filterType === "image" ? "Select Image" : filterType === "file" ? "Select File" : "Select Asset"}
title={filterType === "image" ? "Select Image" : filterType === "video" ? "Select Video" : "Select Asset"}
>
<div className="space-y-4 max-h-[60vh] overflow-hidden flex flex-col">
<div className="space-y-4 max-h-[70vh] overflow-hidden flex flex-col">
<div className="flex gap-2 p-1 bg-white/5 rounded-xl border border-white/10">
{courseId && (
<button
onClick={() => setViewMode("course")}
className={`flex-1 py-2 text-[10px] uppercase font-bold tracking-widest rounded-lg transition-all ${viewMode === "course" ? "bg-blue-500 text-white shadow-lg shadow-blue-500/20" : "text-gray-400 hover:text-gray-200"}`}
>
This Course
</button>
)}
<button
onClick={() => setViewMode("global")}
className={`flex-1 py-2 text-[10px] uppercase font-bold tracking-widest rounded-lg transition-all ${viewMode === "global" ? "bg-blue-500 text-white shadow-lg shadow-blue-500/20" : "text-gray-400 hover:text-gray-200"}`}
>
Global Library
</button>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search files..."
placeholder="Search assets by filename..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl pl-10 pr-4 py-2 text-sm focus:outline-none focus:border-blue-500/50"
className="w-full bg-white/5 border border-white/10 rounded-xl pl-10 pr-4 py-2.5 text-sm font-medium text-gray-200 placeholder:text-gray-500 focus:outline-none focus:border-blue-500/50 transition-colors"
/>
</div>
<div className="flex-1 overflow-y-auto space-y-2 min-h-[300px] pr-2 custom-scrollbar">
<div className="flex-1 overflow-y-auto space-y-2 min-h-[350px] pr-2 custom-scrollbar">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-500 gap-3">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
<span className="text-xs uppercase font-bold tracking-widest">Loading assets...</span>
<div className="flex flex-col items-center justify-center py-24 text-gray-500 gap-4">
<Loader2 className="w-10 h-10 animate-spin text-blue-500" />
<span className="text-[10px] uppercase font-black tracking-[0.2em] opacity-50">Synchronizing...</span>
</div>
) : filteredAssets.length === 0 ? (
<div className="text-center py-20 text-gray-500 text-sm">
{searchTerm ? "No files match your search." : "No assets found for this course."}
) : assets.length === 0 ? (
<div className="text-center py-24 flex flex-col items-center gap-4">
<div className="p-4 bg-white/5 rounded-2xl border border-white/5">
<FileIcon className="w-8 h-8 text-gray-600" />
</div>
<div className="space-y-1">
<div className="text-sm font-bold text-gray-400">Empty Collection</div>
<div className="text-[10px] text-gray-500 uppercase tracking-widest">
{searchTerm ? "No results found" : "No assets uploaded yet"}
</div>
</div>
</div>
) : (
filteredAssets.map((asset) => (
assets.map((asset) => (
<button
key={asset.id}
onClick={() => {
onSelect(asset);
onClose();
}}
className="w-full flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-transparent hover:border-white/10 hover:bg-white/10 transition-all text-left group"
className="w-full flex items-center gap-4 p-3.5 rounded-2xl bg-white/5 border border-white/5 hover:border-blue-500/30 hover:bg-blue-500/5 transition-all text-left group"
>
<div className="p-2 bg-white/5 rounded-lg group-hover:bg-white/10 transition-colors">
<div className="w-12 h-12 flex items-center justify-center bg-white/5 rounded-xl group-hover:bg-blue-500/10 transition-colors shrink-0">
{getIcon(asset.mimetype)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-gray-200 truncate">{asset.filename}</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider">
{(asset.size_bytes / 1024 / 1024).toFixed(2)} MB {asset.mimetype.split('/')[1]}
<div className="text-sm font-bold text-gray-200 truncate group-hover:text-blue-400 transition-colors">{asset.filename}</div>
<div className="flex items-center gap-2 mt-1">
<div className="text-[10px] text-gray-500 uppercase font-bold tracking-wider">
{(asset.size_bytes / 1024 / 1024).toFixed(2)} MB
</div>
<div className="w-1 h-1 rounded-full bg-gray-700" />
<div className="text-[10px] text-gray-500 uppercase font-bold tracking-wider truncate">
{asset.mimetype.split('/')[1]}
</div>
</div>
</div>
</button>
@@ -110,10 +153,10 @@ export default function AssetPickerModal({
)}
</div>
<div className="pt-4 border-t border-white/5 text-center">
<p className="text-[10px] text-gray-500 uppercase tracking-widest">
Only assets uploaded to this course are shown.
</p>
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
<div className="text-[10px] text-gray-500 uppercase font-bold tracking-[0.1em]">
{assets.length} Assets Found
</div>
</div>
</div>
</Modal>
@@ -7,7 +7,7 @@ import { Layout, CheckCircle2, Calendar, BarChart2, Settings, Folder, Graduation
interface CourseEditorLayoutProps {
children: React.ReactNode;
activeTab: "outline" | "grading" | "rubrics" | "calendar" | "analytics" | "settings" | "files" | "grades" | "announcements";
activeTab: "outline" | "grading" | "rubrics" | "calendar" | "analytics" | "settings" | "files" | "grades" | "announcements" | "team";
}
export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) {
@@ -17,6 +17,7 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
{ key: "outline", label: "Outline", icon: Layout, href: `/courses/${id}` },
{ key: "grading", label: "Grading Policy", icon: CheckCircle2, href: `/courses/${id}/grading` },
{ key: "rubrics", label: "Rubrics", icon: Layout, href: `/courses/${id}/rubrics` },
{ key: "team", label: "Team", icon: GraduationCap, href: `/courses/${id}/team` },
{ key: "grades", label: "Gradebook", icon: GraduationCap, href: `/courses/${id}/grades` },
{ key: "announcements", label: "Announcements", icon: Megaphone, href: `/courses/${id}/announcements` },
{ key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` },
+9 -1
View File
@@ -3,7 +3,7 @@
import Link from 'next/link';
import { useAuth } from '@/context/AuthContext';
import { useTranslation } from '@/context/I18nContext';
import { LayoutDashboard, ShieldCheck, LogOut, Webhook, Settings, Globe } from 'lucide-react';
import { LayoutDashboard, ShieldCheck, LogOut, Webhook, Settings, Globe, Library } from 'lucide-react';
export function Navbar() {
const { t, language, setLanguage } = useTranslation();
@@ -28,6 +28,14 @@ export function Navbar() {
{t('nav.courses')}
</Link>
<Link
href="/library/assets"
className="text-sm font-medium text-gray-400 hover:text-blue-400 transition-colors flex items-center gap-2"
>
<Library className="w-4 h-4" />
{t('nav.library') || 'Library'}
</Link>
{user?.role === 'admin' && (
<>
{user.organization_id === '00000000-0000-0000-0000-000000000001' && (