feat: Introduce asset picker modal and audio response blocks, refactor CMS asset API routes, and update dependencies.
This commit is contained in:
Generated
+1191
-10
File diff suppressed because it is too large
Load Diff
@@ -9,23 +9,24 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.2.10",
|
||||
"lucide-react": "^0.395.0",
|
||||
"next": "14.2.21",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"lucide-react": "^0.395.0",
|
||||
"framer-motion": "^11.2.10",
|
||||
"clsx": "^2.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"tailwind-merge": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.21"
|
||||
"eslint-config-next": "14.2.21",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Mic, Square, Play, RotateCcw, Check, X, Clock } from "lucide-react";
|
||||
|
||||
interface AudioResponsePlayerProps {
|
||||
id: string;
|
||||
prompt: string;
|
||||
keywords?: string[];
|
||||
timeLimit?: number;
|
||||
isGraded?: boolean;
|
||||
onComplete?: (score: number, transcript: string) => void;
|
||||
}
|
||||
|
||||
export default function AudioResponsePlayer({
|
||||
id,
|
||||
prompt,
|
||||
keywords = [],
|
||||
timeLimit,
|
||||
isGraded = false,
|
||||
onComplete
|
||||
}: AudioResponsePlayerProps) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [evaluation, setEvaluation] = useState<{ score: number; foundKeywords: string[] } | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize Web Speech API
|
||||
if (typeof window !== 'undefined' && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) {
|
||||
const SpeechRecognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition;
|
||||
recognitionRef.current = new SpeechRecognition();
|
||||
recognitionRef.current.continuous = true;
|
||||
recognitionRef.current.interimResults = true;
|
||||
recognitionRef.current.lang = 'en-US';
|
||||
|
||||
recognitionRef.current.onresult = (event: any) => {
|
||||
let finalTranscript = '';
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
if (event.results[i].isFinal) {
|
||||
finalTranscript += event.results[i][0].transcript + ' ';
|
||||
}
|
||||
}
|
||||
if (finalTranscript) {
|
||||
setTranscript(prev => prev + finalTranscript);
|
||||
}
|
||||
};
|
||||
|
||||
recognitionRef.current.onerror = (event: any) => {
|
||||
console.error('Speech recognition error:', event.error);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||
setAudioBlob(audioBlob);
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
setRecordingTime(0);
|
||||
|
||||
// Start speech recognition
|
||||
if (recognitionRef.current) {
|
||||
setTranscript("");
|
||||
recognitionRef.current.start();
|
||||
}
|
||||
|
||||
// Start timer
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingTime(prev => {
|
||||
const newTime = prev + 1;
|
||||
if (timeLimit && newTime >= timeLimit) {
|
||||
stopRecording();
|
||||
}
|
||||
return newTime;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Error accessing microphone:', error);
|
||||
alert('Could not access microphone. Please check permissions.');
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.stop();
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const playRecording = () => {
|
||||
if (audioBlob) {
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
audio.play();
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateResponse = () => {
|
||||
if (!transcript.trim()) {
|
||||
alert("No speech detected. Please try recording again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const transcriptLower = transcript.toLowerCase();
|
||||
const foundKeywords = keywords.filter(kw =>
|
||||
transcriptLower.includes(kw.toLowerCase())
|
||||
);
|
||||
|
||||
const score = keywords.length > 0
|
||||
? Math.round((foundKeywords.length / keywords.length) * 100)
|
||||
: 100; // If no keywords specified, give full credit for any response
|
||||
|
||||
setEvaluation({ score, foundKeywords });
|
||||
setSubmitted(true);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(score, transcript);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setAudioBlob(null);
|
||||
setTranscript("");
|
||||
setRecordingTime(0);
|
||||
setSubmitted(false);
|
||||
setEvaluation(null);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6" id={id}>
|
||||
<div className="p-8 glass border-white/5 rounded-3xl space-y-6">
|
||||
<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">{prompt}</p>
|
||||
{keywords.length > 0 && !submitted && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Expected topics:</span>
|
||||
{keywords.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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recording Controls */}
|
||||
{!submitted && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{!isRecording && !audioBlob && (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 rounded-2xl font-bold text-white shadow-lg shadow-purple-500/30 transition-all"
|
||||
>
|
||||
<Mic className="w-5 h-5" />
|
||||
Start Recording
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-3 px-6 py-3 bg-red-500/20 border-2 border-red-500 rounded-2xl animate-pulse">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse" />
|
||||
<span className="font-mono text-xl font-bold text-red-400">{formatTime(recordingTime)}</span>
|
||||
{timeLimit && (
|
||||
<span className="text-sm text-gray-400">/ {formatTime(timeLimit)}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-red-600 hover:bg-red-700 rounded-xl font-bold text-white transition-all"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
Stop Recording
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audioBlob && !isRecording && (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={playRecording}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-xl font-bold text-white transition-all"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Play Recording
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="flex items-center gap-2 px-6 py-3 glass hover:bg-white/10 rounded-xl font-bold text-gray-300 transition-all"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Re-record
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transcript Preview */}
|
||||
{transcript && !submitted && (
|
||||
<div className="p-4 bg-white/5 border border-white/10 rounded-xl">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Transcript:</p>
|
||||
<p className="text-sm text-gray-300">{transcript}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
{audioBlob && transcript && (
|
||||
<button
|
||||
onClick={evaluateResponse}
|
||||
className="w-full py-4 bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 rounded-xl font-bold text-white shadow-lg shadow-green-500/30 transition-all"
|
||||
>
|
||||
Submit Response
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evaluation Results */}
|
||||
{submitted && evaluation && (
|
||||
<div className="space-y-4">
|
||||
<div className={`p-6 rounded-2xl border-2 ${evaluation.score >= 70
|
||||
? 'bg-green-500/10 border-green-500'
|
||||
: evaluation.score >= 40
|
||||
? 'bg-yellow-500/10 border-yellow-500'
|
||||
: 'bg-red-500/10 border-red-500'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-bold uppercase tracking-wider text-gray-400">Your Score</span>
|
||||
<span className="text-3xl font-black">{evaluation.score}%</span>
|
||||
</div>
|
||||
|
||||
{keywords.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Keywords Found:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keywords.map((kw, i) => {
|
||||
const found = evaluation.foundKeywords.includes(kw);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`px-3 py-1 rounded-lg text-sm font-medium flex items-center gap-1 ${found
|
||||
? 'bg-green-500/20 border border-green-500/50 text-green-300'
|
||||
: 'bg-gray-500/20 border border-gray-500/50 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{found ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
|
||||
{kw}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white/5 border border-white/10 rounded-xl">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Your Transcript:</p>
|
||||
<p className="text-sm text-gray-300">{transcript}</p>
|
||||
</div>
|
||||
|
||||
{!isGraded && evaluation.score < 70 && (
|
||||
<button
|
||||
onClick={reset}
|
||||
className="w-full py-4 glass hover:bg-white/10 rounded-xl font-bold text-blue-400 transition-all"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
|
||||
interface DescriptionPlayerProps {
|
||||
id: string;
|
||||
title?: string;
|
||||
@@ -15,11 +18,8 @@ export default function DescriptionPlayer({ id, title, content }: DescriptionPla
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-invert prose-lg max-w-none">
|
||||
{/* We can use a markdown parser here later if desired, for now simple multiline text */}
|
||||
<div className="text-gray-300 leading-relaxed whitespace-pre-wrap font-medium">
|
||||
{content}
|
||||
</div>
|
||||
<div className="prose prose-invert prose-lg max-w-none prose-p:text-gray-300 prose-p:leading-relaxed prose-p:font-medium prose-headings:text-white prose-a:text-blue-400 prose-img:rounded-2xl prose-img:shadow-2xl">
|
||||
<ReactMarkdown urlTransform={getImageUrl}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_LMS_API_URL || "http://localhost:3002";
|
||||
export const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
|
||||
|
||||
export const getImageUrl = (path?: string) => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
|
||||
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
||||
return `${CMS_API_URL}${finalPath}`;
|
||||
};
|
||||
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Generated
+1179
-10
File diff suppressed because it is too large
Load Diff
@@ -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 morning routine 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(``);
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user