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
-19
View File
@@ -22,7 +22,6 @@ services:
JWT_SECRET: openccb_secret_key_2025_production JWT_SECRET: openccb_secret_key_2025_production
NEXT_PUBLIC_CMS_API_URL: http://localhost:3001 NEXT_PUBLIC_CMS_API_URL: http://localhost:3001
LMS_INTERNAL_URL: http://experience:3002 LMS_INTERNAL_URL: http://experience:3002
LOCAL_WHISPER_URL: http://whisper:8000
volumes: volumes:
- uploads_data:/app/uploads - uploads_data:/app/uploads
env_file: .env env_file: .env
@@ -48,23 +47,6 @@ services:
- db - db
- ollama - ollama
whisper:
image: ${WHISPER_IMAGE:-fedirz/faster-whisper-server:latest-cpu}
ports:
- "8000:8000"
volumes:
- whisper_cache:/root/.cache/huggingface
environment:
- DEVICE=${WHISPER_DEVICE:-cpu}
# GPU support for RTX 2070 Super
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [ gpu ]
ollama: ollama:
image: ollama/ollama:latest image: ollama/ollama:latest
ports: ports:
@@ -96,5 +78,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
uploads_data: uploads_data:
whisper_cache:
ollama_data: ollama_data:
+1 -2
View File
@@ -143,7 +143,6 @@ if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'='
update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_lms?sslmode=disable" update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_lms?sslmode=disable"
update_env "JWT_SECRET" "supersecretsecret" update_env "JWT_SECRET" "supersecretsecret"
update_env "AI_PROVIDER" "local" update_env "AI_PROVIDER" "local"
update_env "LOCAL_WHISPER_URL" "http://whisper:8000"
update_env "LOCAL_OLLAMA_URL" "http://ollama:11434" update_env "LOCAL_OLLAMA_URL" "http://ollama:11434"
update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001" update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001"
update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002" update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002"
@@ -158,7 +157,7 @@ until docker exec openccb-ollama-1 ollama list &> /dev/null; do sleep 2; done
echo "📥 Downloading models..." echo "📥 Downloading models..."
if [ "$HAS_NVIDIA" = true ]; then if [ "$HAS_NVIDIA" = true ]; then
docker exec openccb-ollama-1 ollama pull llama3.2:1b docker exec openccb-ollama-1 ollama pull llama3:8b
else else
docker exec openccb-ollama-1 ollama pull phi3:mini docker exec openccb-ollama-1 ollama pull phi3:mini
fi fi
+18 -14
View File
@@ -919,14 +919,16 @@ pub async fn summarize_lesson(
.await .await
.map_err(|_| StatusCode::NOT_FOUND)?; .map_err(|_| StatusCode::NOT_FOUND)?;
let transcription_text = lesson // Use lesson summary as content source, fallback to title
.transcription let content_text = lesson
.summary
.as_ref() .as_ref()
.and_then(|t| t["en"].as_str()) .filter(|s| !s.is_empty())
.unwrap_or(""); .cloned()
.unwrap_or_else(|| format!("Lesson: {}", lesson.title));
if transcription_text.is_empty() { if content_text.is_empty() {
tracing::warn!("Cannot summarize lesson {}: No transcription found", id); tracing::warn!("Cannot summarize lesson {}: No content available", id);
return Err(StatusCode::BAD_REQUEST); return Err(StatusCode::BAD_REQUEST);
} }
@@ -963,7 +965,7 @@ pub async fn summarize_lesson(
}, },
{ {
"role": "user", "role": "user",
"content": transcription_text "content": content_text
} }
] ]
})); }));
@@ -1032,15 +1034,17 @@ pub async fn generate_quiz(
.await .await
.map_err(|_| StatusCode::NOT_FOUND)?; .map_err(|_| StatusCode::NOT_FOUND)?;
let transcription_text = lesson // Use lesson summary as content source, fallback to title
.transcription let content_text = lesson
.summary
.as_ref() .as_ref()
.and_then(|t| t["en"].as_str()) .filter(|s| !s.is_empty())
.unwrap_or(""); .cloned()
.unwrap_or_else(|| format!("Lesson: {}", lesson.title));
if transcription_text.is_empty() { if content_text.is_empty() {
tracing::warn!( tracing::warn!(
"Cannot generate quiz for lesson {}: No transcription found", "Cannot generate quiz for lesson {}: No content available",
id id
); );
return Err(StatusCode::BAD_REQUEST); return Err(StatusCode::BAD_REQUEST);
@@ -1079,7 +1083,7 @@ pub async fn generate_quiz(
}, },
{ {
"role": "user", "role": "user",
"content": transcription_text "content": content_text
} }
], ],
"response_format": { "type": "json_object" } "response_format": { "type": "json_object" }
+2 -2
View File
@@ -139,8 +139,8 @@ async fn main() {
.route("/users", get(handlers::get_all_users)) .route("/users", get(handlers::get_all_users))
.route("/users/{id}", axum::routing::put(handlers::update_user)) .route("/users/{id}", axum::routing::put(handlers::update_user))
.route("/audit-logs", get(handlers::get_audit_logs)) .route("/audit-logs", get(handlers::get_audit_logs))
.route("/assets/upload", post(handlers::upload_asset)) .route("/api/assets/upload", post(handlers::upload_asset))
.route("/assets/{id}", delete(handlers::delete_asset)) .route("/api/assets/{id}", delete(handlers::delete_asset))
.route("/courses/{id}/assets", get(handlers::get_course_assets)) .route("/courses/{id}/assets", get(handlers::get_course_assets))
.layer(DefaultBodyLimit::disable()) .layer(DefaultBodyLimit::disable())
.route( .route(
+1191 -10
View File
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -9,23 +9,24 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1",
"framer-motion": "^11.2.10",
"lucide-react": "^0.395.0",
"next": "14.2.21", "next": "14.2.21",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"lucide-react": "^0.395.0", "react-markdown": "^10.1.0",
"framer-motion": "^11.2.10",
"clsx": "^2.1.1",
"tailwind-merge": "^2.3.0" "tailwind-merge": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^8", "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"; "use client";
import ReactMarkdown from "react-markdown";
import { getImageUrl } from "@/lib/api";
interface DescriptionPlayerProps { interface DescriptionPlayerProps {
id: string; id: string;
title?: string; title?: string;
@@ -15,11 +18,8 @@ export default function DescriptionPlayer({ id, title, content }: DescriptionPla
</h3> </h3>
</div> </div>
<div className="prose prose-invert prose-lg max-w-none"> <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">
{/* We can use a markdown parser here later if desired, for now simple multiline text */} <ReactMarkdown urlTransform={getImageUrl}>{content}</ReactMarkdown>
<div className="text-gray-300 leading-relaxed whitespace-pre-wrap font-medium">
{content}
</div>
</div> </div>
</div> </div>
); );
+8
View File
@@ -1,6 +1,14 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_LMS_API_URL || "http://localhost:3002"; 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 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 { export interface Organization {
id: string; id: string;
name: string; name: string;
+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", "next": "14.2.21",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-markdown": "^10.1.0",
"tailwind-merge": "^2.3.0" "tailwind-merge": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
@@ -30,7 +30,6 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
// Activity State (Blocks) // Activity State (Blocks)
const [blocks, setBlocks] = useState<Block[]>([]); const [blocks, setBlocks] = useState<Block[]>([]);
const [summary, setSummary] = useState<string>(""); const [summary, setSummary] = useState<string>("");
const [isTranscribing, setIsTranscribing] = useState(false);
const [isGeneratingSummary, setIsGeneratingSummary] = useState(false); const [isGeneratingSummary, setIsGeneratingSummary] = useState(false);
const [isGeneratingQuiz, setIsGeneratingQuiz] = useState(false); const [isGeneratingQuiz, setIsGeneratingQuiz] = useState(false);
const [gradingCategories, setGradingCategories] = useState<GradingCategory[]>([]); const [gradingCategories, setGradingCategories] = useState<GradingCategory[]>([]);
@@ -42,7 +41,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [importantDateType, setImportantDateType] = useState<string>(""); const [importantDateType, setImportantDateType] = useState<string>("");
const [editValue, setEditValue] = useState(""); const [editValue, setEditValue] = useState("");
const [transcriptionTimer, setTranscriptionTimer] = useState(0);
// Polling for AI status // Polling for AI status
useEffect(() => { useEffect(() => {
@@ -72,27 +71,6 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
}; };
}, [lesson, lesson?.transcription_status, params.lessonId]); }, [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(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@@ -209,18 +187,6 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
setBlocks(newBlocks); 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 () => { const handleSummarize = async () => {
if (!lesson) return; if (!lesson) return;
@@ -440,58 +406,26 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 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>
)}
<button <button
onClick={handleSummarize} onClick={handleSummarize}
disabled={isGeneratingSummary || !lesson.transcription} 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 disabled:opacity-30 disabled:cursor-not-allowed'}`} 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> <span className="text-xl">{isGeneratingSummary ? '⏳' : '✍️'}</span>
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Summarization</div> <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> <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>
<button <button
onClick={handleGenerateQuiz} onClick={handleGenerateQuiz}
disabled={isGeneratingQuiz || !lesson.transcription} 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 disabled:opacity-30 disabled:cursor-not-allowed'}`} 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> <span className="text-xl">{isGeneratingQuiz ? '⏳' : '💡'}</span>
<div className="text-[10px] font-black uppercase tracking-widest opacity-80">Assessments</div> <div className="text-[10px] font-black uppercase tracking-widest opacity-80">Assessments</div>
<div className="font-bold">{isGeneratingQuiz ? 'Building...' : 'Generate Quiz'}</div> <div className="font-bold">{isGeneratingQuiz ? 'Building...' : 'Generate Quiz'}</div>
{!lesson.transcription && <div className="text-[8px] opacity-60">Requires Transcript</div>}
</button> </button>
</div> </div>
</div> </div>
@@ -569,6 +503,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
title={block.title} title={block.title}
content={block.content || ""} content={block.content || ""}
editMode={editMode} editMode={editMode}
courseId={params.id}
onChange={(updates) => updateBlock(block.id, updates)} 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"; "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 { interface DescriptionBlockProps {
id: string; id: string;
title?: string; title?: string;
content: string; content: string;
editMode: boolean; editMode: boolean;
courseId: string;
onChange: (updates: { title?: string; content?: string }) => void; 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 [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 ( return (
<div className="space-y-6"> <div className="space-y-6" id={id}>
{/* Block Header */} {/* Block Header */}
<div className="space-y-2"> <div className="space-y-2">
{editMode ? ( {editMode ? (
@@ -35,47 +75,83 @@ export default function DescriptionBlock({ title, content, editMode, onChange }:
{editMode ? ( {editMode ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Instructional Content</label> <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"> <div className="flex bg-white/5 rounded-lg p-1 border border-white/5">
<button <button
onClick={() => setShowPreview(false)} 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>
<button <button
onClick={() => setShowPreview(true)} 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> </button>
</div> </div>
</div> </div>
{showPreview ? ( {showPreview ? (
<div className="min-h-[200px] p-6 rounded-xl glass border-white/5 bg-white/5"> <div className="min-h-[200px] p-8 rounded-2xl glass border-white/5 bg-white/5">
<div className="prose prose-invert max-w-none"> <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">
<p className="text-gray-300 leading-relaxed text-lg whitespace-pre-wrap"> <ReactMarkdown urlTransform={getImageUrl}>{content || "Nothing to preview..."}</ReactMarkdown>
{content || "Nothing to preview..."}
</p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="relative">
<textarea <textarea
ref={textareaRef}
value={content} value={content}
onChange={(e) => onChange({ content: e.target.value })} onChange={(e) => onChange({ content: e.target.value })}
placeholder="Explain the activity to the students (Markdown supported)..." placeholder="Explain the activity (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" 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>
) : ( ) : (
<div className="prose prose-invert max-w-none"> <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">
<p className="text-gray-300 leading-relaxed text-lg whitespace-pre-wrap"> <ReactMarkdown urlTransform={getImageUrl}>{content || "No description provided."}</ReactMarkdown>
{content || "No description provided."}
</p>
</div> </div>
)} )}
<AssetPickerModal
isOpen={isAssetPickerOpen}
onClose={() => setIsAssetPickerOpen(false)}
courseId={courseId}
filterType={pickerType}
onSelect={handleAssetSelect}
/>
</div> </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 }) }), 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}`), 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) }), 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' }), summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
generateQuiz: (id: string): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST' }), generateQuiz: (id: string): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST' }),
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }), deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
@@ -312,7 +311,7 @@ export const cmsApi = {
// Assets // Assets
getCourseAssets: (courseId: string): Promise<Asset[]> => apiFetch(`/courses/${courseId}/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> => { uploadAsset: (file: File, onProgress?: (pct: number) => void, courseId?: string): Promise<UploadResponse> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const formData = new FormData(); const formData = new FormData();
@@ -320,7 +319,7 @@ export const cmsApi = {
if (courseId) formData.append('course_id', courseId); if (courseId) formData.append('course_id', courseId);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE_URL}/assets/upload`); xhr.open('POST', `${API_BASE_URL}/api/assets/upload`);
const token = getToken(); const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);