Initial commit: Clean workspace without heavy binaries
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { cmsApi } from "@/lib/api";
|
||||
|
||||
interface FileUploadProps {
|
||||
onUploadComplete: (url: string) => void;
|
||||
currentUrl?: string;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
export default function FileUpload({ onUploadComplete, currentUrl, accept = "video/*,audio/*" }: FileUploadProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const result = await cmsApi.uploadAsset(file);
|
||||
onUploadComplete(result.url);
|
||||
} catch (err) {
|
||||
alert("Upload failed. Please try again.");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleUpload(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleUpload(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`relative group cursor-pointer border-2 border-dashed rounded-xl p-8 transition-all flex flex-col items-center justify-center gap-4 ${dragActive ? "border-blue-500 bg-blue-500/10 scale-[1.02]" : "border-white/10 hover:border-white/20 bg-white/5"
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={accept}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{isUploading ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-blue-400">Uploading Asset...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
|
||||
<span className="text-2xl group-hover:scale-110 transition-transform">📁</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold text-gray-300">Drag & drop or <span className="text-blue-400 underline decoration-blue-500/30">browse</span></p>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">Native video, audio files supported</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentUrl && !isUploading && (
|
||||
<div className="flex items-center justify-between px-4 py-3 glass bg-green-500/5 border-green-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<span className="text-sm">✅</span>
|
||||
<span className="text-xs text-green-400 truncate font-medium">{currentUrl}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onUploadComplete(""); }}
|
||||
className="text-[10px] uppercase font-black text-gray-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface MediaPlayerProps {
|
||||
src: string | null;
|
||||
type: string; // "video" | "audio"
|
||||
transcription?: {
|
||||
en?: string;
|
||||
es?: string;
|
||||
cues?: { start: number; end: number; text: string }[];
|
||||
} | null;
|
||||
locked?: boolean;
|
||||
onEnded?: () => void;
|
||||
}
|
||||
|
||||
export default function MediaPlayer({ src, type, transcription, locked, onEnded }: MediaPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [currentCaption, setCurrentCaption] = useState("");
|
||||
const [language, setLanguage] = useState<"en" | "es">("en");
|
||||
|
||||
useEffect(() => {
|
||||
const media = type === "video" ? videoRef.current : audioRef.current;
|
||||
if (!media) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (transcription?.cues) {
|
||||
const activeCue = transcription.cues.find(cue =>
|
||||
media.currentTime >= cue.start && media.currentTime <= cue.end
|
||||
);
|
||||
setCurrentCaption(activeCue?.text || "");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
if (onEnded) onEnded();
|
||||
};
|
||||
|
||||
media.addEventListener("timeupdate", handleTimeUpdate);
|
||||
media.addEventListener("ended", handleEnded);
|
||||
return () => {
|
||||
media.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
media.removeEventListener("ended", handleEnded);
|
||||
};
|
||||
}, [type, transcription, onEnded]);
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="glass aspect-video flex flex-col items-center justify-center border-dashed border-2 border-white/10 text-gray-500">
|
||||
<span className="text-4xl mb-2">🎞️</span>
|
||||
<p>No media file linked yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isYouTube = src.includes("youtube.com") || src.includes("youtu.be");
|
||||
const isVimeo = src.includes("vimeo.com");
|
||||
|
||||
const getYouTubeId = (url: string) => {
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||
const match = url.match(regExp);
|
||||
return (match && match[2].length === 11) ? match[2] : null;
|
||||
};
|
||||
|
||||
const getVimeoId = (url: string) => {
|
||||
const match = url.match(/vimeo.com\/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
if (isYouTube || isVimeo) {
|
||||
let embedUrl = "";
|
||||
if (isYouTube) {
|
||||
const id = getYouTubeId(src);
|
||||
embedUrl = `https://www.youtube.com/embed/${id}`;
|
||||
} else {
|
||||
const id = getVimeoId(src);
|
||||
embedUrl = `https://player.vimeo.com/video/${id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 relative group">
|
||||
<div className={`glass overflow-hidden border border-white/10 aspect-video ${locked ? 'blur-xl grayscale' : ''}`}>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
{locked && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm rounded-xl z-10 text-center p-6 border border-white/10">
|
||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mb-4 border border-white/20">
|
||||
<span className="text-3xl">🔒</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Playback Limited</h3>
|
||||
<p className="text-sm text-gray-300 max-w-xs">This content can only be played once according to the activity rules.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcription && !locked && (
|
||||
<div className="glass p-6 space-y-4 border border-blue-500/20">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="text-lg font-semibold flex items-center gap-2">
|
||||
Transcription <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-1 rounded">AI Enhanced</span>
|
||||
</h4>
|
||||
<div className="flex bg-white/5 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setLanguage("en")}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "en" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||
>EN</button>
|
||||
<button
|
||||
onClick={() => setLanguage("es")}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "es" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||
>ES</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 leading-relaxed max-h-40 overflow-y-auto italic bg-white/5 p-4 rounded-lg">
|
||||
"{transcription[language] || "Transcription not available."}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 relative group">
|
||||
<div className={`relative glass overflow-hidden border border-white/10 ${locked ? 'blur-xl grayscale' : ''}`}>
|
||||
{type === "video" ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
className="w-full aspect-video object-cover"
|
||||
controls={!locked}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-12 flex flex-col items-center justify-center bg-gradient-to-br from-blue-500/20 to-purple-500/20">
|
||||
<audio ref={audioRef} src={src} controls={!locked} className="w-full max-w-md" />
|
||||
<span className="text-xs text-gray-400 mt-6 uppercase tracking-[0.2em] font-medium">Audio Experience</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Caption Overlay */}
|
||||
{currentCaption && type === "video" && !locked && (
|
||||
<div className="absolute bottom-16 left-0 right-0 text-center px-8 pointer-events-none animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<span className="bg-black/80 text-white px-4 py-2 rounded-xl text-lg font-medium backdrop-blur-md border border-white/20 shadow-2xl">
|
||||
{currentCaption}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{locked && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm rounded-xl z-10 text-center p-6 border border-white/10">
|
||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mb-4 border border-white/20">
|
||||
<span className="text-3xl">🔒</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Playback Limited</h3>
|
||||
<p className="text-sm text-gray-300 max-w-xs">This content can only be played once according to the activity rules.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcription && !locked && (
|
||||
<div className="glass p-6 space-y-4 border border-blue-500/20">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="text-lg font-semibold flex items-center gap-2">
|
||||
Transcription <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-1 rounded">AI Enhanced</span>
|
||||
</h4>
|
||||
<div className="flex bg-white/5 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setLanguage("en")}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "en" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||
>EN</button>
|
||||
<button
|
||||
onClick={() => setLanguage("es")}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "es" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||
>ES</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 leading-relaxed max-h-40 overflow-y-auto italic bg-white/5 p-4 rounded-lg">
|
||||
"{transcription[language] || "Transcription not available."}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
interface DescriptionBlockProps {
|
||||
id: string;
|
||||
title?: string;
|
||||
content: string;
|
||||
editMode: boolean;
|
||||
onChange: (updates: { title?: string; content?: string }) => void;
|
||||
}
|
||||
|
||||
export default function DescriptionBlock({ title, content, editMode, onChange }: DescriptionBlockProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Block Header */}
|
||||
<div className="space-y-2">
|
||||
{editMode ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Section Title (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title || ""}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
placeholder="e.g. Introduction, Context..."
|
||||
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>
|
||||
) : (
|
||||
title && <h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">{title}</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Instructional Text</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => onChange({ content: e.target.value })}
|
||||
placeholder="Explain the activity to the students..."
|
||||
className="w-full h-40 bg-white/5 border border-white/10 rounded-xl p-4 text-sm focus:border-blue-500/50 focus:outline-none transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<p className="text-gray-300 leading-relaxed text-lg">
|
||||
{content || "No description provided."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import MediaPlayer from "../MediaPlayer";
|
||||
import FileUpload from "../FileUpload";
|
||||
|
||||
interface MediaBlockProps {
|
||||
id: string;
|
||||
title?: string;
|
||||
url: string;
|
||||
type: 'video' | 'audio';
|
||||
config: {
|
||||
maxPlays?: number;
|
||||
currentPlays?: number;
|
||||
};
|
||||
editMode: boolean;
|
||||
onChange: (updates: { title?: string; url?: string; config?: { maxPlays?: number; currentPlays?: number } }) => void;
|
||||
transcription?: {
|
||||
en?: string;
|
||||
es?: string;
|
||||
cues?: { start: number; end: number; text: string }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function MediaBlock({ title, url, type, config, editMode, onChange, transcription }: MediaBlockProps) {
|
||||
const [localPlays, setLocalPlays] = useState(config.currentPlays || 0);
|
||||
const [sourceType, setSourceType] = useState<"url" | "upload">(url.startsWith("/assets/") ? "upload" : "url");
|
||||
const maxPlays = config.maxPlays || 0;
|
||||
const isLocked = maxPlays > 0 && localPlays >= maxPlays;
|
||||
|
||||
const handleEnded = () => {
|
||||
if (maxPlays > 0) {
|
||||
const nextPlays = localPlays + 1;
|
||||
setLocalPlays(nextPlays);
|
||||
onChange({ config: { ...config, currentPlays: nextPlays } });
|
||||
}
|
||||
};
|
||||
|
||||
// Full URL for display (handles relative paths from server)
|
||||
const displayUrl = url.startsWith("/") ? `http://localhost:3001${url}` : url;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Block Header */}
|
||||
<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">Section Title (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title || ""}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
placeholder="e.g. Explainer Video, Audio Guide..."
|
||||
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>
|
||||
) : (
|
||||
title && <h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white">{title}</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode && (
|
||||
<div className="space-y-6 p-6 glass border-blue-500/10 mb-8 bg-blue-500/5">
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<button
|
||||
onClick={() => setSourceType("url")}
|
||||
className={`px-4 py-2 text-[10px] uppercase font-black tracking-widest rounded-lg transition-all ${sourceType === "url" ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||
>
|
||||
External URL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceType("upload")}
|
||||
className={`px-4 py-2 text-[10px] uppercase font-black tracking-widest rounded-lg transition-all ${sourceType === "upload" ? "bg-blue-500 text-white shadow-lg" : "text-gray-500 hover:text-white"}`}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{sourceType === "url" ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Media URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={url.startsWith("/") ? "" : url}
|
||||
onChange={(e) => onChange({ url: e.target.value })}
|
||||
placeholder="YouTube, Vimeo or static link"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm focus:border-blue-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">File Manager</label>
|
||||
<FileUpload
|
||||
currentUrl={url.startsWith("/") ? url : undefined}
|
||||
onUploadComplete={(newUrl) => onChange({ url: newUrl })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Playback Limit (0 = Unlimited)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxPlays}
|
||||
onChange={(e) => onChange({ config: { ...config, maxPlays: parseInt(e.target.value) || 0 } })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm focus:border-blue-500/50 focus:outline-none h-11"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-500 uppercase leading-relaxed mt-2">Prevent content fatigue by limiting how many times a student can watch/listen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<MediaPlayer
|
||||
src={displayUrl}
|
||||
type={type}
|
||||
transcription={transcription}
|
||||
locked={isLocked}
|
||||
onEnded={handleEnded}
|
||||
/>
|
||||
|
||||
{!editMode && maxPlays > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between px-4 py-2 glass bg-white/5 border-white/5 rounded-lg">
|
||||
<span className="text-xs text-gray-500 uppercase font-medium">Plays Remaining</span>
|
||||
<span className={`text-sm font-bold ${maxPlays - localPlays <= 1 ? 'text-orange-400' : 'text-blue-400'}`}>
|
||||
{Math.max(0, maxPlays - localPlays)} / {maxPlays}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface QuizQuestion {
|
||||
id: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
correct: number;
|
||||
}
|
||||
|
||||
interface QuizBlockProps {
|
||||
id: string;
|
||||
title?: string;
|
||||
quizData: {
|
||||
questions: QuizQuestion[];
|
||||
};
|
||||
editMode: boolean;
|
||||
onChange: (data: { title?: string; questions?: QuizQuestion[] }) => void;
|
||||
}
|
||||
|
||||
export default function QuizBlock({ id, title, quizData, editMode, onChange }: QuizBlockProps) {
|
||||
const [userAnswers, setUserAnswers] = useState<Record<string, number>>({});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const questions = quizData.questions || [];
|
||||
|
||||
const addQuestion = () => {
|
||||
const newQuestion: QuizQuestion = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
question: "New Question?",
|
||||
options: ["Option 1", "Option 2"],
|
||||
correct: 0
|
||||
};
|
||||
onChange({ questions: [...questions, newQuestion] });
|
||||
};
|
||||
|
||||
const updateQuestion = (index: number, updates: Partial<QuizQuestion>) => {
|
||||
const newQuestions = [...questions];
|
||||
newQuestions[index] = { ...newQuestions[index], ...updates };
|
||||
onChange({ questions: newQuestions });
|
||||
};
|
||||
|
||||
const handleAnswer = (qId: string, optionIndex: number) => {
|
||||
if (submitted) return;
|
||||
setUserAnswers(prev => ({ ...prev, [qId]: optionIndex }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8" id={id}>
|
||||
{/* Block Header */}
|
||||
<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">Section Title (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title || ""}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
placeholder="e.g. Final Evaluation, Knowledge Check..."
|
||||
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-blue-500 pl-4 py-1 tracking-tight text-white">
|
||||
{title || "Knowledge Check"}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode ? (
|
||||
<div className="space-y-6">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.id} className="p-6 glass border-white/5 space-y-4 rounded-2xl">
|
||||
<input
|
||||
value={q.question}
|
||||
onChange={(e) => updateQuestion(idx, { question: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl p-3 font-semibold focus:outline-none focus:border-blue-500/50 transition-all"
|
||||
placeholder="Enter your question..."
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{q.options.map((opt, oIdx) => (
|
||||
<div key={oIdx} className="flex gap-3 items-center">
|
||||
<input
|
||||
type="radio"
|
||||
checked={q.correct === oIdx}
|
||||
onChange={() => updateQuestion(idx, { correct: oIdx })}
|
||||
className="w-4 h-4 accent-blue-500"
|
||||
/>
|
||||
<input
|
||||
value={opt}
|
||||
onChange={(e) => {
|
||||
const newOpts = [...q.options];
|
||||
newOpts[oIdx] = e.target.value;
|
||||
updateQuestion(idx, { options: newOpts });
|
||||
}}
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-blue-500/30"
|
||||
placeholder={`Option ${oIdx + 1}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={addQuestion}
|
||||
className="w-full py-4 glass border-dashed border-2 border-white/10 text-gray-500 hover:text-white hover:border-blue-500/30 hover:bg-blue-500/5 transition-all font-bold text-xs uppercase tracking-widest rounded-2xl"
|
||||
>
|
||||
+ Add Question
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{questions.map((q) => (
|
||||
<div key={q.id} className="space-y-4 p-6 glass border-white/5 rounded-2xl">
|
||||
<h4 className="font-bold text-xl text-gray-100 leading-tight">{q.question}</h4>
|
||||
<div className="grid gap-3">
|
||||
{q.options.map((opt, oIdx) => {
|
||||
const isSelected = userAnswers[q.id] === oIdx;
|
||||
const isCorrect = q.correct === oIdx;
|
||||
let style = "glass border-white/10 hover:bg-white/5";
|
||||
if (submitted) {
|
||||
if (isCorrect) style = "bg-green-500/20 border-green-500 text-green-400";
|
||||
else if (isSelected && !isCorrect) style = "bg-red-500/20 border-red-500 text-red-100";
|
||||
else style = "opacity-50 grayscale border-white/5";
|
||||
} else if (isSelected) {
|
||||
style = "bg-blue-500/20 border-blue-500 text-white shadow-[0_0_20px_rgba(59,130,246,0.2)]";
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={oIdx}
|
||||
onClick={() => handleAnswer(q.id, oIdx)}
|
||||
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold ${style}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!submitted && questions.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSubmitted(true)}
|
||||
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
|
||||
>
|
||||
Validate Answers
|
||||
</button>
|
||||
)}
|
||||
{submitted && (
|
||||
<button
|
||||
onClick={() => { setSubmitted(false); setUserAnswers({}); }}
|
||||
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user