feat: Implement course-level asset management and interactive media markers.

This commit is contained in:
2026-01-17 13:55:04 -03:00
parent 0772a88fbe
commit 02909ea85a
15 changed files with 1027 additions and 182 deletions
@@ -218,6 +218,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
}
}
}}
isGraded={lesson.is_graded}
/>
);
case 'document':
@@ -12,18 +12,31 @@ interface MediaPlayerProps {
media_type: 'video' | 'audio';
config?: {
maxPlays?: number;
markers?: {
timestamp: number;
question: string;
options: string[];
correctIndex: number;
}[];
};
hasTranscription?: boolean;
initialPlayCount?: number;
onTimeUpdate?: (time: number) => void;
onPlay?: () => void;
isGraded?: boolean;
}
export default function MediaPlayer({ id, lessonId, title, url, media_type, config, hasTranscription, initialPlayCount, onTimeUpdate, onPlay }: MediaPlayerProps) {
export default function MediaPlayer({ id, lessonId, title, url, media_type, config, hasTranscription, initialPlayCount, onTimeUpdate, onPlay, isGraded }: MediaPlayerProps) {
const [playCount, setPlayCount] = useState(initialPlayCount || 0);
const [hasStarted, setHasStarted] = useState(false);
const [locked, setLocked] = useState(false);
// Marker State
const [activeMarker, setActiveMarker] = useState<{ question: string, options: string[], correctIndex: number } | null>(null);
const [handledMarkers, setHandledMarkers] = useState<Set<number>>(new Set());
const [lastTime, setLastTime] = useState(0);
const [feedback, setFeedback] = useState<{ isCorrect: boolean } | null>(null);
useEffect(() => {
if (initialPlayCount !== undefined) {
setPlayCount(initialPlayCount);
@@ -138,8 +151,28 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
className="w-full h-full rounded-xl"
onPlay={handlePlay}
onTimeUpdate={(e) => {
const time = e.currentTarget.currentTime;
// Marker Logic
if (config?.markers && !activeMarker) {
// Check for markers we just crossed
const markers = config.markers;
for (const marker of markers) {
// Trigger if we crossed the timestamp and haven't handled it yet
// Use a small window to ensure we catch it but don't double trigger
if (time >= marker.timestamp && lastTime < marker.timestamp && !handledMarkers.has(marker.timestamp)) {
e.currentTarget.pause();
setActiveMarker(marker);
setHandledMarkers(prev => new Set(prev).add(marker.timestamp));
break;
}
}
}
setLastTime(time);
if (onTimeUpdate) {
onTimeUpdate(e.currentTarget.currentTime);
onTimeUpdate(time);
}
}}
>
@@ -177,6 +210,71 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
<span>Presta atención. El contenido se bloqueará después de {maxPlays} reproducciones.</span>
</div>
)}
{/* Question Overlay */}
{activeMarker && (
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md rounded-xl animate-in fade-in duration-300">
<div className="bg-white text-black p-6 rounded-2xl shadow-2xl max-w-sm w-full space-y-4">
<div className="flex items-center gap-2 text-blue-600 font-bold text-xs uppercase tracking-widest">
<AlertCircle size={16} />
<span>Quick Check</span>
</div>
<h4 className="text-xl font-bold">{activeMarker.question}</h4>
<div className="grid grid-cols-2 gap-3">
{activeMarker.options.map((option, idx) => (
<button
key={idx}
disabled={!!feedback}
onClick={() => {
const isCorrect = idx === activeMarker.correctIndex;
if (isGraded) {
// Graded Mode: Show feedback then continue
setFeedback({ isCorrect });
// Save answer to backend (mocked for now)
console.log(`Submitted answer for marker at ${activeMarker}: ${isCorrect ? 'Correct' : 'Wrong'}`);
setTimeout(() => {
setFeedback(null);
setActiveMarker(null);
const video = document.querySelector(`div[id="${id}"] video`) as HTMLVideoElement;
if (video) video.play();
}, 1500);
} else {
// Formative Mode: Block until correct
if (isCorrect) {
setFeedback({ isCorrect: true });
setTimeout(() => {
setFeedback(null);
setActiveMarker(null);
const video = document.querySelector(`div[id="${id}"] video`) as HTMLVideoElement;
if (video) video.play();
}, 1000);
} else {
setFeedback({ isCorrect: false });
alert("Try again! (This is just practice)");
setFeedback(null);
}
}
}}
className={`px-4 py-3 rounded-xl font-medium transition-all text-left ${feedback
? idx === activeMarker.correctIndex
? "bg-green-500 text-white"
: feedback.isCorrect === false && "bg-red-500 text-white"
: "bg-gray-100 hover:bg-blue-500 hover:text-white"
}`}
>
{option}
</button>
))}
</div>
{feedback && (
<div className={`mt-2 text-center text-sm font-bold uppercase tracking-widest ${feedback.isCorrect ? 'text-green-600' : 'text-red-500'}`}>
{feedback.isCorrect ? "Correct!" : "Incorrect"}
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,166 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation";
import CourseEditorLayout from "@/components/CourseEditorLayout";
import { cmsApi, Asset, getImageUrl } from "@/lib/api";
import { Upload, Trash2, Copy, FileText, Image as ImageIcon, Film, File as FileIcon } from "lucide-react";
export default function CourseFilesPage() {
const { id } = useParams() as { id: string };
const [assets, setAssets] = useState<Asset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const loadAssets = useCallback(async () => {
try {
const data = await cmsApi.getCourseAssets(id);
setAssets(data);
} catch (error) {
console.error("Failed to load assets:", error);
} finally {
setIsLoading(false);
}
}, [id]);
useEffect(() => {
loadAssets();
}, [loadAssets]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
setUploadProgress(0);
try {
await cmsApi.uploadAsset(file, (pct) => setUploadProgress(pct), id);
await loadAssets(); // Refresh list
} catch (error) {
console.error("Upload failed:", error);
alert("Failed to upload file");
} finally {
setIsUploading(false);
setUploadProgress(0);
}
};
const handleDelete = async (assetId: string) => {
if (!confirm("Are you sure you want to delete this file? This cannot be undone.")) return;
try {
await cmsApi.deleteAsset(assetId);
setAssets(assets.filter(a => a.id !== assetId));
} catch (error) {
console.error("Delete failed:", error);
alert("Failed to delete file");
}
};
const copyToClipboard = (url: string) => {
// Copy the relative path (e.g. /assets/uuid.ext) for use in lessons
navigator.clipboard.writeText(url);
alert(`Copied URL: ${url}`);
};
const getIcon = (mimetype: string) => {
if (mimetype.startsWith('image/')) return <ImageIcon className="w-8 h-8 text-blue-400" />;
if (mimetype.startsWith('video/')) return <Film className="w-8 h-8 text-purple-400" />;
if (mimetype.includes('pdf')) return <FileText className="w-8 h-8 text-red-400" />;
return <FileIcon className="w-8 h-8 text-gray-400" />;
};
const formatSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<CourseEditorLayout activeTab="files">
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Course Files & Assets</h1>
<p className="text-gray-400 mt-1">Manage files specific to this course. These will be included in exports.</p>
</div>
<div className="relative">
<input
type="file"
onChange={handleUpload}
className="hidden"
id="file-upload"
disabled={isUploading}
/>
<label
htmlFor="file-upload"
className={`btn btn-primary gap-2 cursor-pointer ${isUploading ? 'loading' : ''}`}
>
{!isUploading && <Upload className="w-4 h-4" />}
{isUploading ? `Uploading ${uploadProgress}%` : 'Upload File'}
</label>
</div>
</div>
<div className="glass rounded-xl overflow-hidden">
<table className="w-full text-left">
<thead className="bg-white/5 border-b border-white/10 text-gray-400 font-medium">
<tr>
<th className="p-4">Name</th>
<th className="p-4">Type</th>
<th className="p-4">Size</th>
<th className="p-4">Uploaded</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{isLoading ? (
<tr><td colSpan={5} className="p-8 text-center text-gray-500">Loading files...</td></tr>
) : assets.length === 0 ? (
<tr><td colSpan={5} className="p-12 text-center text-gray-500">No files uploaded yet.</td></tr>
) : (
assets.map((asset) => (
<tr key={asset.id} className="hover:bg-white/5 transition-colors">
<td className="p-4">
<div className="flex items-center gap-3">
{getIcon(asset.mimetype)}
<div>
<div className="font-medium text-white">{asset.filename}</div>
<div className="text-xs text-blue-400">{getImageUrl(asset.storage_path.replace('uploads/', '/assets/'))}</div>
</div>
</div>
</td>
<td className="p-4 text-gray-400 font-mono text-sm">{asset.mimetype}</td>
<td className="p-4 text-gray-400 text-sm">{formatSize(asset.size_bytes)}</td>
<td className="p-4 text-gray-400 text-sm">{new Date(asset.created_at).toLocaleDateString()}</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => copyToClipboard(asset.storage_path.replace('uploads/', '/assets/'))}
title="Copy Internal URL"
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-blue-400"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(asset.id)}
title="Delete File"
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</CourseEditorLayout>
);
}
@@ -3,11 +3,11 @@
import React from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Layout, CheckCircle2, Calendar, BarChart2, Settings } from "lucide-react";
import { Layout, CheckCircle2, Calendar, BarChart2, Settings, Folder } from "lucide-react";
interface CourseEditorLayoutProps {
children: React.ReactNode;
activeTab: "outline" | "grading" | "calendar" | "analytics" | "settings";
activeTab: "outline" | "grading" | "calendar" | "analytics" | "settings" | "files";
}
export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) {
@@ -18,6 +18,7 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
{ key: "grading", label: "Grading", icon: CheckCircle2, href: `/courses/${id}/grading` },
{ key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` },
{ key: "analytics", label: "Analytics", icon: BarChart2, href: `/courses/${id}/analytics` },
{ key: "files", label: "Files & Uploads", icon: Folder, href: `/courses/${id}/files` },
{ key: "settings", label: "Settings", icon: Settings, href: `/courses/${id}/settings` },
];
@@ -34,8 +35,8 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
key={tab.key}
href={tab.href}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors ${isActive
? "border-b-2 border-blue-500 bg-white/5"
: "text-gray-500 hover:text-white"
? "border-b-2 border-blue-500 bg-white/5"
: "text-gray-500 hover:text-white"
}`}
>
<Icon className="w-4 h-4" />
+162 -1
View File
@@ -5,6 +5,13 @@ import MediaPlayer from "../MediaPlayer";
import FileUpload from "../FileUpload";
import { getImageUrl } from "@/lib/api";
export interface Marker {
timestamp: number;
question: string;
options: string[];
correctIndex: number;
}
interface MediaBlockProps {
id: string;
title?: string;
@@ -14,9 +21,19 @@ interface MediaBlockProps {
maxPlays?: number;
currentPlays?: number;
show_transcript?: boolean;
markers?: Marker[];
};
editMode: boolean;
onChange: (updates: { title?: string; url?: string; config?: { maxPlays?: number; currentPlays?: number; show_transcript?: boolean } }) => void;
onChange: (updates: {
title?: string;
url?: string;
config?: {
maxPlays?: number;
currentPlays?: number;
show_transcript?: boolean;
markers?: Marker[];
}
}) => void;
transcription?: {
en?: string;
es?: string;
@@ -128,6 +145,150 @@ export default function MediaBlock({ title, url, type, config, editMode, onChang
<p className="text-[10px] text-gray-500 uppercase leading-relaxed mt-2">Uncheck to hide transcription text (e.g. for listening tests).</p>
</div>
</div>
{/* Markers Editor */}
<div className="space-y-4 pt-6 border-t border-white/10">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest block">Interactive Questions (Timestamps)</label>
<div className="space-y-2">
{(config.markers || []).map((marker, idx) => (
<div key={idx} className="bg-white/5 p-4 rounded-lg border border-white/5 space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-mono bg-blue-500/20 text-blue-400 px-2 py-1 rounded">
{Math.floor(marker.timestamp / 60)}:{String(marker.timestamp % 60).padStart(2, '0')}
</span>
<input
value={marker.question}
onChange={(e) => {
const newMarkers = [...(config.markers || [])];
newMarkers[idx].question = e.target.value;
onChange({ config: { ...config, markers: newMarkers } });
}}
className="text-sm bg-transparent border-b border-white/10 flex-1 focus:border-blue-500 outline-none"
/>
<button
onClick={() => {
const newMarkers = [...(config.markers || [])];
// Change order safely
if (idx > 0) {
[newMarkers[idx], newMarkers[idx - 1]] = [newMarkers[idx - 1], newMarkers[idx]];
newMarkers[idx].timestamp = newMarkers[idx - 1].timestamp; // Keep timestamp (?) No, we sort by timestamp usually.
// Simpler delete logic only for now. Reordering happens automatically by sort on Add.
}
}}
className="text-gray-500 hover:text-white hidden"
>
</button>
<button
onClick={() => {
const newMarkers = [...(config.markers || [])];
newMarkers.splice(idx, 1);
onChange({ config: { ...config, markers: newMarkers } });
}}
className="text-red-400 hover:text-red-300 p-1"
>
×
</button>
</div>
{/* Options Management */}
<div className="pl-14 space-y-2">
<label className="text-[10px] font-bold text-gray-500 uppercase">Options</label>
{marker.options.map((opt, optIdx) => (
<div key={optIdx} className="flex items-center gap-2">
<input
type="radio"
name={`correct-${idx}`}
checked={marker.correctIndex === optIdx}
onChange={() => {
const newMarkers = [...(config.markers || [])];
newMarkers[idx].correctIndex = optIdx;
onChange({ config: { ...config, markers: newMarkers } });
}}
className="accent-green-500"
/>
<input
value={opt}
onChange={(e) => {
const newMarkers = [...(config.markers || [])];
newMarkers[idx].options[optIdx] = e.target.value;
onChange({ config: { ...config, markers: newMarkers } });
}}
className={`text-xs bg-transparent border border-white/10 rounded px-2 py-1 flex-1 ${marker.correctIndex === optIdx ? 'text-green-400 border-green-500/30' : ''}`}
/>
<button
onClick={() => {
const newMarkers = [...(config.markers || [])];
newMarkers[idx].options.splice(optIdx, 1);
if (newMarkers[idx].correctIndex >= optIdx) {
newMarkers[idx].correctIndex = Math.max(0, newMarkers[idx].correctIndex - 1);
}
onChange({ config: { ...config, markers: newMarkers } });
}}
className="text-gray-600 hover:text-red-400 px-2"
>
×
</button>
</div>
))}
<button
onClick={() => {
const newMarkers = [...(config.markers || [])];
newMarkers[idx].options.push(`Option ${newMarkers[idx].options.length + 1}`);
onChange({ config: { ...config, markers: newMarkers } });
}}
className="text-[10px] text-blue-400 hover:text-blue-300 uppercase font-bold tracking-widest mt-1"
>
+ Add Option
</button>
</div>
</div>
))}
</div>
<div className="grid grid-cols-4 gap-2">
<input
code-type="number"
placeholder="Sec"
id="new-marker-time"
className="col-span-1 bg-white/5 border border-white/10 rounded px-3 py-2 text-sm"
/>
<input
type="text"
placeholder="Question?"
id="new-marker-question"
className="col-span-2 bg-white/5 border border-white/10 rounded px-3 py-2 text-sm"
/>
<button
onClick={() => {
const timeInput = document.getElementById('new-marker-time') as HTMLInputElement;
const questionInput = document.getElementById('new-marker-question') as HTMLInputElement;
const time = parseInt(timeInput.value);
const question = questionInput.value;
if (time >= 0 && question) {
const newMarker: Marker = {
timestamp: time,
question,
options: ["Yes", "No"], // Default options
correctIndex: 0
};
const newMarkers = [...(config.markers || []), newMarker].sort((a, b) => a.timestamp - b.timestamp);
onChange({ config: { ...config, markers: newMarkers } });
timeInput.value = "";
questionInput.value = "";
}
}}
className="col-span-1 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs font-bold uppercase"
>
Add
</button>
</div>
<p className="text-[10px] text-gray-500 uppercase leading-relaxed">
Questions will pause the video at the specified second. Only simple Yes/No questions supported currently.
</p>
</div>
</div>
)}
+14 -1
View File
@@ -206,6 +206,16 @@ export interface CreateWebhookPayload {
secret?: string;
}
export interface Asset {
id: string;
course_id: string | null;
filename: string;
storage_path: string;
mimetype: string;
size_bytes: number;
created_at: string;
}
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null;
const getSelectedOrgId = () => typeof window !== 'undefined' ? localStorage.getItem('studio_selected_org_id') : null;
@@ -301,10 +311,13 @@ export const cmsApi = {
deleteWebhook: (id: string): Promise<void> => apiFetch(`/webhooks/${id}`, { method: 'DELETE' }),
// Assets
uploadAsset: (file: File, onProgress?: (pct: number) => void): Promise<UploadResponse> => {
getCourseAssets: (courseId: string): Promise<Asset[]> => apiFetch(`/courses/${courseId}/assets`),
deleteAsset: (id: string): Promise<void> => apiFetch(`/assets/${id}`, { method: 'DELETE' }),
uploadAsset: (file: File, onProgress?: (pct: number) => void, courseId?: string): Promise<UploadResponse> => {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
if (courseId) formData.append('course_id', courseId);
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE_URL}/assets/upload`);