Commit 3c1cab51 by Farhaan Khan

Initial commit

parent 919c218f
......@@ -9,16 +9,20 @@
"lint": "next lint"
},
"dependencies": {
"axios": "^1.9.0",
"canvas": "^3.1.0",
"next": "15.3.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.3.3"
"tesseract.js": "^6.0.1"
},
"devDependencies": {
"typescript": "^5",
"@tailwindcss/postcss": "^4",
"@types/axios": "^0.9.36",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4"
"tailwindcss": "^4",
"typescript": "^5"
}
}
// frontend/pages/components/quizmodal.tsx
import React, { useEffect, useState } from 'react';
import Tesseract from 'tesseract.js';
interface QuizModalProps {
slideImageUrl: string;
onClose: (result: 'correct' | 'fail') => void;
attempt: number;
}
export default function QuizModal({ slideImageUrl, onClose, attempt }: QuizModalProps) {
const [question, setQuestion] = useState('');
const [options, setOptions] = useState<string[]>([]);
const [selectedOption, setSelectedOption] = useState('');
const [correctLetter, setCorrectLetter] = useState('');
const [feedback, setFeedback] = useState('');
const [isCompleted, setIsCompleted] = useState(false);
useEffect(() => {
Tesseract.recognize(slideImageUrl, 'eng').then(({ data: { text } }) => {
const lines = text.split('\n').filter(l => l.trim());
const quizIndex = lines.findIndex(l => l.toLowerCase().includes('quiz'));
if (quizIndex >= 0) {
setQuestion(lines[quizIndex + 1]?.trim() || '');
setOptions(lines.slice(quizIndex + 2, quizIndex + 5));
}
const answerLine = lines.find(l => l.toLowerCase().startsWith('correct answer'));
if (answerLine) {
const match = answerLine.match(/[:\-]\s*([A-Z])/i);
if (match) {
setCorrectLetter(match[1].toUpperCase());
}
}
});
}, [slideImageUrl]);
const handleSubmit = () => {
if (isCompleted) return;
const selectedIndex = options.findIndex(opt => opt === selectedOption);
const selectedLetter = String.fromCharCode(65 + selectedIndex); // 65 = A
if (selectedLetter === correctLetter) {
setIsCompleted(true);
setFeedback('✅ Correct answer!');
setTimeout(() => onClose('correct'), 1000);
} else if (attempt >= 2) {
setFeedback('❌ Max attempts reached. Restarting...');
setTimeout(() => onClose('fail'), 1500);
} else {
setFeedback('❌ Wrong answer. Try again.');
setTimeout(() => onClose('fail'), 1500);
}
};
if (!question && options.length === 0) {
return (
<div style={styles.overlay}>
<div style={styles.modal}>
<p>🔍 Reading quiz from slide... Please wait.</p>
</div>
</div>
);
}
return (
<div style={styles.overlay}>
<div style={styles.modal}>
<h2>Quiz (Attempt {attempt + 1}/3)</h2>
<p style={{ fontWeight: 'bold' }}>{question}</p>
{options.map((option, index) => (
<label key={index} style={styles.option}>
<input
type="radio"
value={option}
checked={selectedOption === option}
onChange={() => !isCompleted && setSelectedOption(option)}
disabled={isCompleted}
/>
{option}
{isCompleted && option === options[correctLetter.charCodeAt(0) - 65] && ' ✔️'}
</label>
))}
<button
style={styles.submitButton}
onClick={handleSubmit}
disabled={!selectedOption || isCompleted}
>
{isCompleted ? 'Completed' : 'Submit Answer'}
</button>
{feedback && <p style={{ marginTop: 10 }}>{feedback}</p>}
</div>
</div>
);
}
const styles: { [key: string]: React.CSSProperties } = {
overlay: {
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
backgroundColor: 'rgba(0,0,0,0.85)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
},
modal: {
background: '#fff',
padding: '2rem',
borderRadius: 10,
width: '80%',
maxWidth: '500px',
textAlign: 'left',
zIndex: 1001,
},
option: {
display: 'block',
margin: '0.5rem 0',
},
submitButton: {
padding: '0.5rem 1rem',
backgroundColor: '#0070f3',
color: '#fff',
border: 'none',
borderRadius: 5,
cursor: 'pointer',
marginTop: 10,
},
};
// frontend/pages/components/slideviewer.tsx
import React, { useEffect, useRef, useState } from 'react';
interface Slide {
slideNumber: number;
imageUrl: string;
audioUrl: string | null;
videoUrl: string | null;
}
interface Props {
slide: Slide;
currentIndex: number;
totalSlides: number;
isNextEnabled: boolean;
onAudioEnd: () => void;
onNext: () => void;
onPrev: () => void;
onRestart: () => void;
resetAudio: () => void;
}
export default function SlideViewer({
slide,
currentIndex,
totalSlides,
isNextEnabled,
onAudioEnd,
onNext,
onPrev,
onRestart,
resetAudio,
}: Props) {
const audioRef = useRef<HTMLAudioElement>(null);
const [showVideo, setShowVideo] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && showVideo) {
setShowVideo(false);
const videoEl = document.getElementById('video-player') as HTMLVideoElement;
if (videoEl) {
videoEl.pause();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showVideo]);
useEffect(() => {
resetAudio();
setShowVideo(false); // Reset video when slide changes
if (audioRef.current) {
audioRef.current.load();
audioRef.current.play().catch(() => {});
}
}, [slide]);
return (
<div style={styles.container}>
<button style={styles.prevButton} onClick={onPrev} disabled={currentIndex === 0}>
</button>
<div style={styles.content}>
<img
src={`http://localhost:3001${slide.imageUrl}`}
alt="Slide"
style={styles.image}
/>
{slide.videoUrl && !showVideo && (
<button
style={styles.videoButton}
onClick={() => setShowVideo(true)}
title="Play Video"
>
</button>
)}
{showVideo && slide.videoUrl && (
<video
controls
autoPlay
style={styles.videoOverlay}
onEnded={() => setShowVideo(false)}
>
<source src={`http://localhost:3001${slide.videoUrl}`} type="video/mp4" />
Your browser does not support the video tag.
</video>
)}
{slide.audioUrl && (
<audio
ref={audioRef}
controls
onEnded={onAudioEnd}
style={styles.audio}
>
<source src={`http://localhost:3001${slide.audioUrl}`} type="audio/mpeg" />
</audio>
)}
</div>
<button
style={styles.nextButton}
onClick={onNext}
disabled={!isNextEnabled || currentIndex === totalSlides - 1}
>
</button>
</div>
);
}
const styles: { [key: string]: React.CSSProperties } = {
container: {
display: 'flex',
width: '100vw',
height: '100vh',
alignItems: 'center',
justifyContent: 'space-between',
padding: 20,
boxSizing: 'border-box',
},
prevButton: {
fontSize: '2rem',
cursor: 'pointer',
background: 'none',
border: 'none',
color: '#333',
},
nextButton: {
fontSize: '2rem',
cursor: 'pointer',
background: 'none',
border: 'none',
color: '#333',
},
content: {
flex: 1,
textAlign: 'center',
height: '100%',
position: 'relative',
},
image: {
maxHeight: 'calc(100vh - 120px)',
width: '100%',
maxWidth: '100%',
objectFit: 'contain',
},
audio: {
position: 'absolute',
bottom: 20,
left: '50%',
transform: 'translateX(-50%)',
},
videoButton: {
position: 'absolute',
right: '25%',
top: '50%',
transform: 'translateY(-50%)',
backgroundColor: '#0070f3',
color: 'white',
border: 'none',
borderRadius: '50%',
width: 50,
height: 50,
fontSize: '1.5rem',
cursor: 'pointer',
zIndex: 2,
},
videoOverlay: {
position: 'absolute',
top: '10%',
left: '50%',
transform: 'translateX(-50%)',
maxWidth: '80%',
maxHeight: '80%',
zIndex: 10,
backgroundColor: '#000',
borderRadius: 8,
},
};
import Image from "next/image";
import { Geist, Geist_Mono } from "next/font/google";
import React, { useState } from 'react';
import axios from 'axios';
import { useRouter } from 'next/router';
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
interface Slide {
slideNumber: number;
imageUrl: string;
audioUrl: string | null;
videoUrl: string | null;
}
export default function Home() {
const [pdfFile, setPdfFile] = useState<File | null>(null);
const [zipFile, setZipFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleUpload = async () => {
if (!pdfFile || !zipFile) {
alert('Please select both PDF and ZIP files!');
return;
}
const formData = new FormData();
formData.append('pdf', pdfFile);
formData.append('zip', zipFile);
try {
setLoading(true);
const res = await axios.post<Slide[]>('http://localhost:3001/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000, // 60 seconds for large files
});
console.log(res.data,'res.data')
localStorage.setItem('slides', JSON.stringify(res.data));
router.push('/player');
} catch (err) {
console.error('Upload failed:', err);
alert('Upload failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div
className={`${geistSans.className} ${geistMono.className} grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]`}
>
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
pages/index.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
<div style={styles.container}>
<h1 style={styles.heading}>Upload Slides & Media</h1>
<div style={styles.formGroup}>
<label style={styles.label}>Upload PDF file:</label>
<input type="file" accept=".pdf" onChange={(e) => setPdfFile(e.target.files?.[0] || null)} />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Upload Media ZIP (mp3/mp4):</label>
<input type="file" accept=".zip" onChange={(e) => setZipFile(e.target.files?.[0] || null)} />
</div>
<button onClick={handleUpload} style={styles.uploadButton} disabled={loading}>
{loading ? 'Uploading...' : 'Start Upload'}
</button>
</div>
);
}
const styles: { [key: string]: React.CSSProperties } = {
container: {
padding: '2rem',
background: '#f9fafb',
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
fontFamily: 'Segoe UI, sans-serif',
},
heading: {
fontSize: '2.5rem',
color: '#1f2937',
marginBottom: '2rem',
textAlign: 'center',
},
formGroup: {
marginBottom: '1.5rem',
display: 'flex',
flexDirection: 'column',
width: '100%',
maxWidth: '400px',
},
label: {
marginBottom: '0.5rem',
fontSize: '1rem',
color: '#374151',
},
uploadButton: {
marginTop: '1rem',
padding: '0.75rem 2rem',
fontSize: '1rem',
backgroundColor: '#2563eb',
color: '#ffffff',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
fontWeight: 'bold',
transition: 'background-color 0.2s ease-in-out',
},
};
// frontend/pages/player.tsx
import React, { useEffect, useState } from 'react';
import QuizModal from './components/quizmodal';
import SlideViewer from './components/slideviewer';
interface Slide {
slideNumber: number;
imageUrl: string;
audioUrl: string | null;
videoUrl: string | null;
}
const backendUrl = 'http://localhost:3001';
export default function Player() {
const [slides, setSlides] = useState<Slide[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [audioEnded, setAudioEnded] = useState(false);
const [completedSlides, setCompletedSlides] = useState<number[]>([]);
const [showQuiz, setShowQuiz] = useState(false);
const [quizImageUrl, setQuizImageUrl] = useState('');
const [quizAttempts, setQuizAttempts] = useState(0);
const [quizRedirectMap, setQuizRedirectMap] = useState<{ [key: number]: number }>({});
useEffect(() => {
const stored = localStorage.getItem('slides');
if (!stored) {
alert('No slide data found. Please upload again.');
return;
}
try {
const parsed = JSON.parse(stored);
const parsedSlides = parsed.slides;
if (Array.isArray(parsedSlides) && parsedSlides.length > 0) {
setSlides(parsedSlides);
// Extract quiz slide numbers
const quizSlides: number[] = parsedSlides
.filter((s: Slide) => s.imageUrl.toLowerCase().includes('quiz'))
.map((s: Slide) => s.slideNumber)
.sort((a, b) => a - b);
// Build redirect map
const redirectMap: { [key: number]: number } = {};
for (let i = 0; i < quizSlides.length; i++) {
const currentQuiz = quizSlides[i];
const previousQuiz = quizSlides[i - 1] ?? 0; // 0 if first quiz
const fallback = parsedSlides.find(s => s.slideNumber > previousQuiz)?.slideNumber ?? 0;
redirectMap[currentQuiz] = fallback;
}
setQuizRedirectMap(redirectMap);
} else {
alert('Slide data is empty or invalid. Please re-upload.');
}
} catch (e) {
alert('Slide data is corrupted. Please upload again.');
}
}, []);
useEffect(() => {
setAudioEnded(false);
setShowQuiz(false);
const currentSlide = slides[currentIndex];
if (!currentSlide) return;
const filename = currentSlide.imageUrl.toLowerCase();
if (filename.includes('quiz')) {
setQuizImageUrl(`${backendUrl}${currentSlide.imageUrl}`);
setShowQuiz(true);
}
}, [currentIndex, slides]);
const handleQuizClose = (result: 'correct' | 'fail') => {
setShowQuiz(false);
if (result === 'correct') {
setCompletedSlides((prev) => [...prev, slides[currentIndex].slideNumber]);
setCurrentIndex((prev) => prev + 1);
setQuizAttempts(0);
} else if (quizAttempts < 2) {
setQuizAttempts((prev) => prev + 1);
const failedSlideNumber = slides[currentIndex].slideNumber;
const redirectTo = quizRedirectMap[failedSlideNumber] ?? 0;
const newIndex = slides.findIndex(s => s.slideNumber === redirectTo);
setCurrentIndex(newIndex >= 0 ? newIndex : 0);
} else {
setQuizAttempts(0); // Max attempts used, move forward
setCurrentIndex((prev) => prev + 1);
}
};
const currentSlide = slides[currentIndex];
const isCompleted = completedSlides.includes(currentSlide?.slideNumber ?? -1);
const isNextEnabled = audioEnded || isCompleted || !currentSlide?.audioUrl;
const handleAudioEnd = () => {
setAudioEnded(true);
setCompletedSlides((prev) => [...new Set([...prev, currentSlide.slideNumber])]);
};
if (!currentSlide) {
return <div style={{ padding: 20 }}><h2>No slides to display</h2></div>;
}
return (
<>
<div style={{ textAlign: 'center', margin: '10px 0', fontSize: '18px' }}>
Slide {currentIndex + 1} of {slides.length}
</div>
{!showQuiz && (
<SlideViewer
slide={currentSlide}
currentIndex={currentIndex}
totalSlides={slides.length}
isNextEnabled={isNextEnabled}
onAudioEnd={handleAudioEnd}
onNext={() => setCurrentIndex(prev => prev + 1)}
onPrev={() => setCurrentIndex(prev => Math.max(prev - 1, 0))}
onRestart={() => setCurrentIndex(0)}
resetAudio={() => setAudioEnded(false)}
/>
)}
{showQuiz && (
<QuizModal
slideImageUrl={quizImageUrl}
onClose={handleQuizClose}
attempt={quizAttempts}
/>
)}
</>
);
}
// utils/api.ts
import axios from 'axios';
const API = axios.create({
baseURL: 'http://localhost:3001', // <-- Your backend URL here
timeout: 80000,
});
export default API;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment