Commit 9cab2abf authored by AravindR-K's avatar AravindR-K

feat : finished with he AI workflow

parent eb8e8e8b
...@@ -3,3 +3,5 @@ PORT=5000 ...@@ -3,3 +3,5 @@ PORT=5000
MONGODB_URI=mongodb://127.0.0.1:27017/quiz_app MONGODB_URI=mongodb://127.0.0.1:27017/quiz_app
JWT_SECRET=quiz_app_super_secret_key_2026 JWT_SECRET=quiz_app_super_secret_key_2026
JWT_EXPIRES_IN=1d JWT_EXPIRES_IN=1d
OLLAMA_URL=http://127.0.0.1:11434
OLLAMA_MODEL=llama3.2
...@@ -3,10 +3,10 @@ ...@@ -3,10 +3,10 @@
"version": "1.0.0", "version": "1.0.0",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon --ignore uploads/ server.js", "dev": "nodemon --ignore uploads/ server.js",
"seed": "node seedAdmin.js", "seed": "node seedAdmin.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
......
...@@ -306,6 +306,214 @@ ...@@ -306,6 +306,214 @@
} }
}); });
// @route POST /api/admin/quiz/generate-ai
// @desc Generate quiz using local Ollama LLM — AI determines title, timer, and questions from prompt
// @access Admin
router.post('/quiz/generate-ai', async (req, res) => {
try {
const { prompt, difficulty, assignToAll, assignees, assignedGroups } = req.body;
if (!prompt || !prompt.trim()) {
return res.status(400).json({ message: 'Please provide a prompt describing the quiz you want.' });
}
const ollamaUrl = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const ollamaModel = process.env.OLLAMA_MODEL || 'llama3.2';
const diffLevel = difficulty || 'medium';
const aiPrompt = `Based on this request: "${prompt}"
Generate a quiz at ${diffLevel} difficulty level. You must determine:
1. A short quiz title (max 5 words)
2. A short topic label (max 3 words, e.g. "Computer Networks", "Data Structures")
3. A reasonable time limit in minutes
4. The appropriate number of questions
CRITICAL RULES:
- Each question must have exactly 4 options.
- The "correct" field MUST contain the EXACT FULL TEXT of the correct option. DO NOT write "option1" or "option2" — write the actual answer text.
- For multiple correct answers, comma-separate the full text of each correct option.
- Return ONLY a valid JSON object, no markdown fences, no explanation.
EXAMPLE (notice "correct" contains the actual answer text, NOT "option1"):
{
"title": "JavaScript Basics",
"topic": "JavaScript",
"timer": 10,
"questions": [
{
"question": "What does DOM stand for?",
"option1": "Document Object Model",
"option2": "Data Object Manager",
"option3": "Digital Output Mode",
"option4": "Document Oriented Markup",
"correct": "Document Object Model"
}
]
}
IMPORTANT: The "correct" value must be the EXACT TEXT of the option, not the option key. Output ONLY the JSON.`;
console.log(`Calling Ollama at ${ollamaUrl}/api/chat with model ${ollamaModel}...`);
const ollamaResponse = await fetch(`${ollamaUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: ollamaModel,
messages: [
{ role: 'system', content: 'You are a quiz generator. Output ONLY valid JSON. The "correct" field must always contain the EXACT TEXT of the correct answer option, never "option1" or "option2".' },
{ role: 'user', content: aiPrompt }
],
stream: false,
options: { temperature: 0.7 }
})
});
if (!ollamaResponse.ok) {
const errText = await ollamaResponse.text();
console.error('Ollama error:', errText);
return res.status(500).json({ message: `Ollama error (${ollamaResponse.status}): Make sure Ollama is running with "${ollamaModel}" model pulled.` });
}
const ollamaData = await ollamaResponse.json();
let responseText = (ollamaData.message?.content || '').trim();
console.log('Ollama raw response length:', responseText.length);
// Strip markdown code fences if present
if (responseText.startsWith('```')) {
responseText = responseText.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
}
// Try to extract JSON object from response
const jsonObjMatch = responseText.match(/\{[\s\S]*\}/);
if (jsonObjMatch) {
responseText = jsonObjMatch[0];
}
let aiResult;
try {
aiResult = JSON.parse(responseText);
} catch (parseErr) {
console.error('AI response parse error:', parseErr);
console.error('Raw response:', responseText.substring(0, 500));
return res.status(500).json({ message: 'Failed to parse AI response. The LLM did not return valid JSON. Please try again.' });
}
// Extract metadata from AI response
const quizTitle = aiResult.title || 'AI Generated Quiz';
const quizTopic = aiResult.topic || quizTitle;
const quizTimer = parseInt(aiResult.timer) || 15;
const aiQuestions = aiResult.questions;
if (!Array.isArray(aiQuestions) || aiQuestions.length === 0) {
return res.status(500).json({ message: 'AI returned empty or invalid questions. Please try again.' });
}
// Parse AI output into our question format
const parsedQuestions = aiQuestions.map((q) => {
const options = [
(q.option1 || '').toString().trim(),
(q.option2 || '').toString().trim(),
(q.option3 || '').toString().trim(),
(q.option4 || '').toString().trim()
].filter(o => o.length > 0);
// Build a map for "option1" -> actual text fallback
const optionKeyMap = {
'option1': options[0] || '',
'option2': options[1] || '',
'option3': options[2] || '',
'option4': options[3] || ''
};
const correctStr = (q.correct || '').toString().trim();
const correctArr = correctStr.split(',').map(s => s.trim());
// First: try to match correct values against actual option text
let correctAnswers = options.filter(opt => correctArr.includes(opt));
// Fallback: if LLM returned "option1", "option2" keys instead of text
if (correctAnswers.length === 0) {
correctAnswers = correctArr
.map(c => optionKeyMap[c.toLowerCase()] || '')
.filter(c => c.length > 0);
}
// Last resort: if still empty, try case-insensitive match
if (correctAnswers.length === 0) {
correctAnswers = options.filter(opt =>
correctArr.some(c => c.toLowerCase() === opt.toLowerCase())
);
}
// Absolute fallback: use first option
if (correctAnswers.length === 0 && options.length > 0) {
console.warn(`Warning: Could not match correct answer "${correctStr}" for question "${q.question}". Using first option as fallback.`);
correctAnswers.push(options[0]);
}
return {
question: (q.question || '').toString().trim(),
options,
correctAnswers,
type: correctAnswers.length > 1 ? 'mcq' : 'single'
};
}).filter(q => q.question && q.options.length === 4 && q.correctAnswers.length > 0);
if (parsedQuestions.length === 0) {
return res.status(500).json({ message: 'Could not parse any valid questions from AI response.' });
}
// Log parsed questions for debugging
console.log('Parsed questions sample:', JSON.stringify(parsedQuestions[0], null, 2));
// Create quiz in DB
const quiz = await Quiz.create({
title: quizTitle,
timer: quizTimer,
totalQuestions: parsedQuestions.length,
createdBy: req.user._id,
category: quizTopic,
difficulty: diffLevel,
topic: quizTopic,
assignToAll: assignToAll === 'true' || assignToAll === true,
assignees: assignees || [],
assignedGroups: assignedGroups || [],
generatedByAI: true
});
const questionDocs = parsedQuestions.map(q => ({
quizId: quiz._id,
question: q.question,
options: q.options,
correctAnswers: q.correctAnswers,
type: q.type
}));
await Question.insertMany(questionDocs);
res.status(201).json({
message: 'AI Quiz generated successfully',
quiz: {
id: quiz._id,
title: quiz.title,
timer: quiz.timer,
totalQuestions: parsedQuestions.length,
category: quiz.category
},
questions: parsedQuestions
});
} catch (error) {
console.error('AI generation error:', error);
if (error.cause?.code === 'ECONNREFUSED') {
return res.status(500).json({ message: 'Cannot connect to Ollama. Make sure Ollama is running (ollama serve).' });
}
res.status(500).json({ message: 'AI generation failed: ' + (error.message || 'Unknown error') });
}
});
// @route POST /api/admin/quiz/create-manual // @route POST /api/admin/quiz/create-manual
// @desc Create quiz manually (for AI-generated quizzes or manual entry) // @desc Create quiz manually (for AI-generated quizzes or manual entry)
// @access Admin // @access Admin
......
.page-container { padding: 32px 40px; max-width: 800px; } .page-container { padding: 32px 40px; }
.page-header { margin-bottom: 28px; } .page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 8px 0 4px; } .page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 8px 0 4px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; } .page-subtitle { color: var(--text-muted); font-size: 14px; }
...@@ -6,13 +6,81 @@ ...@@ -6,13 +6,81 @@
.back-link:hover { color: var(--accent-primary); } .back-link:hover { color: var(--accent-primary); }
.back-link .material-symbols-rounded { font-size: 18px; } .back-link .material-symbols-rounded { font-size: 18px; }
/* ============ MODE TOGGLE ============ */
.mode-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.mode-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 24px 16px;
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.25s ease;
color: var(--text-secondary);
box-shadow: var(--shadow-card);
}
.mode-btn:hover {
border-color: var(--border-strong);
background: var(--bg-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.mode-btn.active {
border-color: var(--accent-primary);
background: var(--accent-primary-light);
color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(var(--accent-primary-rgb), 0.1);
}
.mode-btn .material-symbols-rounded {
font-size: 32px;
transition: transform 0.2s;
}
.mode-btn.active .material-symbols-rounded {
transform: scale(1.15);
}
.mode-label {
font-size: 16px;
font-weight: 700;
}
.mode-desc {
font-size: 12px;
color: var(--text-muted);
}
.mode-btn.active .mode-desc {
color: var(--accent-primary);
opacity: 0.7;
}
/* ============ FORM ============ */
.form-card { margin-bottom: 32px; } .form-card { margin-bottom: 32px; }
.quiz-form { display: flex; flex-direction: column; gap: 20px; } .quiz-form { display: flex; flex-direction: column; gap: 20px; }
.form-group { display: flex; flex-direction: column; flex: 1; } .form-group { display: flex; flex-direction: column; flex: 1; }
.form-row { display: flex; gap: 16px; } .form-row { display: flex; gap: 16px; }
.form-textarea {
resize: vertical;
min-height: 100px;
line-height: 1.6;
}
.file-upload { .file-upload {
display: flex; align-items: center; gap: 12px; padding: 20px; display: flex; align-items: center; gap: 12px; padding: 24px;
background: var(--bg-input); border: 2px dashed var(--border-color); background: var(--bg-input); border: 2px dashed var(--border-color);
border-radius: var(--radius-md); cursor: pointer; transition: all 0.2s; border-radius: var(--radius-md); cursor: pointer; transition: all 0.2s;
} }
...@@ -21,11 +89,62 @@ ...@@ -21,11 +89,62 @@
.file-name { font-size: 14px; color: var(--text-primary); font-weight: 500; } .file-name { font-size: 14px; color: var(--text-primary); font-weight: 500; }
.file-placeholder { font-size: 14px; color: var(--text-muted); } .file-placeholder { font-size: 14px; color: var(--text-muted); }
@media (max-width: 768px) { .format-hint {
.page-container { padding: 20px 16px; } display: flex;
.form-row { flex-direction: column; } align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
} }
/* ============ AI SECTION ============ */
.ai-section {
padding: 24px;
background: rgba(var(--accent-primary-rgb), 0.04);
border: 1px solid rgba(var(--accent-primary-rgb), 0.12);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: 20px;
}
.ai-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--accent-gradient);
color: #fff;
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
width: fit-content;
}
.ai-badge .material-symbols-rounded {
font-size: 16px;
}
.ai-loading-text {
font-size: 14px;
animation: pulse-text 1.5s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ============ SUBMIT ============ */
.submit-btn {
width: 100%;
margin-top: 4px;
}
/* ============ ASSIGNMENT ============ */
.assignment-box { .assignment-box {
margin-top: 12px; margin-top: 12px;
padding: 16px; padding: 16px;
...@@ -43,6 +162,7 @@ ...@@ -43,6 +162,7 @@
background: var(--bg-secondary); background: var(--bg-secondary);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-top: 8px;
} }
.list-item { .list-item {
...@@ -55,13 +175,8 @@ ...@@ -55,13 +175,8 @@
transition: background 0.15s ease; transition: background 0.15s ease;
} }
.list-item:last-child { .list-item:last-child { border-bottom: none; }
border-bottom: none; .list-item:hover { background: var(--bg-hover); }
}
.list-item:hover {
background: var(--bg-hover);
}
.list-item input[type="checkbox"] { .list-item input[type="checkbox"] {
width: 16px; width: 16px;
...@@ -70,23 +185,11 @@ ...@@ -70,23 +185,11 @@
cursor: pointer; cursor: pointer;
} }
.item-details { .item-details { display: flex; flex-direction: column; }
display: flex; .item-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
flex-direction: column; .item-sub { font-size: 12px; color: var(--text-muted); }
}
.item-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.item-sub { .empty-list {
font-size: 12px;
color: var(--text-muted);
}
.empty-state {
padding: 20px; padding: 20px;
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
...@@ -94,3 +197,9 @@ ...@@ -94,3 +197,9 @@
font-style: italic; font-style: italic;
} }
/* ============ RESPONSIVE ============ */
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.form-row { flex-direction: column; }
.mode-toggle { grid-template-columns: 1fr; }
}
...@@ -4,7 +4,21 @@ ...@@ -4,7 +4,21 @@
<span class="material-symbols-rounded">arrow_back</span> Back to Quizzes <span class="material-symbols-rounded">arrow_back</span> Back to Quizzes
</a> </a>
<h1>Create New Quiz</h1> <h1>Create New Quiz</h1>
<p class="page-subtitle">Upload an Excel file with questions to create a quiz</p> <p class="page-subtitle">Choose a method to create your quiz</p>
</div>
<!-- Mode Toggle -->
<div class="mode-toggle">
<button class="mode-btn" [class.active]="mode() === 'excel'" (click)="setMode('excel')">
<span class="material-symbols-rounded">upload_file</span>
<span class="mode-label">Excel Upload</span>
<span class="mode-desc">Upload a spreadsheet</span>
</button>
<button class="mode-btn" [class.active]="mode() === 'ai'" (click)="setMode('ai')">
<span class="material-symbols-rounded">auto_awesome</span>
<span class="mode-label">AI Generate</span>
<span class="mode-desc">Let AI create questions</span>
</button>
</div> </div>
@if (error()) { @if (error()) {
...@@ -16,37 +30,82 @@ ...@@ -16,37 +30,82 @@
<div class="card card-padding form-card"> <div class="card card-padding form-card">
<form (ngSubmit)="onSubmit()" class="quiz-form"> <form (ngSubmit)="onSubmit()" class="quiz-form">
<div class="form-group">
<label class="form-label">Quiz Title *</label>
<input class="form-input" [(ngModel)]="title" name="title" placeholder="e.g. JavaScript Basics">
</div>
<div class="form-row"> <!-- ============ EXCEL MODE ============ -->
@if (mode() === 'excel') {
<div class="form-group"> <div class="form-group">
<label class="form-label">Time Limit (minutes) *</label> <label class="form-label">Quiz Title *</label>
<input class="form-input" type="number" [(ngModel)]="timer" name="timer" min="1"> <input class="form-input" [(ngModel)]="title" name="title" placeholder="e.g. JavaScript Basics">
</div> </div>
<div class="form-group">
<label class="form-label">Difficulty</label> <div class="form-row">
<select class="form-select" [(ngModel)]="difficulty" name="difficulty"> <div class="form-group">
<option value="easy">Easy</option> <label class="form-label">Time Limit (minutes) *</label>
<option value="medium">Medium</option> <input class="form-input" type="number" [(ngModel)]="timer" name="timer" min="1">
<option value="hard">Hard</option> </div>
</select> <div class="form-group">
<label class="form-label">Difficulty</label>
<select class="form-select" [(ngModel)]="difficulty" name="difficulty">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div> </div>
</div>
<div class="form-row">
<div class="form-group"> <div class="form-group">
<label class="form-label">Category</label> <label class="form-label">Topic</label>
<input class="form-input" [(ngModel)]="category" name="category" placeholder="e.g. Programming"> <input class="form-input" [(ngModel)]="topic" name="topicExcel" placeholder="e.g. Arrays & Loops">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Topic</label> <label class="form-label">Questions File (Excel) *</label>
<input class="form-input" [(ngModel)]="topic" name="topic" placeholder="e.g. Arrays & Loops"> <div class="file-upload" (click)="fileInput.click()">
<input #fileInput type="file" accept=".xlsx,.xls" (change)="onFileSelected($event)" hidden>
<span class="material-symbols-rounded upload-icon">upload_file</span>
@if (fileName()) {
<span class="file-name">{{ fileName() }}</span>
} @else {
<span class="file-placeholder">Click to select an Excel file</span>
}
</div>
<p class="format-hint">
<span class="material-symbols-rounded" style="font-size:14px">info</span>
Format: Question | Option1 | Option2 | Option3 | Option4 | Correct
</p>
</div> </div>
</div> }
<!-- ============ AI MODE ============ -->
@if (mode() === 'ai') {
<div class="ai-section animate-fade-in">
<div class="ai-badge">
<span class="material-symbols-rounded">auto_awesome</span>
AI-Powered Generation
</div>
<div class="form-group">
<label class="form-label">Describe Your Quiz *</label>
<textarea class="form-input form-textarea" [(ngModel)]="topic" name="topic" rows="4"
placeholder="e.g. Generate a quiz on Operating Systems covering process scheduling, memory management, and file systems. Include 10 questions that a student can answer in 15 minutes."></textarea>
<p class="format-hint">
<span class="material-symbols-rounded" style="font-size:14px">lightbulb</span>
Tip: Mention the topic, number of questions, and time limit in your prompt. AI will auto-generate the quiz title, timer, and questions.
</p>
</div>
<div class="form-group">
<label class="form-label">Difficulty Level</label>
<select class="form-select" [(ngModel)]="difficulty" name="difficultyAi">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
}
<!-- Assignment (shared) -->
<div class="form-group"> <div class="form-group">
<label class="form-label">Assign To</label> <label class="form-label">Assign To</label>
<select class="form-select" [(ngModel)]="assignmentType" name="assignmentType"> <select class="form-select" [(ngModel)]="assignmentType" name="assignmentType">
...@@ -59,13 +118,12 @@ ...@@ -59,13 +118,12 @@
@if (assignmentType === 'users') { @if (assignmentType === 'users') {
<div class="form-group assignment-box"> <div class="form-group assignment-box">
<label class="form-label">Search Candidates</label> <label class="form-label">Search Candidates</label>
<input class="form-input" type="text" [(ngModel)]="userSearchQuery" name="userSearchQuery" <input class="form-input" type="text" [(ngModel)]="userSearchQuery" name="userSearchQuery"
(input)="onUserSearch()" placeholder="Search by name or email..."> (input)="onUserSearch()" placeholder="Search by name or email...">
<div class="list-container mt-2"> <div class="list-container mt-2">
@for (user of filteredUsers; track user._id) { @for (user of filteredUsers; track user._id) {
<div class="list-item" (click)="toggleUserSelection(user._id)"> <div class="list-item" (click)="toggleUserSelection(user._id)">
<input type="checkbox" [checked]="selectedUsers.includes(user._id)" <input type="checkbox" [checked]="selectedUsers.includes(user._id)"
(change)="toggleUserSelection(user._id)"> (change)="toggleUserSelection(user._id)">
<div class="item-details"> <div class="item-details">
<span class="item-name">{{ user.name }}</span> <span class="item-name">{{ user.name }}</span>
...@@ -74,7 +132,7 @@ ...@@ -74,7 +132,7 @@
</div> </div>
} }
@if (filteredUsers.length === 0) { @if (filteredUsers.length === 0) {
<div class="empty-state">No candidates found</div> <div class="empty-list">No candidates found</div>
} }
</div> </div>
</div> </div>
...@@ -86,7 +144,7 @@ ...@@ -86,7 +144,7 @@
<div class="list-container"> <div class="list-container">
@for (group of availableGroups; track group) { @for (group of availableGroups; track group) {
<div class="list-item" (click)="toggleGroupSelection(group)"> <div class="list-item" (click)="toggleGroupSelection(group)">
<input type="checkbox" [checked]="selectedGroups.includes(group)" <input type="checkbox" [checked]="selectedGroups.includes(group)"
(change)="toggleGroupSelection(group)"> (change)="toggleGroupSelection(group)">
<div class="item-details"> <div class="item-details">
<span class="item-name">{{ group }}</span> <span class="item-name">{{ group }}</span>
...@@ -94,28 +152,21 @@ ...@@ -94,28 +152,21 @@
</div> </div>
} }
@if (availableGroups.length === 0) { @if (availableGroups.length === 0) {
<div class="empty-state">No groups found</div> <div class="empty-list">No groups found</div>
} }
</div> </div>
</div> </div>
} }
<div class="form-group"> <!-- Submit -->
<label class="form-label">Questions File (Excel) *</label> <button type="submit" class="btn btn-primary btn-lg submit-btn" [disabled]="loading()">
<div class="file-upload" (click)="fileInput.click()"> @if (loading() && mode() === 'ai') {
<input #fileInput type="file" accept=".xlsx,.xls" (change)="onFileSelected($event)" hidden> <div class="spinner"></div>
<span class="material-symbols-rounded upload-icon">upload_file</span> <span class="ai-loading-text">AI is generating your quiz...</span>
@if (fileName()) { } @else if (loading()) {
<span class="file-name">{{ fileName() }}</span>
} @else {
<span class="file-placeholder">Click to select an Excel file</span>
}
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg" [disabled]="loading()" style="width: 100%;">
@if (loading()) {
<div class="spinner"></div> Creating... <div class="spinner"></div> Creating...
} @else if (mode() === 'ai') {
<span class="material-symbols-rounded">auto_awesome</span> Generate Quiz with AI
} @else { } @else {
<span class="material-symbols-rounded">add_circle</span> Create Quiz <span class="material-symbols-rounded">add_circle</span> Create Quiz
} }
......
...@@ -12,35 +12,44 @@ import { QuizService } from '../../../services/quiz.service'; ...@@ -12,35 +12,44 @@ import { QuizService } from '../../../services/quiz.service';
styleUrl: './create-quiz.css' styleUrl: './create-quiz.css'
}) })
export class CreateQuizComponent implements OnInit { export class CreateQuizComponent implements OnInit {
// Mode toggle: 'excel' or 'ai'
mode = signal<'excel' | 'ai'>('excel');
// Excel mode fields
title = ''; title = '';
timer = 30; timer = 30;
category = '';
difficulty = 'medium'; difficulty = 'medium';
topic = ''; topic = '';
selectedFile: File | null = null; selectedFile: File | null = null;
fileName = signal(''); fileName = signal('');
loading = signal(false); // Assignment
success = signal(''); assignmentType = 'all';
error = signal('');
assignmentType = 'all'; // 'all', 'users', 'groups'
availableUsers: any[] = []; availableUsers: any[] = [];
filteredUsers: any[] = []; filteredUsers: any[] = [];
availableGroups: string[] = []; availableGroups: string[] = [];
selectedUsers: string[] = []; selectedUsers: string[] = [];
selectedGroups: string[] = []; selectedGroups: string[] = [];
userSearchQuery = ''; userSearchQuery = '';
// State
loading = signal(false);
success = signal('');
error = signal('');
constructor(private quizService: QuizService, private router: Router) {} constructor(private quizService: QuizService, private router: Router) {}
ngOnInit(): void { ngOnInit(): void {
this.fetchUsersAndGroups(); this.fetchUsersAndGroups();
} }
setMode(mode: 'excel' | 'ai'): void {
this.mode.set(mode);
this.error.set('');
this.success.set('');
}
fetchUsersAndGroups(): void { fetchUsersAndGroups(): void {
// Fetch users (candidates)
this.quizService.getUsers('candidate').subscribe({ this.quizService.getUsers('candidate').subscribe({
next: (res) => { next: (res) => {
this.availableUsers = res.users || []; this.availableUsers = res.users || [];
...@@ -49,7 +58,6 @@ export class CreateQuizComponent implements OnInit { ...@@ -49,7 +58,6 @@ export class CreateQuizComponent implements OnInit {
error: (err) => console.error('Failed to fetch users', err) error: (err) => console.error('Failed to fetch users', err)
}); });
// Fetch groups
this.quizService.getAdminGroups().subscribe({ this.quizService.getAdminGroups().subscribe({
next: (res) => { next: (res) => {
this.availableGroups = res.groups || []; this.availableGroups = res.groups || [];
...@@ -63,7 +71,7 @@ export class CreateQuizComponent implements OnInit { ...@@ -63,7 +71,7 @@ export class CreateQuizComponent implements OnInit {
this.filteredUsers = [...this.availableUsers]; this.filteredUsers = [...this.availableUsers];
} else { } else {
const q = this.userSearchQuery.toLowerCase(); const q = this.userSearchQuery.toLowerCase();
this.filteredUsers = this.availableUsers.filter(u => this.filteredUsers = this.availableUsers.filter(u =>
u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q) u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
); );
} }
...@@ -96,6 +104,14 @@ export class CreateQuizComponent implements OnInit { ...@@ -96,6 +104,14 @@ export class CreateQuizComponent implements OnInit {
} }
onSubmit(): void { onSubmit(): void {
if (this.mode() === 'excel') {
this.submitExcel();
} else {
this.submitAI();
}
}
private submitExcel(): void {
if (!this.title.trim()) { this.error.set('Please enter a quiz title'); return; } if (!this.title.trim()) { this.error.set('Please enter a quiz title'); return; }
if (!this.timer || this.timer < 1) { this.error.set('Timer must be at least 1 minute'); return; } if (!this.timer || this.timer < 1) { this.error.set('Timer must be at least 1 minute'); return; }
if (!this.selectedFile) { this.error.set('Please upload an Excel file'); return; } if (!this.selectedFile) { this.error.set('Please upload an Excel file'); return; }
...@@ -108,20 +124,10 @@ export class CreateQuizComponent implements OnInit { ...@@ -108,20 +124,10 @@ export class CreateQuizComponent implements OnInit {
formData.append('title', this.title); formData.append('title', this.title);
formData.append('timer', this.timer.toString()); formData.append('timer', this.timer.toString());
formData.append('questionsFile', this.selectedFile); formData.append('questionsFile', this.selectedFile);
if (this.category) formData.append('category', this.category);
if (this.difficulty) formData.append('difficulty', this.difficulty); if (this.difficulty) formData.append('difficulty', this.difficulty);
if (this.topic) formData.append('topic', this.topic); if (this.topic) formData.append('topic', this.topic);
// Assignment handling this.appendAssignment(formData);
if (this.assignmentType === 'all') {
formData.append('assignToAll', 'true');
} else if (this.assignmentType === 'users') {
formData.append('assignToAll', 'false');
formData.append('assignees', JSON.stringify(this.selectedUsers));
} else if (this.assignmentType === 'groups') {
formData.append('assignToAll', 'false');
formData.append('assignedGroups', JSON.stringify(this.selectedGroups));
}
this.quizService.createQuiz(formData).subscribe({ this.quizService.createQuiz(formData).subscribe({
next: (res) => { next: (res) => {
...@@ -135,4 +141,51 @@ export class CreateQuizComponent implements OnInit { ...@@ -135,4 +141,51 @@ export class CreateQuizComponent implements OnInit {
} }
}); });
} }
private submitAI(): void {
if (!this.topic.trim()) { this.error.set('Please describe your quiz topic'); return; }
this.loading.set(true);
this.error.set('');
this.success.set('');
const data: any = {
prompt: this.topic,
difficulty: this.difficulty
};
if (this.assignmentType === 'all') {
data.assignToAll = true;
} else if (this.assignmentType === 'users') {
data.assignToAll = false;
data.assignees = this.selectedUsers;
} else if (this.assignmentType === 'groups') {
data.assignToAll = false;
data.assignedGroups = this.selectedGroups;
}
this.quizService.generateAIQuiz(data).subscribe({
next: (res) => {
this.loading.set(false);
this.success.set(`AI Quiz "${res.quiz.title}" generated with ${res.quiz.totalQuestions} questions!`);
setTimeout(() => this.router.navigate(['/admin/quizzes']), 2000);
},
error: (err) => {
this.loading.set(false);
this.error.set(err.error?.message || 'AI generation failed');
}
});
}
private appendAssignment(formData: FormData): void {
if (this.assignmentType === 'all') {
formData.append('assignToAll', 'true');
} else if (this.assignmentType === 'users') {
formData.append('assignToAll', 'false');
formData.append('assignees', JSON.stringify(this.selectedUsers));
} else if (this.assignmentType === 'groups') {
formData.append('assignToAll', 'false');
formData.append('assignedGroups', JSON.stringify(this.selectedGroups));
}
}
} }
...@@ -24,6 +24,25 @@ ...@@ -24,6 +24,25 @@
.btn-primary:hover .material-symbols-rounded { .btn-primary:hover .material-symbols-rounded {
color: #fff; color: #fff;
} }
.attempted-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.attempted-badge .material-symbols-rounded {
font-size: 16px;
color: var(--text-muted);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.page-container { padding: 20px 16px; } .page-container { padding: 20px 16px; }
.quiz-grid { grid-template-columns: 1fr; } .quiz-grid { grid-template-columns: 1fr; }
......
...@@ -55,12 +55,19 @@ ...@@ -55,12 +55,19 @@
<div class="quiz-topic">{{ quiz.topic }}</div> <div class="quiz-topic">{{ quiz.topic }}</div>
} }
<div class="quiz-card-actions"> <div class="quiz-card-actions">
<a [routerLink]="['/admin/quiz', quiz._id, 'edit']" class="btn btn-outline btn-sm"> @if (quiz.attemptCount > 0) {
<span class="material-symbols-rounded">edit</span> Edit <span class="attempted-badge">
</a> <span class="material-symbols-rounded">lock</span>
<button class="btn btn-danger btn-sm" (click)="deleteQuiz(quiz._id)"> {{ quiz.attemptCount }} attempt{{ quiz.attemptCount > 1 ? 's' : '' }}
<span class="material-symbols-rounded">delete</span> Delete </span>
</button> } @else {
<a [routerLink]="['/admin/quiz', quiz._id, 'edit']" class="btn btn-outline btn-sm">
<span class="material-symbols-rounded">edit</span> Edit
</a>
<button class="btn btn-danger btn-sm" (click)="deleteQuiz(quiz._id)">
<span class="material-symbols-rounded">delete</span> Delete
</button>
}
</div> </div>
</div> </div>
} }
......
...@@ -55,6 +55,10 @@ ...@@ -55,6 +55,10 @@
return this.http.post(`${this.adminUrl}/quiz/create-manual`, data); return this.http.post(`${this.adminUrl}/quiz/create-manual`, data);
} }
generateAIQuiz(data: any): Observable<any> {
return this.http.post(`${this.adminUrl}/quiz/generate-ai`, data);
}
getAdminQuizzes(): Observable<any> { getAdminQuizzes(): Observable<any> {
return this.http.get(`${this.adminUrl}/quizzes`); return this.http.get(`${this.adminUrl}/quizzes`);
} }
......
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