Skip to content

Commit 661e531

Browse files
committed
feat: add activity discussion/comments system with threaded replies
1 parent a08bafc commit 661e531

3 files changed

Lines changed: 256 additions & 0 deletions

File tree

public/course.html

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,130 @@ <h2 class="font-bold text-slate-800 text-lg mb-3">Welcome!</h2>
223223
}
224224
}
225225

226+
227+
// Comments
228+
let replyToId = null;
229+
function escHtml(s) { const d = document.createElement('div'); d.textContent = s||''; return d.innerHTML; }
230+
async function loadComments() {
231+
try {
232+
const res = await fetch('/api/activities/' + actId + '/comments');
233+
const data = await res.json();
234+
if (res.ok) renderComments(data.data || []);
235+
} catch(e) { console.error('loadComments', e); }
236+
}
237+
function renderComments(comments) {
238+
const top = comments.filter(c => !c.parent_id);
239+
const byParent = {};
240+
comments.filter(c => c.parent_id).forEach(c => {
241+
byParent[c.parent_id] = byParent[c.parent_id] || [];
242+
byParent[c.parent_id].push(c);
243+
});
244+
const list = document.getElementById('comments-list');
245+
if (top.length === 0) {
246+
list.innerHTML = '<p class="text-slate-400 text-sm">No comments yet. Be the first!</p>';
247+
return;
248+
}
249+
list.innerHTML = top.map(c => renderComment(c, byParent)).join('');
250+
}
251+
function renderComment(c, byParent) {
252+
const replies = (byParent[c.id] || []).map(r => renderComment(r, byParent)).join('');
253+
const initial = (c.author || '?')[0].toUpperCase();
254+
const date = new Date(c.created_at).toLocaleDateString();
255+
const replyBtn = token
256+
? '<button onclick="startReply('' + c.id + '', '' + escHtml(c.author) + '')" class="text-xs text-indigo-500 hover:underline">Reply</button>'
257+
: '';
258+
const replyThread = replies
259+
? '<div class="mt-3 pl-4 border-l-2 border-slate-100 space-y-3">' + replies + '</div>'
260+
: '';
261+
return '<div class="flex gap-3" id="comment-' + c.id + '">' +
262+
'<div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 font-bold text-sm flex-shrink-0">' + initial + '</div>' +
263+
'<div class="flex-1">' +
264+
'<div class="flex items-center gap-2 mb-1">' +
265+
'<span class="font-semibold text-slate-800 text-sm">' + escHtml(c.author) + '</span>' +
266+
'<span class="text-xs text-slate-400">' + date + '</span>' +
267+
'</div>' +
268+
'<p class="text-slate-700 text-sm mb-1">' + escHtml(c.body) + '</p>' +
269+
replyBtn + replyThread +
270+
'</div></div>';
271+
}
272+
function startReply(commentId, author) {
273+
replyToId = commentId;
274+
const ind = document.getElementById('reply-indicator');
275+
ind.classList.remove('hidden');
276+
ind.innerHTML = 'Replying to <strong>' + escHtml(author) + '</strong> &mdash; <button type="button" onclick="cancelReply()" class="underline">cancel</button>';
277+
document.getElementById('comment-input').focus();
278+
}
279+
function cancelReply() {
280+
replyToId = null;
281+
document.getElementById('reply-indicator').classList.add('hidden');
282+
}
283+
async function postComment() {
284+
const input = document.getElementById('comment-input');
285+
const body = input.value.trim();
286+
if (body.length === 0) return;
287+
const btn = document.querySelector('#comment-form button[onclick="postComment()"]');
288+
btn.textContent = 'Posting...'; btn.disabled = true;
289+
try {
290+
const payload = { body };
291+
if (replyToId) payload.parent_id = replyToId;
292+
const res = await fetch('/api/activities/' + actId + '/comments', {
293+
method: 'POST',
294+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
295+
body: JSON.stringify(payload)
296+
});
297+
const data = await res.json();
298+
if (res.ok) {
299+
input.value = '';
300+
cancelReply();
301+
await loadComments();
302+
} else {
303+
alert(data.error || 'Failed to post comment');
304+
}
305+
} catch (e) { alert(e.message); }
306+
finally { btn.textContent = 'Post Comment'; btn.disabled = false; }
307+
}
308+
document.addEventListener('click', function(e) {
309+
if (e.target.classList.contains('reply-btn')) {
310+
startReply(e.target.dataset.id, e.target.dataset.author);
311+
}
312+
});
313+
314+
document.addEventListener('DOMContentLoaded', function() {
315+
if (token) {
316+
const cf = document.getElementById('comment-form');
317+
if (cf) cf.classList.remove('hidden');
318+
} else {
319+
const cl = document.getElementById('comment-login-cta');
320+
if (cl) cl.classList.remove('hidden');
321+
}
322+
if (actId) loadComments();
323+
});
226324
if (!actId) {
227325
document.getElementById('act-title').textContent = 'No activity selected';
228326
} else {
229327
loadActivity().catch(e => { document.getElementById('act-title').textContent = 'Error: ' + e.message; });
230328
}
231329
</script>
330+
331+
<!-- Comments Section -->
332+
<div class="mt-8 bg-white rounded-2xl shadow-sm border border-slate-100 p-6" id="comments-section">
333+
<h2 class="font-bold text-slate-800 text-lg mb-4">&#128172; Discussion</h2>
334+
<div id="comments-list" class="space-y-4 mb-6"></div>
335+
<div id="comment-form" class="hidden">
336+
<div id="reply-indicator" class="hidden text-xs text-indigo-600 mb-2">
337+
Replying to a comment &mdash; <button type="button" onclick="cancelReply()" class="underline">cancel</button>
338+
</div>
339+
<textarea id="comment-input" rows="3" placeholder="Write a comment..."
340+
class="w-full border border-slate-200 rounded-xl px-4 py-2 text-sm text-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-300 resize-none"></textarea>
341+
<label for="comment-input" class="sr-only">Write a comment</label>
342+
<button type="button" onclick="postComment()"
343+
class="mt-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-5 py-2 rounded-xl text-sm transition">
344+
Post Comment
345+
</button>
346+
</div>
347+
<div id="comment-login-cta" class="hidden text-sm text-slate-500">
348+
<a href="/login.html" class="text-indigo-600 font-semibold hover:underline">Login</a> to join the discussion.
349+
</div>
350+
</div>
232351
</body>
233352
</html>

