Skip to content

Commit cb43f33

Browse files
committed
feat: add course assignment system with submissions and grading
1 parent a08bafc commit cb43f33

3 files changed

Lines changed: 638 additions & 0 deletions

File tree

public/course.html

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ <h2 class="font-bold text-slate-800 text-lg mb-3">Welcome!</h2>
180180
if (data.is_host) {
181181
document.getElementById('act-action').innerHTML =
182182
'<a href="/teach.html?activity_id=' + esc(a.id) + '" class="bg-white text-brand font-bold px-6 py-2.5 rounded-xl shadow hover:bg-indigo-50 transition text-sm">Manage Activity</a>';
183+
isHost = true;
184+
document.getElementById('btn-create-assignment').classList.remove('hidden');
183185
document.getElementById('welcome-card').querySelector('#welcome-text').textContent =
184186
'You are the host of this activity. Use the Manage button to add sessions and update details.';
185187
} else if (data.is_enrolled) {
@@ -223,11 +225,241 @@ <h2 class="font-bold text-slate-800 text-lg mb-3">Welcome!</h2>
223225
}
224226
}
225227

228+
229+
// Assignments
230+
let currentAsgnId = null;
231+
let currentSubId = null;
232+
let isHost = false;
233+
234+
function esc2(s) { const d = document.createElement('div'); d.textContent = s||''; return d.innerHTML; }
235+
236+
async function loadAssignments() {
237+
if (!actId) return;
238+
try {
239+
const headers = token ? { Authorization: 'Bearer ' + token } : {};
240+
const res = await fetch('/api/activities/' + actId + '/assignments', { headers });
241+
const data = await res.json();
242+
if (res.ok) renderAssignments(data.data || []);
243+
} catch(e) { console.error('loadAssignments', e); }
244+
}
245+
246+
function renderAssignments(assignments) {
247+
const list = document.getElementById('assignments-list');
248+
if (!assignments.length) {
249+
list.innerHTML = '<p class="text-slate-400 text-sm">No assignments yet.</p>';
250+
return;
251+
}
252+
list.innerHTML = assignments.map(a => {
253+
const due = a.due_date ? '<span class="text-xs text-slate-400">Due: ' + new Date(a.due_date).toLocaleDateString() + '</span>' : '';
254+
const badge = a.status === 'published'
255+
? '<span class="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">Published</span>'
256+
: '<span class="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full">Draft</span>';
257+
const hostActions = isHost
258+
? '<button type="button" onclick="viewSubmissions(\'' + a.id + '\')" class="text-xs text-indigo-500 hover:underline">Submissions (' + (a.submission_count||0) + ')</button>'
259+
: (token ? '<button type="button" onclick="showSubmitForm(\'' + a.id + '\', \'' + esc2(a.title) + '\')" class="text-xs text-indigo-500 hover:underline">Submit</button>' : '');
260+
return '<div class="bg-slate-50 rounded-xl p-4 border border-slate-100">' +
261+
'<div class="flex items-start justify-between mb-1">' +
262+
'<h3 class="font-semibold text-slate-800 text-sm">' + esc2(a.title) + '</h3>' +
263+
'<div class="flex items-center gap-2">' + badge + '</div>' +
264+
'</div>' +
265+
(a.description ? '<p class="text-xs text-slate-500 mb-2">' + esc2(a.description) + '</p>' : '') +
266+
'<div class="flex items-center justify-between">' +
267+
'<div class="flex gap-3">' + due + '<span class="text-xs text-slate-400">Max: ' + a.max_score + ' pts</span></div>' +
268+
hostActions +
269+
'</div>' +
270+
'</div>';
271+
}).join('');
272+
}
273+
274+
function showCreateAssignment() {
275+
document.getElementById('create-assignment-form').classList.remove('hidden');
276+
}
277+
function hideCreateAssignment() {
278+
document.getElementById('create-assignment-form').classList.add('hidden');
279+
}
280+
281+
async function createAssignment() {
282+
const title = document.getElementById('asgn-title').value.trim();
283+
if (!title) { alert('Title is required'); return; }
284+
const payload = {
285+
title,
286+
description: document.getElementById('asgn-desc').value.trim(),
287+
due_date: document.getElementById('asgn-due').value || null,
288+
max_score: parseInt(document.getElementById('asgn-score').value) || 100,
289+
status: document.getElementById('asgn-status').value,
290+
allow_late: document.getElementById('asgn-late').checked,
291+
};
292+
try {
293+
const res = await fetch('/api/activities/' + actId + '/assignments', {
294+
method: 'POST',
295+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
296+
body: JSON.stringify(payload)
297+
});
298+
const data = await res.json();
299+
if (res.ok) {
300+
hideCreateAssignment();
301+
document.getElementById('asgn-title').value = '';
302+
document.getElementById('asgn-desc').value = '';
303+
await loadAssignments();
304+
} else { alert(data.error || 'Failed'); }
305+
} catch(e) { alert(e.message); }
306+
}
307+
308+
function showSubmitForm(asgnId, title) {
309+
currentAsgnId = asgnId;
310+
document.getElementById('submit-asgn-title').textContent = title;
311+
document.getElementById('submit-form').classList.remove('hidden');
312+
document.getElementById('submit-text').focus();
313+
}
314+
function hideSubmitForm() {
315+
document.getElementById('submit-form').classList.add('hidden');
316+
currentAsgnId = null;
317+
}
318+
319+
async function submitAssignment() {
320+
const text = document.getElementById('submit-text').value.trim();
321+
if (!text) { alert('Please write a response'); return; }
322+
try {
323+
const res = await fetch('/api/assignments/' + currentAsgnId + '/submit', {
324+
method: 'POST',
325+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
326+
body: JSON.stringify({ text_response: text })
327+
});
328+
const data = await res.json();
329+
if (res.ok) {
330+
hideSubmitForm();
331+
document.getElementById('submit-text').value = '';
332+
alert('Submitted successfully!');
333+
await loadAssignments();
334+
} else { alert(data.error || 'Failed'); }
335+
} catch(e) { alert(e.message); }
336+
}
337+
338+
async function viewSubmissions(asgnId) {
339+
try {
340+
const res = await fetch('/api/assignments/' + asgnId + '/submissions', {
341+
headers: { Authorization: 'Bearer ' + token }
342+
});
343+
const data = await res.json();
344+
if (!res.ok) { alert(data.error || 'Failed'); return; }
345+
const subs = data.data || [];
346+
if (!subs.length) { alert('No submissions yet'); return; }
347+
const list = document.getElementById('assignments-list');
348+
list.innerHTML = '<button type="button" onclick="loadAssignments()" class="text-xs text-indigo-500 hover:underline mb-3 block">← Back to assignments</button>' +
349+
subs.map(s => '<div class="bg-white rounded-xl p-4 border border-slate-200 mb-2">' +
350+
'<div class="flex justify-between mb-1">' +
351+
'<span class="font-semibold text-slate-800 text-sm">' + esc2(s.student_name) + '</span>' +
352+
'<span class="text-xs ' + (s.status==='graded' ? 'text-green-600' : 'text-yellow-600') + '">' + s.status + (s.score !== null ? ' — ' + s.score + ' pts' : '') + '</span>' +
353+
'</div>' +
354+
'<p class="text-xs text-slate-500 mb-2">' + esc2(s.text_response) + '</p>' +
355+
'<button type="button" onclick="showGradeForm(\'' + s.id + '\')" class="text-xs text-indigo-500 hover:underline">Grade</button>' +
356+
'</div>').join('');
357+
} catch(e) { alert(e.message); }
358+
}
359+
360+
function showGradeForm(subId) {
361+
currentSubId = subId;
362+
document.getElementById('grade-form').classList.remove('hidden');
363+
document.getElementById('grade-score').focus();
364+
}
365+
function hideGradeForm() {
366+
document.getElementById('grade-form').classList.add('hidden');
367+
currentSubId = null;
368+
}
369+
370+
async function gradeSubmission() {
371+
const score = parseInt(document.getElementById('grade-score').value);
372+
const feedback = document.getElementById('grade-feedback').value.trim();
373+
if (isNaN(score)) { alert('Please enter a score'); return; }
374+
try {
375+
const res = await fetch('/api/submissions/' + currentSubId + '/grade', {
376+
method: 'POST',
377+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
378+
body: JSON.stringify({ score, feedback })
379+
});
380+
const data = await res.json();
381+
if (res.ok) {
382+
hideGradeForm();
383+
alert('Graded successfully!');
384+
} else { alert(data.error || 'Failed'); }
385+
} catch(e) { alert(e.message); }
386+
}
387+
388+
// Load assignments after activity loads
389+
const _origLoadActivity = loadActivity;
390+
async function loadActivity() {
391+
await _origLoadActivity.call(this, ...arguments);
392+
}
393+
394+
document.addEventListener('DOMContentLoaded', function() {
395+
if (actId) loadAssignments();
396+
});
226397
if (!actId) {
227398
document.getElementById('act-title').textContent = 'No activity selected';
228399
} else {
229400
loadActivity().catch(e => { document.getElementById('act-title').textContent = 'Error: ' + e.message; });
230401
}
231402
</script>
403+
404+
<!-- Assignments Section -->
405+
<div class="mt-8 bg-white rounded-2xl shadow-sm border border-slate-100 p-6" id="assignments-section">
406+
<div class="flex items-center justify-between mb-4">
407+
<h2 class="font-bold text-slate-800 text-lg">&#128196; Assignments</h2>
408+
<button id="btn-create-assignment" class="hidden bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-xl transition" onclick="showCreateAssignment()">+ New Assignment</button>
409+
</div>
410+
411+
<!-- Create Assignment Form (host only) -->
412+
<div id="create-assignment-form" class="hidden mb-6 bg-slate-50 rounded-xl p-4 border border-slate-200">
413+
<h3 class="font-semibold text-slate-700 mb-3 text-sm">Create Assignment</h3>
414+
<input id="asgn-title" type="text" placeholder="Title *" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm mb-2 focus:outline-none focus:ring-2 focus:ring-indigo-300">
415+
<textarea id="asgn-desc" rows="3" placeholder="Description (optional)" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm mb-2 focus:outline-none focus:ring-2 focus:ring-indigo-300 resize-none"></textarea>
416+
<div class="grid grid-cols-2 gap-2 mb-2">
417+
<div>
418+
<label class="text-xs text-slate-500 mb-1 block">Due Date (optional)</label>
419+
<input id="asgn-due" type="datetime-local" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300">
420+
</div>
421+
<div>
422+
<label class="text-xs text-slate-500 mb-1 block">Max Score</label>
423+
<input id="asgn-score" type="number" value="100" min="1" max="1000" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300">
424+
</div>
425+
</div>
426+
<div class="flex items-center gap-4 mb-3">
427+
<select id="asgn-status" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300">
428+
<option value="draft">Draft</option>
429+
<option value="published">Published</option>
430+
</select>
431+
<label class="flex items-center gap-2 text-sm text-slate-600">
432+
<input type="checkbox" id="asgn-late"> Allow late submissions
433+
</label>
434+
</div>
435+
<div class="flex gap-2">
436+
<button type="button" onclick="createAssignment()" class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition">Create</button>
437+
<button type="button" onclick="hideCreateAssignment()" class="bg-slate-100 hover:bg-slate-200 text-slate-600 text-sm font-semibold px-4 py-2 rounded-lg transition">Cancel</button>
438+
</div>
439+
</div>
440+
441+
<div id="assignments-list" class="space-y-3"></div>
442+
443+
<!-- Submit Assignment Form (students) -->
444+
<div id="submit-form" class="hidden mt-4 bg-slate-50 rounded-xl p-4 border border-slate-200">
445+
<h3 class="font-semibold text-slate-700 mb-3 text-sm">Submit Assignment: <span id="submit-asgn-title"></span></h3>
446+
<textarea id="submit-text" rows="4" placeholder="Your response..." class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm mb-2 focus:outline-none focus:ring-2 focus:ring-indigo-300 resize-none"></textarea>
447+
<div class="flex gap-2">
448+
<button type="button" onclick="submitAssignment()" class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition">Submit</button>
449+
<button type="button" onclick="hideSubmitForm()" class="bg-slate-100 hover:bg-slate-200 text-slate-600 text-sm font-semibold px-4 py-2 rounded-lg transition">Cancel</button>
450+
</div>
451+
</div>
452+
453+
<!-- Grade Form (host) -->
454+
<div id="grade-form" class="hidden mt-4 bg-slate-50 rounded-xl p-4 border border-slate-200">
455+
<h3 class="font-semibold text-slate-700 mb-3 text-sm">Grade Submission</h3>
456+
<input id="grade-score" type="number" min="0" placeholder="Score" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm mb-2 focus:outline-none focus:ring-2 focus:ring-indigo-300">
457+
<textarea id="grade-feedback" rows="3" placeholder="Feedback (optional)" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm mb-2 focus:outline-none focus:ring-2 focus:ring-indigo-300 resize-none"></textarea>
458+
<div class="flex gap-2">
459+
<button type="button" onclick="gradeSubmission()" class="bg-green-600 hover:bg-green-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition">Save Grade</button>
460+
<button type="button" onclick="hideGradeForm()" class="bg-slate-100 hover:bg-slate-200 text-slate-600 text-sm font-semibold px-4 py-2 rounded-lg transition">Cancel</button>
461+
</div>
462+
</div>
463+
</div>
232464
</body>
233465
</html>

