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
@@ -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>
);
}