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>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
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 })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user