schema.sql

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,42 @@ CREATE INDEX IF NOT EXISTS idx_sessions_activity ON sessions(activity_id);
9393
CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id);
9494
CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id);
9595
CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id);
96+
97+
-- ASSIGNMENTS (tasks created by activity hosts for enrolled students)
98+
CREATE TABLE IF NOT EXISTS assignments (
99+
id TEXT PRIMARY KEY,
100+
activity_id TEXT NOT NULL,
101+
title TEXT NOT NULL,
102+
description TEXT,
103+
due_date TEXT,
104+
max_score INTEGER NOT NULL DEFAULT 100,
105+
status TEXT NOT NULL DEFAULT 'draft',
106+
allow_late INTEGER NOT NULL DEFAULT 0,
107+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
108+
updated_at TEXT,
109+
FOREIGN KEY (activity_id) REFERENCES activities(id)
110+
);
111+
112+
-- SUBMISSIONS (student responses to assignments)
113+
CREATE TABLE IF NOT EXISTS submissions (
114+
id TEXT PRIMARY KEY,
115+
assignment_id TEXT NOT NULL,
116+
student_id TEXT NOT NULL,
117+
text_response TEXT,
118+
file_url TEXT,
119+
status TEXT NOT NULL DEFAULT 'submitted',
120+
score INTEGER,
121+
feedback TEXT,
122+
graded_by TEXT,
123+
graded_at TEXT,
124+
submitted_at TEXT NOT NULL DEFAULT (datetime('now')),
125+
updated_at TEXT,
126+
UNIQUE (assignment_id, student_id),
127+
FOREIGN KEY (assignment_id) REFERENCES assignments(id),
128+
FOREIGN KEY (student_id) REFERENCES users(id),
129+
FOREIGN KEY (graded_by) REFERENCES users(id)
130+
);
131+
132+
CREATE INDEX IF NOT EXISTS idx_assignments_activity ON assignments(activity_id);
133+
CREATE INDEX IF NOT EXISTS idx_submissions_assignment ON submissions(assignment_id);
134+
CREATE INDEX IF NOT EXISTS idx_submissions_student ON submissions(student_id);

0 commit comments

Comments
 (0)