c4645e3475
Phase 1 — Quick wins: - Image lazy-loading on series/resource cards - Newsletter signup added to Episodes page (before highlights) - Per-route meta tags via usePageMeta hook (title, og:title, og:description) - Breadcrumbs on study index, section, and notes pages - SVG completion checkmark badges on study section list - Analytics time-range filter (7d / 30d / 90d) in admin panel Phase 2 — Medium features: - Related episodes on archived series detail pages - Resource library two-tier filter (type + tag chips) - Global search (Fuse.js) moved below sticky header as full-width bar - Q&A anonymous upvoting with localStorage dedup + admin pin/unpin - Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics Phase 3 — Larger features: - Study section comments (auto-approve for enrolled users, admin moderation panel) - Study completion certificate (canvas render, PNG download, shareable public URL) - Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI) - Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion - Public certificate route at /certificate/:token with og meta tags - Comment moderation panel added to admin under Manage > Study Comments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
123 lines
4.7 KiB
JavaScript
123 lines
4.7 KiB
JavaScript
import { randomUUID } from 'node:crypto'
|
|
import { requireAdminAuth } from '../auth.js'
|
|
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, isStudyUserEnrolled } from '../study-helpers.js'
|
|
import { state } from '../state.js'
|
|
import { queueStudyCommentsWrite } from '../data.js'
|
|
import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js'
|
|
|
|
export function register(app) {
|
|
// GET public-approved comments for a section (no auth)
|
|
app.get('/api/study-comments/:studySlug/:sectionId', (req, res) => {
|
|
const studySlug = normalizeStudySlug(req.params.studySlug)
|
|
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
|
if (!studySlug || !sectionId) {
|
|
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
|
|
}
|
|
|
|
const comments = state.studyComments
|
|
.filter(c => c.studySlug === studySlug && c.sectionId === sectionId && c.isApproved === true)
|
|
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
|
.map(c => ({
|
|
id: c.id,
|
|
displayName: c.displayName,
|
|
text: c.text,
|
|
createdAt: c.createdAt,
|
|
}))
|
|
|
|
res.json({ comments })
|
|
})
|
|
|
|
// POST — enrolled study users post a comment (auto-approved for enrolled users)
|
|
app.post('/api/study-comments/:studySlug/:sectionId', requireStudyAuth, (req, res) => {
|
|
const user = req.studyUser
|
|
const studySlug = normalizeStudySlug(req.params.studySlug)
|
|
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
|
|
|
if (!studySlug || !sectionId) {
|
|
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
|
|
}
|
|
|
|
if (!isStudyUserEnrolled(user, studySlug)) {
|
|
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return
|
|
}
|
|
|
|
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
|
|
if (!text || text.length < 2 || text.length > MAX_STUDY_COMMENT_LENGTH) {
|
|
res.status(400).json({ message: `Comment must be between 2 and ${MAX_STUDY_COMMENT_LENGTH} characters.` }); return
|
|
}
|
|
|
|
// Auto-approve for enrolled users
|
|
const now = new Date().toISOString()
|
|
const comment = {
|
|
id: randomUUID(),
|
|
studySlug,
|
|
sectionId,
|
|
userId: user.id,
|
|
displayName: user.displayName || user.username,
|
|
text,
|
|
createdAt: now,
|
|
isApproved: true,
|
|
approvedAt: now,
|
|
}
|
|
|
|
state.studyComments.unshift(comment)
|
|
if (state.studyComments.length > MAX_STUDY_COMMENTS) {
|
|
state.studyComments = state.studyComments.slice(0, MAX_STUDY_COMMENTS)
|
|
}
|
|
queueStudyCommentsWrite()
|
|
|
|
res.status(201).json({
|
|
ok: true,
|
|
comment: {
|
|
id: comment.id,
|
|
displayName: comment.displayName,
|
|
text: comment.text,
|
|
createdAt: comment.createdAt,
|
|
},
|
|
})
|
|
})
|
|
|
|
// DELETE — enrolled users can delete their own comment
|
|
app.delete('/api/study-comments/:commentId', requireStudyAuth, (req, res) => {
|
|
const user = req.studyUser
|
|
const { commentId } = req.params
|
|
const idx = state.studyComments.findIndex(c => c.id === commentId && c.userId === user.id)
|
|
if (idx === -1) {
|
|
res.status(404).json({ message: 'Comment not found.' }); return
|
|
}
|
|
state.studyComments.splice(idx, 1)
|
|
queueStudyCommentsWrite()
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
// Admin: list all comments (optionally filter by studySlug)
|
|
app.get('/api/admin-study-comments', requireAdminAuth, (req, res) => {
|
|
const { studySlug, sectionId, approved } = req.query
|
|
let comments = state.studyComments
|
|
if (studySlug) comments = comments.filter(c => c.studySlug === studySlug)
|
|
if (sectionId) comments = comments.filter(c => c.sectionId === sectionId)
|
|
if (approved === 'true') comments = comments.filter(c => c.isApproved === true)
|
|
if (approved === 'false') comments = comments.filter(c => c.isApproved !== true)
|
|
res.json({ comments: comments.slice(0, 500) })
|
|
})
|
|
|
|
// Admin: approve a comment
|
|
app.post('/api/admin-study-comments/:commentId/approve', requireAdminAuth, (req, res) => {
|
|
const comment = state.studyComments.find(c => c.id === req.params.commentId)
|
|
if (!comment) { res.status(404).json({ message: 'Comment not found.' }); return }
|
|
comment.isApproved = true
|
|
comment.approvedAt = new Date().toISOString()
|
|
queueStudyCommentsWrite()
|
|
res.json({ ok: true, comment })
|
|
})
|
|
|
|
// Admin: delete any comment
|
|
app.delete('/api/admin-study-comments/:commentId', requireAdminAuth, (req, res) => {
|
|
const idx = state.studyComments.findIndex(c => c.id === req.params.commentId)
|
|
if (idx === -1) { res.status(404).json({ message: 'Comment not found.' }); return }
|
|
state.studyComments.splice(idx, 1)
|
|
queueStudyCommentsWrite()
|
|
res.json({ ok: true })
|
|
})
|
|
}
|