Files
nmemmert c4645e3475 Add cross-site improvements across three phases
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>
2026-06-09 09:10:29 -04:00

135 lines
5.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import mammoth from 'mammoth'
import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js'
import { queueEpisodeScriptsWrite } from '../data.js'
import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js'
/**
* Simple whitespace-normalised search over episode script text.
* Returns { episodeNumber, title, filename, snippet, score } for matches.
*/
function searchScripts(query) {
if (!query || query.trim().length < 2) return []
const q = query.trim().toLowerCase()
const words = q.split(/\s+/).filter(w => w.length >= 2)
const results = []
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
if (!entry?.text) continue
const text = entry.text.toLowerCase()
// Count how many words appear in the text
const matchCount = words.filter(w => text.includes(w)).length
if (matchCount === 0) continue
// Find a snippet around the first matching word
const firstWord = words.find(w => text.includes(w))
const idx = firstWord ? text.indexOf(firstWord) : 0
const start = Math.max(0, idx - 80)
const end = Math.min(entry.text.length, idx + 180)
let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim()
if (start > 0) snippet = '…' + snippet
if (end < entry.text.length) snippet = snippet + '…'
results.push({
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
snippet,
score: matchCount / words.length, // 01: fraction of query words found
})
}
return results.sort((a, b) => b.score - a.score).slice(0, 8)
}
export function register(app) {
// Public script search — called by the frontend global search
app.get('/api/episode-scripts/search', (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''
if (!q || q.length < 2) { res.json({ results: [] }); return }
res.json({ results: searchScripts(q) })
})
// Admin: list all uploaded scripts
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
uploadedAt: entry.uploadedAt ?? null,
wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0,
}))
list.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
res.json({ scripts: list })
})
// Admin: upload a docx script for an episode
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => {
const episodeNumber = req.params.episodeNumber?.trim()
if (!episodeNumber || !/^\d+$/.test(episodeNumber)) {
res.status(400).json({ message: 'Episode number must be a positive integer.' }); return
}
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : `episode-${episodeNumber}.docx`
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}`
if (!dataUrl.startsWith('data:')) {
res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return
}
const base64 = dataUrl.split(',')[1] ?? ''
if (!base64) { res.status(400).json({ message: 'Empty file.' }); return }
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > 20 * 1024 * 1024) {
res.status(400).json({ message: 'File must be between 1 byte and 20MB.' }); return
}
let text
try {
const result = await mammoth.extractRawText({ buffer })
text = result.value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
} catch (err) {
console.error('[episode-scripts] mammoth extraction failed:', err.message)
res.status(422).json({ message: 'Could not extract text from file. Make sure it is a valid .docx file.' }); return
}
if (!text || text.length < 10) {
res.status(422).json({ message: 'No readable text found in document.' }); return
}
// Trim to max length
if (text.length > MAX_EPISODE_SCRIPT_LENGTH) {
text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH)
}
state.episodeScripts[episodeNumber] = {
title,
filename,
text,
uploadedAt: new Date().toISOString(),
wordCount: text.split(/\s+/).filter(Boolean).length,
}
queueEpisodeScriptsWrite()
res.json({
ok: true,
episodeNumber,
wordCount: state.episodeScripts[episodeNumber].wordCount,
})
})
// Admin: delete a script
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => {
const episodeNumber = req.params.episodeNumber?.trim()
if (!state.episodeScripts[episodeNumber]) {
res.status(404).json({ message: 'Script not found.' }); return
}
delete state.episodeScripts[episodeNumber]
queueEpisodeScriptsWrite()
res.json({ ok: true })
})
}