feat: Introduce asset picker modal and audio response blocks, refactor CMS asset API routes, and update dependencies.

This commit is contained in:
2026-01-18 01:02:01 -03:00
parent 02909ea85a
commit 46b6253f22
16 changed files with 3101 additions and 168 deletions
+1179 -10
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,6 +17,7 @@
"next": "14.2.21",
"react": "^18",
"react-dom": "^18",
"react-markdown": "^10.1.0",
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
@@ -30,7 +30,6 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
// Activity State (Blocks)
const [blocks, setBlocks] = useState<Block[]>([]);
const [summary, setSummary] = useState<string>("");
const [isTranscribing, setIsTranscribing] = useState(false);
const [isGeneratingSummary, setIsGeneratingSummary] = useState(false);
const [isGeneratingQuiz, setIsGeneratingQuiz] = useState(false);
const [gradingCategories, setGradingCategories] = useState<GradingCategory[]>([]);
@@ -42,7 +41,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [importantDateType, setImportantDateType] = useState<string>("");
const [editValue, setEditValue] = useState("");
const [transcriptionTimer, setTranscriptionTimer] = useState(0);
// Polling for AI status
useEffect(() => {
@@ -72,27 +71,6 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
};
}, [lesson, lesson?.transcription_status, params.lessonId]);
// Timer logic
useEffect(() => {
let timerInterval: NodeJS.Timeout;
if (lesson && (lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing')) {
timerInterval = setInterval(() => {
setTranscriptionTimer(prev => prev + 1);
}, 1000);
} else {
setTranscriptionTimer(0);
}
return () => {
if (timerInterval) clearInterval(timerInterval);
};
}, [lesson, lesson?.transcription_status]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
useEffect(() => {
const loadData = async () => {
@@ -209,18 +187,6 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
setBlocks(newBlocks);
};
const handleTranscribe = async () => {
if (!lesson) return;
setIsTranscribing(true);
try {
const updated = await cmsApi.transcribeLesson(lesson.id);
setLesson(updated);
} catch {
alert("Failed to transcribe video.");
} finally {
setIsTranscribing(false);
}
};
const handleSummarize = async () => {
if (!lesson) return;
@@ -440,58 +406,26 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(lesson.content_type === 'video' || lesson.content_type === 'audio') && (
<button
onClick={handleTranscribe}
disabled={isTranscribing || lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing'}
className={`p-6 rounded-2xl border transition-all text-left flex flex-col gap-2 relative overflow-hidden ${lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing'
? 'bg-blue-500/20 border-blue-500/50 text-blue-300 animate-pulse'
: lesson.transcription_status === 'completed' || lesson.transcription
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'bg-blue-500/10 border-blue-500/30 text-blue-400 hover:border-blue-500/60'
}`}
>
<span className="text-xl">
{lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing' ? '⏳' : '🎤'}
</span>
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Transcription & Translation</div>
<div className="font-bold">
{lesson.transcription_status === 'queued'
? `Queued (${formatTime(transcriptionTimer)})`
: lesson.transcription_status === 'processing'
? `Processing (${formatTime(transcriptionTimer)})`
: lesson.transcription ? 'Regenerate Content' : 'Transcribe & Translate'}
</div>
{lesson.transcription && !lesson.transcription.es && lesson.transcription_status === 'completed' && (
<div className="text-[8px] text-amber-500 font-bold uppercase">Translation missing</div>
)}
{(lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing') && (
<div className="absolute bottom-0 left-0 h-1 bg-blue-500 animate-[progress_2s_ease-in-out_infinite]" style={{ width: '100%' }}></div>
)}
</button>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={handleSummarize}
disabled={isGeneratingSummary || !lesson.transcription}
className={`p-6 rounded-2xl border transition-all text-left flex flex-col gap-2 ${isGeneratingSummary ? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-300 animate-pulse' : summary ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-indigo-500/10 border-indigo-500/30 text-indigo-400 hover:border-indigo-500/60 disabled:opacity-30 disabled:cursor-not-allowed'}`}
disabled={isGeneratingSummary}
className={`p-6 rounded-2xl border transition-all text-left flex flex-col gap-2 ${isGeneratingSummary ? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-300 animate-pulse' : summary ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-indigo-500/10 border-indigo-500/30 text-indigo-400 hover:border-indigo-500/60'}`}
>
<span className="text-xl">{isGeneratingSummary ? '⏳' : '✍️'}</span>
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Summarization</div>
<div className="font-bold">{isGeneratingSummary ? 'Generating...' : summary ? 'Update Summary' : 'Generate Summary'}</div>
{!lesson.transcription && <div className="text-[8px] opacity-60">Requires Transcript</div>}
</button>
<button
onClick={handleGenerateQuiz}
disabled={isGeneratingQuiz || !lesson.transcription}
className={`p-6 border rounded-2xl transition-all text-left flex flex-col gap-2 ${isGeneratingQuiz ? 'bg-purple-500/20 border-purple-500/50 text-purple-300 animate-pulse' : 'bg-purple-500/10 border-purple-500/30 hover:border-purple-500/60 text-purple-400 disabled:opacity-30 disabled:cursor-not-allowed'}`}
disabled={isGeneratingQuiz}
className={`p-6 border rounded-2xl transition-all text-left flex flex-col gap-2 ${isGeneratingQuiz ? 'bg-purple-500/20 border-purple-500/50 text-purple-300 animate-pulse' : 'bg-purple-500/10 border-purple-500/30 hover:border-purple-500/60 text-purple-400'}`}
>
<span className="text-xl">{isGeneratingQuiz ? '⏳' : '💡'}</span>
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Assessments</div>
<div className="font-bold">{isGeneratingQuiz ? 'Building...' : 'Generate Quiz'}</div>
{!lesson.transcription && <div className="text-[8px] opacity-60">Requires Transcript</div>}
</button>
</div>
</div>
@@ -569,6 +503,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
title={block.title}
content={block.content || ""}
editMode={editMode}
courseId={params.id}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
@@ -0,0 +1,121 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
import { Search, Image as ImageIcon, FileText, Film, File as FileIcon, Loader2 } from "lucide-react";
import Modal from "./Modal";
import { cmsApi, Asset } from "@/lib/api";
interface AssetPickerModalProps {
isOpen: boolean;
onClose: () => void;
courseId: string;
onSelect: (asset: Asset) => void;
filterType?: "image" | "file" | "all";
}
export default function AssetPickerModal({
isOpen,
onClose,
courseId,
onSelect,
filterType = "all"
}: AssetPickerModalProps) {
const [assets, setAssets] = useState<Asset[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const loadAssets = useCallback(async () => {
if (!isOpen) return;
setIsLoading(true);
try {
const data = await cmsApi.getCourseAssets(courseId);
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/"));
}
setAssets(filtered);
} catch (error) {
console.error("Failed to load assets for picker:", error);
} finally {
setIsLoading(false);
}
}, [courseId, isOpen, filterType]);
useEffect(() => {
loadAssets();
}, [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" />;
if (mimetype.includes('pdf')) return <FileText className="w-5 h-5 text-red-400" />;
return <FileIcon className="w-5 h-5 text-gray-400" />;
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={filterType === "image" ? "Select Image" : filterType === "file" ? "Select File" : "Select Asset"}
>
<div className="space-y-4 max-h-[60vh] overflow-hidden flex flex-col">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Search files..."
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"
/>
</div>
<div className="flex-1 overflow-y-auto space-y-2 min-h-[300px] 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>
) : 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."}
</div>
) : (
filteredAssets.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"
>
<div className="p-2 bg-white/5 rounded-lg group-hover:bg-white/10 transition-colors">
{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>
</div>
</button>
))
)}
</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>
</div>
</Modal>
);
}
@@ -0,0 +1,134 @@
"use client";
import { Mic, Clock, Tag } from "lucide-react";
interface AudioResponseBlockProps {
id: string;
title?: string;
prompt: string;
keywords?: string[];
timeLimit?: number; // in seconds
editMode: boolean;
onChange: (updates: { title?: string; prompt?: string; keywords?: string[]; timeLimit?: number }) => void;
}
export default function AudioResponseBlock({
id,
title,
prompt,
keywords = [],
timeLimit,
editMode,
onChange
}: AudioResponseBlockProps) {
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
{editMode ? (
<div className="space-y-2 p-6 glass border-white/5 bg-white/5 mb-4">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Activity Title (Optional)</label>
<input
type="text"
value={title || ""}
onChange={(e) => onChange({ title: e.target.value })}
placeholder="e.g. Speaking Practice, Pronunciation Test..."
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none"
/>
</div>
) : (
<h3 className="text-xl font-bold border-l-4 border-purple-500 pl-4 py-1 tracking-tight text-white">
{title || "Audio Response"}
</h3>
)}
</div>
{editMode ? (
<div className="space-y-6">
<div className="p-6 glass border-white/5 space-y-4">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest flex items-center gap-2">
<Mic className="w-4 h-4" />
Question Prompt
</label>
<textarea
value={prompt}
onChange={(e) => onChange({ prompt: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-xl p-4 min-h-[100px] text-lg font-medium focus:outline-none focus:border-purple-500/50 transition-all"
placeholder="What question should the student answer? (e.g. 'Describe your daily routine in English')"
/>
</div>
<div className="p-6 glass border-white/5 space-y-4">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest flex items-center gap-2">
<Tag className="w-4 h-4" />
Expected Keywords (Optional)
</label>
<textarea
value={keywords.join("\n")}
onChange={(e) => onChange({ keywords: e.target.value.split("\n").filter(k => k.trim() !== "") })}
className="w-full bg-white/5 border border-white/10 rounded-xl p-4 min-h-[100px] text-sm font-medium focus:outline-none focus:border-purple-500/50 transition-all"
placeholder="breakfast&#10;morning&#10;routine&#10;work"
/>
<p className="text-[10px] text-gray-500 uppercase tracking-wider">
One keyword per line. Used for automatic evaluation of speech content.
</p>
</div>
<div className="p-6 glass border-white/5 space-y-4">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest flex items-center gap-2">
<Clock className="w-4 h-4" />
Time Limit (Optional)
</label>
<input
type="number"
value={timeLimit || ""}
onChange={(e) => onChange({ timeLimit: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="60"
min="10"
max="300"
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-medium focus:border-purple-500/50 focus:outline-none"
/>
<p className="text-[10px] text-gray-500 uppercase tracking-wider">
Maximum recording time in seconds (10-300). Leave empty for no limit.
</p>
</div>
</div>
) : (
<div className="p-8 glass border-white/5 rounded-3xl space-y-8">
<div className="flex items-start gap-4">
<div className="p-3 bg-purple-500/20 rounded-xl">
<Mic className="w-6 h-6 text-purple-400" />
</div>
<div className="flex-1">
<p className="text-xl font-bold text-gray-100 mb-2">{prompt || "Record your audio response:"}</p>
{keywords.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
<span className="text-xs text-gray-500 uppercase tracking-wider">Expected topics:</span>
{keywords.slice(0, 5).map((kw, i) => (
<span key={i} className="px-2 py-1 bg-purple-500/10 border border-purple-500/20 rounded text-xs text-purple-300">
{kw}
</span>
))}
</div>
)}
{timeLimit && (
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
Maximum {timeLimit} seconds
</p>
)}
</div>
</div>
<div className="p-6 bg-purple-500/5 border-2 border-dashed border-purple-500/20 rounded-2xl text-center">
<p className="text-sm text-gray-400">
🎤 Audio recording will be available in the Experience player
</p>
<p className="text-xs text-gray-600 mt-2">
Students will use their microphone to record their response
</p>
</div>
</div>
)}
</div>
);
}
@@ -1,19 +1,59 @@
"use client";
import { useState } from "react";
import { useState, useRef } from "react";
import ReactMarkdown from "react-markdown";
import { Bold, Italic, Link as LinkIcon, Image as ImageIcon, FileText, Eye, PenLine } from "lucide-react";
import AssetPickerModal from "../AssetPickerModal";
import { Asset, getImageUrl } from "@/lib/api";
interface DescriptionBlockProps {
id: string;
title?: string;
content: string;
editMode: boolean;
courseId: string;
onChange: (updates: { title?: string; content?: string }) => void;
}
export default function DescriptionBlock({ title, content, editMode, onChange }: DescriptionBlockProps) {
export default function DescriptionBlock({ id, title, content, editMode, courseId, onChange }: DescriptionBlockProps) {
const [showPreview, setShowPreview] = useState(false);
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const [pickerType, setPickerType] = useState<"image" | "file">("image");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const insertMarkdown = (prefix: string, suffix: string = "") => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
const before = text.substring(0, start);
const after = text.substring(end);
const newContent = before + prefix + selectedText + suffix + after;
onChange({ content: newContent });
// Restore focus and selection
setTimeout(() => {
textarea.focus();
const newCursorPos = start + prefix.length + selectedText.length + suffix.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
};
const handleAssetSelect = (asset: Asset) => {
const url = asset.storage_path.replace('uploads/', '/assets/');
if (asset.mimetype.startsWith('image/')) {
insertMarkdown(`![${asset.filename}](${url})`);
} else {
insertMarkdown(`[${asset.filename}](${url})`);
}
};
return (
<div className="space-y-6">
<div className="space-y-6" id={id}>
{/* Block Header */}
<div className="space-y-2">
{editMode ? (
@@ -35,47 +75,83 @@ export default function DescriptionBlock({ title, content, editMode, onChange }:
{editMode ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Instructional Content</label>
<div className="flex items-center gap-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Instructional Content</label>
<div className="h-4 w-px bg-white/10 mx-2" />
{/* Toolbar */}
{!showPreview && (
<div className="flex items-center gap-1">
<button onClick={() => insertMarkdown("**", "**")} className="p-1.5 hover:bg-white/10 rounded-md transition-colors text-gray-400 hover:text-white" title="Bold"><Bold size={14} /></button>
<button onClick={() => insertMarkdown("*", "*")} className="p-1.5 hover:bg-white/10 rounded-md transition-colors text-gray-400 hover:text-white" title="Italic"><Italic size={14} /></button>
<button onClick={() => insertMarkdown("[", "](url)")} className="p-1.5 hover:bg-white/10 rounded-md transition-colors text-gray-400 hover:text-white" title="Link"><LinkIcon size={14} /></button>
<div className="w-px h-3 bg-white/10 mx-1" />
<button
onClick={() => { setPickerType("image"); setIsAssetPickerOpen(true); }}
className="p-1.5 hover:bg-white/10 rounded-md transition-colors text-blue-400 hover:text-blue-300"
title="Insert Image"
>
<ImageIcon size={14} />
</button>
<button
onClick={() => { setPickerType("file"); setIsAssetPickerOpen(true); }}
className="p-1.5 hover:bg-white/10 rounded-md transition-colors text-purple-400 hover:text-purple-300"
title="Insert File Link"
>
<FileText size={14} />
</button>
</div>
)}
</div>
<div className="flex bg-white/5 rounded-lg p-1 border border-white/5">
<button
onClick={() => setShowPreview(false)}
className={`px-4 py-1.5 text-[10px] uppercase font-black tracking-widest rounded-md transition-all ${!showPreview ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
className={`px-3 py-1 flex items-center gap-2 text-[10px] uppercase font-black tracking-widest rounded-md transition-all ${!showPreview ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
>
Write
<PenLine size={12} /> Write
</button>
<button
onClick={() => setShowPreview(true)}
className={`px-4 py-1.5 text-[10px] uppercase font-black tracking-widest rounded-md transition-all ${showPreview ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
className={`px-3 py-1 flex items-center gap-2 text-[10px] uppercase font-black tracking-widest rounded-md transition-all ${showPreview ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
>
Preview
<Eye size={12} /> Preview
</button>
</div>
</div>
{showPreview ? (
<div className="min-h-[200px] p-6 rounded-xl glass border-white/5 bg-white/5">
<div className="prose prose-invert max-w-none">
<p className="text-gray-300 leading-relaxed text-lg whitespace-pre-wrap">
{content || "Nothing to preview..."}
</p>
<div className="min-h-[200px] p-8 rounded-2xl glass border-white/5 bg-white/5">
<div className="prose prose-invert max-w-none prose-p:text-gray-300 prose-p:leading-relaxed prose-p:text-lg prose-headings:text-white prose-a:text-blue-400 prose-img:rounded-xl">
<ReactMarkdown urlTransform={getImageUrl}>{content || "Nothing to preview..."}</ReactMarkdown>
</div>
</div>
) : (
<textarea
value={content}
onChange={(e) => onChange({ content: e.target.value })}
placeholder="Explain the activity to the students (Markdown supported)..."
className="w-full h-60 bg-white/5 border border-white/10 rounded-xl p-6 text-lg tracking-tight focus:border-blue-500/50 focus:outline-none transition-all resize-none shadow-inner"
/>
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => onChange({ content: e.target.value })}
placeholder="Explain the activity (Markdown supported)..."
className="w-full h-80 bg-white/5 border border-white/10 rounded-xl p-6 text-lg tracking-tight focus:border-blue-500/50 focus:outline-none transition-all resize-none shadow-inner custom-scrollbar"
/>
<div className="absolute bottom-4 right-4 text-[10px] text-gray-600 font-bold uppercase tracking-widest pointer-events-none">
Markdown Mode
</div>
</div>
)}
</div>
) : (
<div className="prose prose-invert max-w-none">
<p className="text-gray-300 leading-relaxed text-lg whitespace-pre-wrap">
{content || "No description provided."}
</p>
<div className="prose prose-invert max-w-none prose-p:text-gray-300 prose-p:leading-relaxed prose-p:text-lg prose-headings:text-white prose-a:text-blue-400 prose-img:rounded-xl">
<ReactMarkdown urlTransform={getImageUrl}>{content || "No description provided."}</ReactMarkdown>
</div>
)}
<AssetPickerModal
isOpen={isAssetPickerOpen}
onClose={() => setIsAssetPickerOpen(false)}
courseId={courseId}
filterType={pickerType}
onSelect={handleAssetSelect}
/>
</div>
);
}
+2 -3
View File
@@ -270,7 +270,6 @@ export const cmsApi = {
createLesson: (module_id: string, title: string, content_type: string, position: number): Promise<Lesson> => apiFetch('/lessons', { method: 'POST', body: JSON.stringify({ module_id, title, content_type, position }) }),
getLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}`),
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
transcribeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/transcribe`, { method: 'POST' }),
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
generateQuiz: (id: string): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST' }),
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
@@ -312,7 +311,7 @@ export const cmsApi = {
// Assets
getCourseAssets: (courseId: string): Promise<Asset[]> => apiFetch(`/courses/${courseId}/assets`),
deleteAsset: (id: string): Promise<void> => apiFetch(`/assets/${id}`, { method: 'DELETE' }),
deleteAsset: (id: string): Promise<void> => apiFetch(`/api/assets/${id}`, { method: 'DELETE' }),
uploadAsset: (file: File, onProgress?: (pct: number) => void, courseId?: string): Promise<UploadResponse> => {
return new Promise((resolve, reject) => {
const formData = new FormData();
@@ -320,7 +319,7 @@ export const cmsApi = {
if (courseId) formData.append('course_id', courseId);
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE_URL}/assets/upload`);
xhr.open('POST', `${API_BASE_URL}/api/assets/upload`);
const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);