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>
135 lines
5.0 KiB
JavaScript
135 lines
5.0 KiB
JavaScript
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, // 0–1: 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 })
|
||
})
|
||
}
|