schema.sql

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,21 @@ 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+
-- COMMENTS (discussion threads on activities)
98+
CREATE TABLE IF NOT EXISTS comments (
99+
id TEXT PRIMARY KEY,
100+
activity_id TEXT NOT NULL,
101+
user_id TEXT NOT NULL,
102+
body TEXT NOT NULL, -- encrypted
103+
parent_id TEXT, -- NULL = top-level, else reply
104+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
105+
updated_at TEXT,
106+
FOREIGN KEY (activity_id) REFERENCES activities(id),
107+
FOREIGN KEY (user_id) REFERENCES users(id),
108+
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
109+
);
110+
111+
CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id);
112+
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
113+
CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id);

src/worker.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
POST /api/sessions – add a session to activity [host]
1515
GET /api/tags – list all tags
1616
POST /api/activity-tags – add tags to an activity [host]
17+
GET /api/activities/:id/comments – list comments for an activity
18+
POST /api/activities/:id/comments – post a comment [auth]
19+
DELETE /api/comments/:id – delete a comment [owner|host]
1720
1821
Security model
1922
* ALL user PII (username, email, display name, role) is encrypted with a
@@ -361,6 +364,22 @@ def _is_basic_auth_valid(req, env) -> bool:
361364
"CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id)",
362365
"CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id)",
363366
"CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id)",
367+
# Comments
368+
"""CREATE TABLE IF NOT EXISTS comments (
369+
id TEXT PRIMARY KEY,
370+
activity_id TEXT NOT NULL,
371+
user_id TEXT NOT NULL,
372+
body TEXT NOT NULL,
373+
parent_id TEXT,
374+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
375+
updated_at TEXT,
376+
FOREIGN KEY (activity_id) REFERENCES activities(id),
377+
FOREIGN KEY (user_id) REFERENCES users(id),
378+
FOREIGN KEY (parent_id) REFERENCES comments(id)
379+
)""",
380+
"CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id)",
381+
"CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id)",
382+
"CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id)",
364383
]
365384

366385

@@ -1203,6 +1222,106 @@ async def _dispatch(request, env):
12031222
return await serve_static(path, env)
12041223

12051224

1225+
1226+
# ---------------------------------------------------------------------------
1227+
# Comments API
1228+
# ---------------------------------------------------------------------------
1229+
1230+
async def api_get_comments(req, env, activity_id: str, enc_key: str):
1231+
"""GET /api/activities/:id/comments — list comments for an activity."""
1232+
# Check activity exists
1233+
act = await env.DB.prepare(
1234+
"SELECT id FROM activities WHERE id = ?"
1235+
).bind(activity_id).first()
1236+
if not act:
1237+
return err("Activity not found", 404)
1238+
1239+
rows = await env.DB.prepare(
1240+
"SELECT c.id, c.body, c.parent_id, c.created_at, c.updated_at, "
1241+
"c.user_id, u.name AS author_name "
1242+
"FROM comments c "
1243+
"JOIN users u ON u.id = c.user_id "
1244+
"WHERE c.activity_id = ? "
1245+
"ORDER BY c.created_at ASC"
1246+
).bind(activity_id).all()
1247+
1248+
comments = []
1249+
for r in (rows.results or []):
1250+
comments.append({
1251+
"id": r["id"],
1252+
"body": decrypt(r["body"], enc_key),
1253+
"parent_id": r["parent_id"],
1254+
"created_at": r["created_at"],
1255+
"updated_at": r["updated_at"],
1256+
"user_id": r["user_id"],
1257+
"author": decrypt(r["author_name"], enc_key),
1258+
})
1259+
return ok(comments)
1260+
1261+
1262+
async def api_post_comment(req, env, activity_id: str, user, enc_key: str):
1263+
"""POST /api/activities/:id/comments — post a comment (auth required)."""
1264+
if not user:
1265+
return err("Authentication required", 401)
1266+
1267+
# Check activity exists
1268+
act = await env.DB.prepare(
1269+
"SELECT id FROM activities WHERE id = ?"
1270+
).bind(activity_id).first()
1271+
if not act:
1272+
return err("Activity not found", 404)
1273+
1274+
body, parse_err = await parse_json_object(req)
1275+
if parse_err:
1276+
return parse_err
1277+
1278+
text = (body.get("body") or "").strip()
1279+
if not text:
1280+
return err("Comment body is required")
1281+
if len(text) > 2000:
1282+
return err("Comment must be 2000 characters or fewer")
1283+
1284+
parent_id = body.get("parent_id") or None
1285+
if parent_id:
1286+
parent = await env.DB.prepare(
1287+
"SELECT id FROM comments WHERE id = ? AND activity_id = ?"
1288+
).bind(parent_id, activity_id).first()
1289+
if not parent:
1290+
return err("Parent comment not found", 404)
1291+
1292+
cid = new_id()
1293+
await env.DB.prepare(
1294+
"INSERT INTO comments (id, activity_id, user_id, body, parent_id) "
1295+
"VALUES (?, ?, ?, ?, ?)"
1296+
).bind(cid, activity_id, user["id"], encrypt(text, enc_key), parent_id).run()
1297+
1298+
return ok({"id": cid, "body": text, "parent_id": parent_id,
1299+
"user_id": user["id"], "activity_id": activity_id}, "Comment posted")
1300+
1301+
1302+
async def api_delete_comment(req, env, comment_id: str, user):
1303+
"""DELETE /api/comments/:id — delete own comment (or host deletes any)."""
1304+
if not user:
1305+
return err("Authentication required", 401)
1306+
1307+
comment = await env.DB.prepare(
1308+
"SELECT c.id, c.user_id, a.host_id "
1309+
"FROM comments c JOIN activities a ON a.id = c.activity_id "
1310+
"WHERE c.id = ?"
1311+
).bind(comment_id).first()
1312+
if not comment:
1313+
return err("Comment not found", 404)
1314+
1315+
is_owner = comment["user_id"] == user["id"]
1316+
is_host = comment["host_id"] == user["id"]
1317+
1318+
if not (is_owner or is_host):
1319+
return err("Permission denied", 403)
1320+
1321+
await env.DB.prepare("DELETE FROM comments WHERE id = ?").bind(comment_id).run()
1322+
return ok(msg="Comment deleted")
1323+
1324+
12061325
async def on_fetch(request, env):
12071326
try:
12081327
return await _dispatch(request, env)

0 commit comments

Comments
 (0)