Compare commits

...
11 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 03c3dd72fe Highlights: auto-fill episode audio from the feed by season + episode number; v2.46.1
Publish Container / build (push) Successful in 15m38s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 09:09:23 -04:00
nmemmertandClaude Sonnet 5.5 30a7e8a6cb Study: lesson reminders on by default for new accounts; one-time backfill turns them on for existing accounts; v2.46.0
Publish Container / build (push) Successful in 16m7s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 16:53:03 -04:00
nmemmertandClaude Sonnet 5.5 a11adf8ad2 Episodes: Browse Episodes defaults to the current series' season; v2.45.3
Publish Container / build (push) Successful in 16m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:37:35 -04:00
nmemmertandClaude Sonnet 5.5 e2cd66386b Episodes: Podcast Highlights always defaults to the current series tab; v2.45.2
Publish Container / build (push) Successful in 16m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 10:01:16 -04:00
nmemmertandClaude Sonnet 5.5 597f996474 Fix mobile nav: tapping Episodes/Studies opens submenu instead of navigating, larger caret, scrollable open menu; v2.45.1
Publish Container / build (push) Successful in 14m27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:10:16 -04:00
nmemmertandClaude Sonnet 5.5 a1adfa0ef1 Scripture Index: verse of the week (admin override or weekly rotation through covered passages); v2.45.0
Publish Container / build (push) Successful in 17m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:50:19 -04:00
nmemmertandClaude Sonnet 5.5 e9d9dd0d50 Analytics: unique visitors this week + probed-URLs list; Scripture Index: most-studied strip + print; admin warning for unparseable passage references; v2.44.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:38:26 -04:00
nmemmertandClaude Sonnet 5.5 6a5d525d6e Fix Scripture Index doubled/unparseable refs, Titus passage fetch 400, Ko-fi QR 404, auto-load study comments; stop probe URLs inflating Real Hits, real 404 status for unknown URLs; v2.43.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:30:05 -04:00
nmemmertandClaude Sonnet 5.5 09177f47ed Notes: multiple notes per section with last-edited dates, delete, sort by recently edited; Word export now readable; shared note-format module + tests; v2.43.0
Publish Container / build (push) Successful in 16m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:14:45 -04:00
nmemmertandClaude Sonnet 5.5 401fd451de Fix lint errors, upgrade react-simple-maps to v5 (clears high vuln), My Notes: search other studies + Markdown download; v2.42.2
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:08:08 -04:00
nmemmertandClaude Sonnet 5.5 cf8835119e Update dependencies within semver ranges (react 19.3, tiptap 3.32, express 5.3, etc.); v2.42.1
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:03:03 -04:00
25 changed files with 1924 additions and 1234 deletions

No files matched your search

+1258 -1125
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.42.0", "version": "2.46.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -38,7 +38,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"react-simple-maps": "^3.0.0", "react-simple-maps": "^5.0.5",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"resend": "^6.10.0", "resend": "^6.10.0",
"tar": "^7.5.19" "tar": "^7.5.19"
+9 -1
View File
@@ -39,10 +39,12 @@ import {
sanitizeUserAgent, sanitizeUserAgent,
shouldCountHit, shouldCountHit,
recordHit, recordHit,
isKnownSitePath,
scheduleStudyReminders, scheduleStudyReminders,
scheduleReengagementEmails, scheduleReengagementEmails,
scheduleWeeklyDigestEmails, scheduleWeeklyDigestEmails,
backfillCompletedStudies, backfillCompletedStudies,
backfillReminderDefault,
} from './server/study-helpers.js' } from './server/study-helpers.js'
import { recordVisitor } from './server/routes/analytics.js' import { recordVisitor } from './server/routes/analytics.js'
@@ -143,7 +145,8 @@ registerSalvation(app)
app.use((req, res, next) => { app.use((req, res, next) => {
if (shouldCountHit(req)) { if (shouldCountHit(req)) {
const ua = sanitizeUserAgent(req.get('user-agent')) const ua = sanitizeUserAgent(req.get('user-agent'))
const botDetection = detectBot(ua) const knownPath = isKnownSitePath(req.path)
const botDetection = knownPath ? detectBot(ua) : { isBot: true, reason: 'unknown-path' }
recordHit(req.path, botDetection.isBot, botDetection.reason) recordHit(req.path, botDetection.isBot, botDetection.reason)
queueHitStatsWrite() queueHitStatsWrite()
if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) { if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
@@ -208,6 +211,11 @@ Promise.all([
.catch(err => { .catch(err => {
console.error('[study] completed-study backfill failed:', err) console.error('[study] completed-study backfill failed:', err)
}) })
.then(() => backfillReminderDefault())
.then(updated => { if (updated > 0) console.log(`[study] turned lesson reminders on for ${updated} existing accounts`) })
.catch(err => {
console.error('[study] reminder-default backfill failed:', err)
})
.finally(() => { .finally(() => {
createBackupSnapshot('startup').catch(() => {}) createBackupSnapshot('startup').catch(() => {})
setInterval(() => { setInterval(() => {
+1
View File
@@ -172,6 +172,7 @@ export const EMPTY_HIT_STATS = {
byPath: {}, byPath: {},
byPathReal: {}, byPathReal: {},
byPathBot: {}, byPathBot: {},
byPathUnknown: {},
byDay: {}, byDay: {},
byDayReal: {}, byDayReal: {},
byDayBot: {}, byDayBot: {},
+3
View File
@@ -167,6 +167,7 @@ export function loadHitStatsFromDisk() {
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {}, byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {}, byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {}, byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
byPathUnknown: parsed?.byPathUnknown && typeof parsed.byPathUnknown === 'object' ? parsed.byPathUnknown : {},
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {}, byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {}, byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {}, byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
@@ -1250,6 +1251,7 @@ function sanitizeLoadedHitStats(value) {
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {}, byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {}, byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {},
byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {}, byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {},
byPathUnknown: value?.byPathUnknown && typeof value.byPathUnknown === 'object' ? value.byPathUnknown : {},
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {}, byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {}, byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {},
byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {}, byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {},
@@ -1419,6 +1421,7 @@ export function sanitizeStudyUsers(value) {
remindersPausedUntil: typeof item?.remindersPausedUntil === 'string' ? item.remindersPausedUntil : null, remindersPausedUntil: typeof item?.remindersPausedUntil === 'string' ? item.remindersPausedUntil : null,
reminderFrequency: item?.reminderFrequency === 'weekly' ? 'weekly' : 'immediate', reminderFrequency: item?.reminderFrequency === 'weekly' ? 'weekly' : 'immediate',
weeklyDigestSentAt: typeof item?.weeklyDigestSentAt === 'string' ? item.weeklyDigestSentAt : null, weeklyDigestSentAt: typeof item?.weeklyDigestSentAt === 'string' ? item.weeklyDigestSentAt : null,
remindersDefaultedAt: typeof item?.remindersDefaultedAt === 'string' ? item.remindersDefaultedAt : null,
}) })
} }
return out.slice(0, MAX_STUDY_USERS) return out.slice(0, MAX_STUDY_USERS)
+11 -6
View File
@@ -4,7 +4,7 @@ import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
// The Spotify (Anchor) RSS feed only lists episodes that are live, so a season + episode number // The Spotify (Anchor) RSS feed only lists episodes that are live, so a season + episode number
// showing up there means the episode has been published. That drives two things on the public site: // showing up there means the episode has been published. That drives two things on the public site:
// - highlights marked "hold until released" stay hidden until their episode is in the feed // - highlights marked "hold until released" stay hidden until their episode is in the feed
// - study lessons with no audio of their own pick it up from the matching feed episode // - highlights and study lessons with no audio of their own pick it up from the matching feed episode
const SNAPSHOT_TTL_MS = 5 * 60 * 1000 const SNAPSHOT_TTL_MS = 5 * 60 * 1000
@@ -44,7 +44,7 @@ export async function applyFeedRelease(content) {
const links = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : [] const links = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const studies = Array.isArray(content.studies) ? content.studies : [] const studies = Array.isArray(content.studies) ? content.studies : []
if (!links.some(l => l?.holdUntilReleased) && studies.length === 0) return content if (links.length === 0 && studies.length === 0) return content
const [index, wf] = await Promise.all([getFeedIndex(), loadPodcastWorkflow()]) const [index, wf] = await Promise.all([getFeedIndex(), loadPodcastWorkflow()])
const seasonA = laneSeasonOf('A', wf, content) const seasonA = laneSeasonOf('A', wf, content)
@@ -52,11 +52,16 @@ export async function applyFeedRelease(content) {
const nextLinks = links.flatMap(link => { const nextLinks = links.flatMap(link => {
// General highlights aren't tied to a series, so there's no season to match against // General highlights aren't tied to a series, so there's no season to match against
if (!link?.holdUntilReleased || link.lane === 'general') return [link] if (!link || link.lane === 'general') return [link]
const season = link.lane === 'second' ? seasonB : seasonA const season = link.lane === 'second' ? seasonB : seasonA
const item = season && link.episodeNumber ? index.get(`${season}-${link.episodeNumber}`) : null const item = season && link.episodeNumber ? index.get(`${season}-${String(link.episodeNumber).trim()}`) : null
if (!item) return [] if (!item) return link.holdUntilReleased ? [] : [link]
return [{ ...link, url: link.url || item.link || '' }] // Highlights with no audio of their own pick it up from the matching feed episode
return [{
...link,
url: link.url || item.link || '',
embedUrl: link.embedUrl?.trim() ? link.embedUrl : (item.audioUrl || link.embedUrl),
}]
}) })
const studyLane = study => { const studyLane = study => {
+9
View File
@@ -20,6 +20,7 @@ import {
buildLastNDaysStats, buildLastNDaysStats,
shouldCountHit, shouldCountHit,
recordHit, recordHit,
isKnownSitePath,
getStudyCatalog, getStudyCatalog,
} from '../study-helpers.js' } from '../study-helpers.js'
@@ -260,6 +261,7 @@ export function register(app) {
const ip = getClientIp(req) const ip = getClientIp(req)
if (!pageviewAllowed(ip)) { res.json({ ok: false, reason: 'rate-limited' }); return } if (!pageviewAllowed(ip)) { res.json({ ok: false, reason: 'rate-limited' }); return }
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/' const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
if (!isKnownSitePath(rawPath)) { res.json({ ok: false, reason: 'unknown-path' }); return }
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : '' const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
recordHit(rawPath, false) recordHit(rawPath, false)
queueHitStatsWrite() queueHitStatsWrite()
@@ -391,6 +393,13 @@ export function register(app) {
firstHitAt: state.hitStats.firstHitAt, firstHitAt: state.hitStats.firstHitAt,
lastHitAt: state.hitStats.lastHitAt, lastHitAt: state.hitStats.lastHitAt,
topPaths, topPathsReal, topPathsBot, topPaths, topPathsReal, topPathsBot,
topUnknownPaths: Object.entries(state.hitStats.byPathUnknown ?? {})
.sort((a, b) => b[1] - a[1]).slice(0, 25).map(([path, hits]) => ({ path, hits })),
uniqueVisitors7d: new Set(
state.visitorStats.recentVisits
.filter(v => Date.now() - new Date(v.at).getTime() <= 7 * 24 * 60 * 60 * 1000)
.map(v => v.visitorId),
).size,
last7Days, last7DaysReal, last7DaysBot, last7Days, last7DaysReal, last7DaysBot,
last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal, last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal,
botReasons, botReasons,
+6 -1
View File
@@ -29,7 +29,12 @@ BIBLE_BOOKS.forEach((b, index) => {
}) })
function parseReference(reference) { function parseReference(reference) {
const stripped = reference.replace(/^[a-z]+/i, '') // Accept a leading book name ("Titus 1:1-2", "1 John 2:3"), en/em dashes and verse-part letters ("1:13a")
const stripped = reference
.replace(/[–—]/g, '-')
.replace(/(\d)[a-z]\b/gi, '$1')
.replace(/^\s*(?:[1-3]\s*)?[a-z][a-z .]*?\s*(?=\d+:)/i, '')
.trim()
const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/) const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/)
if (!match) return null if (!match) return null
return { return {
+5 -2
View File
@@ -16,7 +16,7 @@ import {
} from '../config.js' } from '../config.js'
import { state } from '../state.js' import { state } from '../state.js'
import { loadSiteContentFile } from '../data.js' import { loadSiteContentFile } from '../data.js'
import { sanitizeRedirectRules } from '../study-helpers.js' import { sanitizeRedirectRules, isKnownSitePath } from '../study-helpers.js'
import { fetchAllEpisodes } from './episodes.js' import { fetchAllEpisodes } from './episodes.js'
import { fetchNuggetsEpisodes } from './nuggets.js' import { fetchNuggetsEpisodes } from './nuggets.js'
import { BIBLE_BOOKS } from '../../shared/bible-books.js' import { BIBLE_BOOKS } from '../../shared/bible-books.js'
@@ -217,7 +217,8 @@ export function register(app) {
if (!sec.id || !sec.reference || !sec.releasedAt) continue if (!sec.id || !sec.reference || !sec.releasedAt) continue
if (new Date(sec.releasedAt) > cutoff) continue if (new Date(sec.releasedAt) > cutoff) continue
entries.push({ entries.push({
reference: `${bookName} ${sec.reference}`, // Section references often already include the book ("Titus 1:1-2"); only prepend when they don't
reference: /^\s*(?:[1-3]\s*)?[A-Za-z]/.test(sec.reference) ? sec.reference.trim() : `${bookName} ${sec.reference}`,
sourceType: 'study', sourceType: 'study',
sourceTitle: s.title || s.slug, sourceTitle: s.title || s.slug,
sectionTitle: sec.title || '', sectionTitle: sec.title || '',
@@ -274,6 +275,8 @@ export function register(app) {
app.use(async (_req, res) => { app.use(async (_req, res) => {
try { try {
const html = await readFile(INDEX_FILE, 'utf8') const html = await readFile(INDEX_FILE, 'utf8')
// Unknown URLs still render the app's "Page not found" screen, but with a real 404 status
if (!isKnownSitePath(_req.path)) res.status(404)
res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent)) res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent))
} catch { } catch {
res.status(503).send('Frontend build not found. Run "npm run build" first.') res.status(503).send('Frontend build not found. Run "npm run build" first.')
+2 -1
View File
@@ -1,3 +1,4 @@
import { noteEntriesToText } from '../../shared/note-format.js'
import { randomUUID, timingSafeEqual } from 'node:crypto' import { randomUUID, timingSafeEqual } from 'node:crypto'
import rateLimit from 'express-rate-limit' import rateLimit from 'express-rate-limit'
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx' import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
@@ -87,7 +88,7 @@ export function register(app) {
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
if (!studyEntries[studySlug]) studyEntries[studySlug] = {} if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {} studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].noteText = noteText.trim() studyEntries[studySlug][sectionId].noteText = noteEntriesToText(noteText) || noteText.trim()
} }
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) { for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
+2 -1
View File
@@ -117,7 +117,8 @@ export function register(app) {
passwordHash: hashStudyPassword(password), passwordHash: hashStudyPassword(password),
displayName, displayName,
subscribeNewsletter: subscribe, subscribeNewsletter: subscribe,
studyRemindersEnabled: false, studyRemindersEnabled: true,
remindersDefaultedAt: now,
pendingEmailChange: null, pendingEmailChange: null,
emailVerified: false, emailVerified: false,
pendingEmailVerification: { pendingEmailVerification: {
+47
View File
@@ -476,6 +476,21 @@ export function normalizeHitPath(pathname) {
return pathname return pathname
} }
// Top-level URL segments the SPA / static pages actually serve. Anything else is a probe or
// scanner hit, which must not count as a real visit or add a new key to the per-path stats.
const KNOWN_SITE_SEGMENTS = new Set([
'start-here', 'study', 'finished', 'episodes', 'nuggets', 'resources', 'downloads', 'about', 'contact',
'questions', 'newsletter', 'scripture', 'thanks', 'subscribe', 'certificate', 'host', 'admin', 'preview',
'privacy', 'terms', 'salvation', 'qr',
])
const MAX_UNKNOWN_PATHS = 300
export function isKnownSitePath(pathname) {
const first = String(pathname ?? '/').split('/')[1] ?? ''
return first === '' || KNOWN_SITE_SEGMENTS.has(first)
}
export function shouldCountHit(req) { export function shouldCountHit(req) {
if (req.method !== 'GET') return false if (req.method !== 'GET') return false
if (req.path.startsWith('/api/')) return false if (req.path.startsWith('/api/')) return false
@@ -580,6 +595,21 @@ export function recordHit(pathname, isBot = false, botReason = null) {
state.hitStats.lastHitAt = nowIso state.hitStats.lastHitAt = nowIso
state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso
if (isBot && botReason === 'unknown-path') {
// Probe/scanner URLs: keep a bounded list for the admin view instead of one stats key per URL
const unknown = (state.hitStats.byPathUnknown ??= {})
const key = safePath.slice(0, 120)
if (key in unknown || Object.keys(unknown).length < MAX_UNKNOWN_PATHS) unknown[key] = (unknown[key] ?? 0) + 1
else unknown['(other)'] = (unknown['(other)'] ?? 0) + 1
state.hitStats.botHits += 1
state.hitStats.byPathBot['(unknown)'] = (state.hitStats.byPathBot['(unknown)'] ?? 0) + 1
state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1
state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1
state.hitStats.byPath['(unknown)'] = (state.hitStats.byPath['(unknown)'] ?? 0) + 1
state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1
return
}
if (isBot) { if (isBot) {
state.hitStats.botHits += 1 state.hitStats.botHits += 1
state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1 state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1
@@ -706,6 +736,23 @@ export function extractResendMessageId(result) {
return '' return ''
} }
// One-off pass: lesson reminders are now on by default, so switch them on for accounts that
// predate the default. Each account is marked as it is handled, so a later opt-out sticks.
export async function backfillReminderDefault() {
const now = new Date().toISOString()
let updated = 0
for (const user of state.studyUsers) {
if (user.remindersDefaultedAt) continue
if (user.studyRemindersEnabled !== true) {
user.studyRemindersEnabled = true
updated++
}
user.remindersDefaultedAt = now
}
if (state.studyUsers.some(user => user.remindersDefaultedAt === now)) queueStudyUsersWrite()
return updated
}
// ── Study reminder scheduler ─────────────────────────────────────────────── // ── Study reminder scheduler ───────────────────────────────────────────────
export async function scheduleStudyReminders(sendStudyReminderEmail) { export async function scheduleStudyReminders(sendStudyReminderEmail) {
+32 -1
View File
@@ -67,7 +67,7 @@ export const BIBLE_BOOKS = [
{ id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] }, { id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] },
{ id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] }, { id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] },
{ id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] }, { id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] },
{ id: 'TIT', name: 'Titus' }, { id: 'TIT', name: 'Titus', aliases: ['tit'] },
{ id: 'PHM', name: 'Philemon', aliases: ['philem'] }, { id: 'PHM', name: 'Philemon', aliases: ['philem'] },
{ id: 'HEB', name: 'Hebrews', aliases: ['heb'] }, { id: 'HEB', name: 'Hebrews', aliases: ['heb'] },
{ id: 'JAM', name: 'James', aliases: ['jas'] }, { id: 'JAM', name: 'James', aliases: ['jas'] },
@@ -85,3 +85,34 @@ export function bookNumberFor(usfmId) {
const index = BIBLE_BOOKS.findIndex(b => b.id === usfmId) const index = BIBLE_BOOKS.findIndex(b => b.id === usfmId)
return index === -1 ? null : index + 1 return index === -1 ? null : index + 1
} }
let referenceBookLookup = null
/**
* Checks a passage reference the way the Scripture Index and passage fetch read it.
* `bookHint` is the study's Bible Book field (or slug) and is used when the reference
* has no book of its own ("1:1-2"). Returns null when it will work, otherwise a short
* message explaining what to change.
*/
export function checkPassageReference(reference, bookHint) {
if (!referenceBookLookup) {
referenceBookLookup = new Set()
for (const b of BIBLE_BOOKS) {
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
referenceBookLookup.add(key)
referenceBookLookup.add(key.replace(/\s+/g, ''))
}
}
}
const clean = String(reference ?? '').replace(/[–—]/g, '-').replace(/(\d)[a-z]\b/gi, '$1').trim()
if (!clean) return null
const m = clean.match(/^(?:(.*?)\s+)?(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/)
if (!m) return 'Use the form 1:1-2 (chapter:verse, optional range). It will be listed under "Other references" in the Scripture Index until fixed.'
const book = (m[1] ?? String(bookHint ?? '')).toLowerCase().replace(/\.\s*/g, ' ').trim()
if (!book || !referenceBookLookup.has(book)) {
return m[1]
? `"${m[1]}" is not a recognized Bible book name.`
: 'Set this study\'s "Bible Book" field (e.g. titus) so this reference can be matched to a book.'
}
return null
}
+14
View File
@@ -0,0 +1,14 @@
export type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general'
export interface NoteEntry {
id: string
anchor: NoteAnchorId
html: string
updatedAt: string | null
}
export const NOTE_ANCHORS: NoteAnchorId[]
export const NOTE_ANCHOR_LABELS: Record<NoteAnchorId, string>
export function newNoteId(): string
export function parseNoteEntries(raw: string): NoteEntry[]
export function serializeNoteEntries(entries: NoteEntry[]): string
export function noteHtmlToText(html: string): string
export function noteEntriesToText(raw: string): string
+79
View File
@@ -0,0 +1,79 @@
// Lesson note storage format, shared by the client and the server.
//
// Each lesson's notes are stored as one string. Current format (v2):
// {"v":2,"entries":[{"id","anchor","html","updatedAt"}]}
// Legacy format (v1) was a JSON object of { anchor: html }, and older still was
// plain text. Both are still read and are upgraded to v2 on the next save.
export const NOTE_ANCHORS = ['scripture', 'commentary', 'questions', 'general']
export const NOTE_ANCHOR_LABELS = {
scripture: 'Scripture Text',
commentary: 'Commentary',
questions: 'Reflection Questions',
general: 'General Notes',
}
export function newNoteId() {
return `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
}
export function parseNoteEntries(raw) {
if (typeof raw !== 'string' || !raw.trim()) return []
let parsed = null
try {
parsed = JSON.parse(raw)
} catch {
// Legacy plain-text note.
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
}
if (parsed.v === 2 && Array.isArray(parsed.entries)) {
return parsed.entries
.filter(e => e && typeof e.html === 'string' && e.html.trim())
.map(e => ({
id: typeof e.id === 'string' && e.id ? e.id : newNoteId(),
anchor: NOTE_ANCHORS.includes(e.anchor) ? e.anchor : 'general',
html: e.html,
updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : null,
}))
}
return Object.entries(parsed)
.filter(([, html]) => typeof html === 'string' && html.trim())
.map(([anchor, html]) => ({
id: `legacy-${anchor}`,
anchor: NOTE_ANCHORS.includes(anchor) ? anchor : 'general',
html,
updatedAt: null,
}))
}
export function serializeNoteEntries(entries) {
const kept = entries.filter(e => e.html && e.html.trim())
if (kept.length === 0) return ''
return JSON.stringify({ v: 2, entries: kept })
}
export function noteHtmlToText(html) {
return String(html)
.replace(/<\/(p|li|h[1-6]|blockquote)>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
// Plain-text rendering of a whole lesson's notes (used by the Word export).
export function noteEntriesToText(raw) {
return parseNoteEntries(raw)
.map(e => `${NOTE_ANCHOR_LABELS[e.anchor]}\n${noteHtmlToText(e.html)}`)
.join('\n\n')
}
+30 -1
View File
@@ -1,3 +1,4 @@
import { checkPassageReference } from '../shared/bible-books.js'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
@@ -616,6 +617,8 @@ export interface AdminStats {
topPaths: Array<{ path: string; hits: number }> topPaths: Array<{ path: string; hits: number }>
topPathsReal: Array<{ path: string; hits: number }> topPathsReal: Array<{ path: string; hits: number }>
topPathsBot: Array<{ path: string; hits: number }> topPathsBot: Array<{ path: string; hits: number }>
topUnknownPaths?: Array<{ path: string; hits: number }>
uniqueVisitors7d?: number
last7Days: Array<{ day: string; hits: number }> last7Days: Array<{ day: string; hits: number }>
last7DaysReal: Array<{ day: string; hits: number }> last7DaysReal: Array<{ day: string; hits: number }>
last7DaysBot: Array<{ day: string; hits: number }> last7DaysBot: Array<{ day: string; hits: number }>
@@ -881,6 +884,7 @@ const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
{ id: 'global-header', label: 'Header' }, { id: 'global-header', label: 'Header' },
{ id: 'global-platforms', label: 'Platform URLs' }, { id: 'global-platforms', label: 'Platform URLs' },
{ id: 'global-banner', label: 'Analytics Banner' }, { id: 'global-banner', label: 'Analytics Banner' },
{ id: 'global-verse', label: 'Verse of the Week' },
{ id: 'global-welcome-email', label: 'Welcome Email' }, { id: 'global-welcome-email', label: 'Welcome Email' },
], ],
'start-here': [ 'start-here': [
@@ -1024,6 +1028,7 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
{ key: 'platformCreatorUrl', label: 'Platform — Creator Profile URL', section: 'global' }, { key: 'platformCreatorUrl', label: 'Platform — Creator Profile URL', section: 'global' },
{ key: 'platformKofiUrl', label: 'Platform — Ko-fi URL', section: 'global' }, { key: 'platformKofiUrl', label: 'Platform — Ko-fi URL', section: 'global' },
{ key: 'platformKofiQrUrl', label: 'Platform — Ko-fi QR Code Image URL', section: 'global' }, { key: 'platformKofiQrUrl', label: 'Platform — Ko-fi QR Code Image URL', section: 'global' },
{ key: 'verseOfTheWeek', label: 'Scripture Index — Verse of the Week (optional)', section: 'global' },
{ key: 'welcomeEmailSubject', label: 'Welcome Email — Subject', section: 'global' }, { key: 'welcomeEmailSubject', label: 'Welcome Email — Subject', section: 'global' },
{ key: 'welcomeEmailGreetingPrefix', label: 'Welcome Email — Greeting Prefix', section: 'global' }, { key: 'welcomeEmailGreetingPrefix', label: 'Welcome Email — Greeting Prefix', section: 'global' },
{ key: 'welcomeEmailIntro', label: 'Welcome Email — Intro Paragraph', multiline: true, section: 'global' }, { key: 'welcomeEmailIntro', label: 'Welcome Email — Intro Paragraph', multiline: true, section: 'global' },
@@ -2422,6 +2427,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [commentsLoaded, setCommentsLoaded] = useState(false) const [commentsLoaded, setCommentsLoaded] = useState(false)
const [commentFilter, setCommentFilter] = useState<'all' | 'pending' | 'approved'>('all') const [commentFilter, setCommentFilter] = useState<'all' | 'pending' | 'approved'>('all')
const [commentSearch, setCommentSearch] = useState('') const [commentSearch, setCommentSearch] = useState('')
// Load moderation queue as soon as the section is opened; a failed load leaves the manual button available
const commentsAutoTried = useRef(false)
useEffect(() => {
if (adminView !== 'study-comments' || commentsLoaded || commentsAutoTried.current) return
commentsAutoTried.current = true
void loadStudyComments()
}, [adminView, commentsLoaded])
// Episode scripts state // Episode scripts state
const [scriptsList, setScriptsList] = useState<Array<{ key: string; episodeNumber: string; title: string; filename: string; season: string | null; uploadedAt: string | null; wordCount: number }>>([]) const [scriptsList, setScriptsList] = useState<Array<{ key: string; episodeNumber: string; title: string; filename: string; season: string | null; uploadedAt: string | null; wordCount: number }>>([])
@@ -4130,7 +4142,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<div className="admin-dashboard-card"> <div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div> <div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
<div className="admin-dashboard-card-label">Real Visits (7 Days)</div> <div className="admin-dashboard-card-label">Real Visits (7 Days)</div>
<div className="admin-dashboard-card-sub">{thisWeekHits.toLocaleString()} total · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} unique all time</div> <div className="admin-dashboard-card-sub">{thisWeekHits.toLocaleString()} total · {stats?.uniqueVisitors7d?.toLocaleString() ?? '—'} unique this week · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} all time (consented)</div>
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('analytics')}>Full Analytics →</button> <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('analytics')}>Full Analytics →</button>
</div> </div>
<div className="admin-dashboard-card"> <div className="admin-dashboard-card">
@@ -6294,6 +6306,10 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<div className="admin-field"> <div className="admin-field">
<label htmlFor={`sl-reference-${activeLesson.id}`}>Passage Reference</label> <label htmlFor={`sl-reference-${activeLesson.id}`}>Passage Reference</label>
<input id={`sl-reference-${activeLesson.id}`} type="text" value={activeLesson.reference} placeholder="1:1-2" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'reference', e.target.value)} /> <input id={`sl-reference-${activeLesson.id}`} type="text" value={activeLesson.reference} placeholder="1:1-2" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'reference', e.target.value)} />
{(() => {
const refProblem = checkPassageReference(activeLesson.reference, activeStudy.bibleBook?.trim() || activeStudy.slug)
return refProblem ? <p className="admin-field-warning" role="alert" style={{ color: '#e0a030', fontSize: '0.85rem', margin: '0.35rem 0 0' }}>⚠ {refProblem}</p> : null
})()}
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label htmlFor={`sl-title-${activeLesson.id}`}>Section Title</label> <label htmlFor={`sl-title-${activeLesson.id}`}>Section Title</label>
@@ -6422,6 +6438,19 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
</div> </div>
))} ))}
</>}
{tabOf('global', 'global-footer') === 'global-verse' && <>
<div className="admin-panel-subhead" id="global-verse"><h3>Verse of the Week</h3></div>
<div className="admin-field">
<label htmlFor="field-verseOfTheWeek">Verse of the Week (optional)</label>
<input id="field-verseOfTheWeek" type="text" placeholder="Titus 2:11-12" value={(form.verseOfTheWeek as string) ?? ''} onChange={e => handleChange('verseOfTheWeek', e.target.value)} />
<p className="admin-field-hint">Shown at the top of the Scripture Index. Leave blank and the site picks a different covered passage each week automatically. Use a full reference with the book name.</p>
{(() => {
const vowProblem = checkPassageReference(String(form.verseOfTheWeek ?? ''), '')
return vowProblem ? <p className="admin-field-warning" role="alert" style={{ color: '#e0a030', fontSize: '0.85rem', margin: '0.35rem 0 0' }}>⚠ {vowProblem}</p> : null
})()}
</div>
</>} </>}
{tabOf('global', 'global-footer') === 'global-welcome-email' && <> {tabOf('global', 'global-footer') === 'global-welcome-email' && <>
<div className="admin-panel-subhead" id="global-welcome-email"><h3>Welcome Email</h3></div> <div className="admin-panel-subhead" id="global-welcome-email"><h3>Welcome Email</h3></div>
+58 -1
View File
@@ -13275,7 +13275,20 @@ a.study-track-card:hover {
} }
.header-nav--open { .header-nav--open {
max-height: 660px; max-height: calc(100dvh - var(--site-header-h, 64px));
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.nav-dropdown-label {
cursor: pointer;
}
.nav-dropdown-caret {
min-width: 44px;
min-height: 44px;
margin: -0.8rem 0;
font-size: 0.9em;
} }
.nav-dropdown-trigger { .nav-dropdown-trigger {
@@ -13718,6 +13731,31 @@ a.study-track-card:hover {
.si-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .si-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.si-verse-btn { margin-left: auto; font: inherit; font-size: 0.8rem; background: none; border: 0; padding: 0; cursor: pointer; color: var(--brand-muted); text-decoration: underline; } .si-verse-btn { margin-left: auto; font: inherit; font-size: 0.8rem; background: none; border: 0; padding: 0; cursor: pointer; color: var(--brand-muted); text-decoration: underline; }
.si-verse-btn:hover { color: var(--brand-gold); } .si-verse-btn:hover { color: var(--brand-gold); }
.si-vow { margin-bottom: 1rem; padding: 1.1rem 1.25rem; border: 1px solid var(--brand-gold-dark); border-radius: 12px; background: radial-gradient(ellipse at top left, rgba(201, 168, 76, 0.12), transparent 70%), var(--brand-black-3); }
.si-vow-eyebrow { margin: 0 0 0.25rem; font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--brand-gold); }
.si-vow-ref { margin: 0 0 0.5rem; font-family: var(--brand-font-heading); font-size: 1.6rem; font-weight: 600; line-height: 1.15; }
.si-vow-text { margin: 0 0 0.75rem; font-style: italic; line-height: 1.65; color: var(--brand-muted); }
.si-top { margin-bottom: 1rem; padding: 0.9rem 1rem; background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 12px; }
.si-top-title { margin: 0 0 0.6rem; font-family: var(--brand-font-heading); font-size: 1.15rem; font-weight: 600; }
.si-top-list { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.si-top-item { display: inline-flex; align-items: baseline; gap: 0.5rem; font: inherit; font-size: 0.88rem; padding: 0.35rem 0.8rem; border-radius: 999px; border: 1px solid var(--brand-border); background: transparent; color: var(--brand-warm-white); cursor: pointer; }
.si-top-item:hover { border-color: var(--brand-gold-dark); }
.si-top-ref { font-weight: 700; color: var(--brand-gold-light); }
.si-top-count { font-size: 0.75rem; color: var(--brand-muted); }
.si-print-title { display: none; }
@media print {
.site > *:not(.si-main) { display: none !important; }
.si-main { padding: 0; }
.si-main, .si-group, .si-top { background: #fff !important; color: #000 !important; }
.si-print-title { display: block; font-size: 1.6rem; margin: 0 0 1rem; color: #000; }
.si-toolbar, .si-books, .si-meta, .si-top, .si-vow, .si-verse-btn, .si-verse, .si-chev { display: none !important; }
.si-group { border: 0; border-bottom: 1px solid #999; break-inside: avoid-page; border-radius: 0; }
.si-group-head { padding: 0.5rem 0; color: #000; }
.si-ref { color: #000; }
.si-chip { border: 0; padding: 0; color: #000; }
.si-chip-kind { color: #444; }
.si-chip-label { white-space: normal; overflow: visible; }
}
.si-verse { margin: 0.5rem 0 0; padding-left: 0.9rem; border-left: 2px solid var(--brand-gold-dark); font-size: 0.95rem; line-height: 1.65; font-style: italic; color: var(--brand-muted); } .si-verse { margin: 0.5rem 0 0; padding-left: 0.9rem; border-left: 2px solid var(--brand-gold-dark); font-size: 0.95rem; line-height: 1.65; font-style: italic; color: var(--brand-muted); }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -14162,3 +14200,22 @@ a.sl-pager-link:hover { border-color: rgba(201, 168, 76, 0.6); transform: transl
.notes-reader-meta { margin: 0.25rem 0 1rem; font-size: 0.82rem; color: #7a7060; } .notes-reader-meta { margin: 0.25rem 0 1rem; font-size: 0.82rem; color: #7a7060; }
.notes-copy-btn { background: none; border: 0; padding: 0; color: #c9a84c; cursor: pointer; font: inherit; text-decoration: underline; } .notes-copy-btn { background: none; border: 0; padding: 0; color: #c9a84c; cursor: pointer; font: inherit; text-decoration: underline; }
.notes-copy-btn:hover { color: #e3c46a; } .notes-copy-btn:hover { color: #e3c46a; }
.notes-other-matches { margin-top: 1rem; }
.notes-export-btn { margin-top: 1rem; width: 100%; }
.note-card-footer { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; }
.note-card-delete { background: none; border: 0; padding: 0; color: #b5654f; cursor: pointer; font: inherit; }
.note-card-delete:hover { text-decoration: underline; }
.note-entry-list { list-style: none; margin: 0 0 0.75rem; padding: 0; display: grid; gap: 0.4rem; }
.note-entry { width: 100%; text-align: left; background: rgba(201, 168, 76, 0.06); border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 8px; padding: 0.45rem 0.65rem; cursor: pointer; color: inherit; font: inherit; display: grid; gap: 0.1rem; }
.note-entry:hover, .note-entry--active { border-color: #c9a84c; background: rgba(201, 168, 76, 0.14); }
.note-entry-label { font-size: 0.72rem; color: #c9a84c; text-transform: uppercase; letter-spacing: 0.04em; }
.note-entry-snippet { font-size: 0.85rem; color: #b8ad92; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.note-add-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: 0.75rem; font-size: 0.8rem; color: #7a7060; }
.note-add-chip { background: none; border: 1px solid rgba(201, 168, 76, 0.35); border-radius: 999px; padding: 0.2rem 0.6rem; color: #c9a84c; cursor: pointer; font: inherit; font-size: 0.78rem; }
.note-add-chip:hover { background: rgba(201, 168, 76, 0.12); }
.notes-sort { display: flex; gap: 0.4rem; margin: 0.6rem 0; }
.notes-sort-btn { flex: 1; background: none; border: 1px solid rgba(201, 168, 76, 0.25); border-radius: 6px; padding: 0.3rem 0.4rem; color: #b8ad92; cursor: pointer; font: inherit; font-size: 0.78rem; }
.notes-sort-btn--active { border-color: #c9a84c; color: #c9a84c; background: rgba(201, 168, 76, 0.1); }
.notes-block-date { font-weight: 400; color: #7a7060; font-size: 0.75rem; }
+85 -8
View File
@@ -472,7 +472,7 @@ function SiteHeader({ content }: { content: SiteContent }) {
{/* Episodes dropdown */} {/* Episodes dropdown */}
<div ref={dropdownRef} className={`nav-dropdown${episodesOpen ? ' nav-dropdown--open' : ''}`}> <div ref={dropdownRef} className={`nav-dropdown${episodesOpen ? ' nav-dropdown--open' : ''}`}>
<div className="nav-dropdown-trigger"> <div className="nav-dropdown-trigger">
<NavLink to="/episodes" className={`nav-dropdown-label${episodesActive ? ' header-nav-link--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Episodes</NavLink> <NavLink to="/episodes" className={`nav-dropdown-label${episodesActive ? ' header-nav-link--active' : ''}`} onClick={e => { if (window.matchMedia('(max-width: 900px)').matches) { e.preventDefault(); setEpisodesOpen(o => !o) } else { setMenuOpen(false); setEpisodesOpen(false) } }}>Episodes</NavLink>
<button type="button" className="nav-dropdown-caret" onClick={() => setEpisodesOpen(o => !o)} aria-expanded={episodesOpen} aria-label="Episodes menu"> <button type="button" className="nav-dropdown-caret" onClick={() => setEpisodesOpen(o => !o)} aria-expanded={episodesOpen} aria-label="Episodes menu">
<span aria-hidden="true">{episodesOpen ? '▴' : '▾'}</span> <span aria-hidden="true">{episodesOpen ? '▴' : '▾'}</span>
</button> </button>
@@ -489,7 +489,7 @@ function SiteHeader({ content }: { content: SiteContent }) {
{/* Studies dropdown */} {/* Studies dropdown */}
<div ref={studiesDropdownRef} className={`nav-dropdown${studiesOpen ? ' nav-dropdown--open' : ''}`}> <div ref={studiesDropdownRef} className={`nav-dropdown${studiesOpen ? ' nav-dropdown--open' : ''}`}>
<div className="nav-dropdown-trigger"> <div className="nav-dropdown-trigger">
<NavLink to="/study" className={`nav-dropdown-label${studiesActive ? ' header-nav-link--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Studies</NavLink> <NavLink to="/study" className={`nav-dropdown-label${studiesActive ? ' header-nav-link--active' : ''}`} onClick={e => { if (window.matchMedia('(max-width: 900px)').matches) { e.preventDefault(); setStudiesOpen(o => !o) } else { setMenuOpen(false); setStudiesOpen(false) } }}>Studies</NavLink>
<button type="button" className="nav-dropdown-caret" onClick={() => setStudiesOpen(o => !o)} aria-expanded={studiesOpen} aria-label="Studies menu"> <button type="button" className="nav-dropdown-caret" onClick={() => setStudiesOpen(o => !o)} aria-expanded={studiesOpen} aria-label="Studies menu">
<span aria-hidden="true">{studiesOpen ? '▴' : '▾'}</span> <span aria-hidden="true">{studiesOpen ? '▴' : '▾'}</span>
</button> </button>
@@ -662,7 +662,7 @@ function KofiSupportSection({ content }: { content: SiteContent }) {
</div> </div>
{qrUrl && ( {qrUrl && (
<figure className="cx-support-qr"> <figure className="cx-support-qr">
<img src={qrUrl} alt="Ko-fi donation QR code" /> <img src={qrUrl} alt="Ko-fi donation QR code" onError={e => { const fig = e.currentTarget.parentElement; if (fig) fig.style.display = 'none' }} />
<figcaption>Scan to give on Ko-fi</figcaption> <figcaption>Scan to give on Ko-fi</figcaption>
</figure> </figure>
)} )}
@@ -829,7 +829,8 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
{ key: 'primary', title: content.seriesTitle, links: links.filter(link => laneOf(link) === 'primary') }, { key: 'primary', title: content.seriesTitle, links: links.filter(link => laneOf(link) === 'primary') },
{ key: 'second', title: content.secondSeries?.title?.trim() || 'Second Series', links: links.filter(link => laneOf(link) === 'second') }, { key: 'second', title: content.secondSeries?.title?.trim() || 'Second Series', links: links.filter(link => laneOf(link) === 'second') },
{ key: 'general', title: 'More Highlights', links: links.filter(link => laneOf(link) === 'general') }, { key: 'general', title: 'More Highlights', links: links.filter(link => laneOf(link) === 'general') },
].filter(group => group.links.length > 0) ].filter(group => group.key === 'primary' || group.links.length > 0)
// Always open on the current series, even if it has no highlights yet
const active = groups.find(group => group.key === activeKey) ?? groups[0] const active = groups.find(group => group.key === activeKey) ?? groups[0]
const isExpanded = expanded[active.key] === true const isExpanded = expanded[active.key] === true
const shown = isExpanded ? active.links : active.links.slice(0, HIGHLIGHTS_PREVIEW_COUNT) const shown = isExpanded ? active.links : active.links.slice(0, HIGHLIGHTS_PREVIEW_COUNT)
@@ -858,6 +859,7 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
</div> </div>
)} )}
<div className="episode-list"> <div className="episode-list">
{shown.length === 0 && <p className="finished-books-empty">No highlights for this series yet.</p>}
{shown.map(link => <HighlightCard key={link.id} link={link} />)} {shown.map(link => <HighlightCard key={link.id} link={link} />)}
</div> </div>
{active.links.length > HIGHLIGHTS_PREVIEW_COUNT && ( {active.links.length > HIGHLIGHTS_PREVIEW_COUNT && (
@@ -2239,7 +2241,9 @@ function EpisodesPage({ content }: { content: SiteContent }) {
) )
const { episodes, loading } = useAllEpisodes() const { episodes, loading } = useAllEpisodes()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [season, setSeason] = useState('all') // Until a chip is picked, browse the current series' season (all episodes if none is set)
const [pickedSeason, setSeason] = useState<string | null>(null)
const season = pickedSeason ?? (content.seriesSeason ? String(content.seriesSeason) : 'all')
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [openKey, setOpenKey] = useState<string | null>(null) const [openKey, setOpenKey] = useState<string | null>(null)
@@ -2655,7 +2659,9 @@ for (const b of BIBLE_BOOKS) {
function parseSIRef(ref: string) { function parseSIRef(ref: string) {
// Supports same-chapter ranges ("5:15-20") and chapter-spanning ranges ("5:15-6:2"). // Supports same-chapter ranges ("5:15-20") and chapter-spanning ranges ("5:15-6:2").
const m = ref.match(/^(.*?)\s+(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/) // Tolerate en/em dashes and verse-part suffixes like "10-12a"
const clean = ref.replace(/[\u2013\u2014]/g, '-').replace(/(\d)[a-z]\b/gi, '$1').trim()
const m = clean.match(/^(.*?)\s+(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/)
if (!m) return null if (!m) return null
const bookId = SI_BOOK_MAP[m[1].toLowerCase().replace(/\.\s*/g, ' ').trim()] const bookId = SI_BOOK_MAP[m[1].toLowerCase().replace(/\.\s*/g, ' ').trim()]
if (!bookId) return null if (!bookId) return null
@@ -2733,7 +2739,7 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
// Merge entries that share the same reference (a study lesson and an episode can both // Merge entries that share the same reference (a study lesson and an episode can both
// cover the same passage) into one row, then group rows by book in canonical Bible order. // cover the same passage) into one row, then group rows by book in canonical Bible order.
const { bookGroups, otherRows } = useMemo(() => { const { bookGroups, otherRows, topRows } = useMemo(() => {
const rowMap = new Map<string, ScriptureRow>() const rowMap = new Map<string, ScriptureRow>()
const order: string[] = [] const order: string[] = []
for (const entry of entries) { for (const entry of entries) {
@@ -2787,7 +2793,12 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
rows: groupsByBook.get(id)!.sort((a, b) => a.chapter - b.chapter || a.verseStart - b.verseStart), rows: groupsByBook.get(id)!.sort((a, b) => a.chapter - b.chapter || a.verseStart - b.verseStart),
})) }))
other.sort((a, b) => a.displayRef.localeCompare(b.displayRef)) other.sort((a, b) => a.displayRef.localeCompare(b.displayRef))
return { bookGroups: groups, otherRows: other } // Passages covered by the most lessons/episodes (needs at least two sources to be worth highlighting)
const top = [...rowMap.values()]
.filter(r => r.bookId && r.sources.length >= 2)
.sort((a, b) => b.sources.length - a.sources.length || order.indexOf(a.key) - order.indexOf(b.key))
.slice(0, 6)
return { bookGroups: groups, otherRows: other, topRows: top }
}, [entries]) }, [entries])
// Apply the search box and filters // Apply the search box and filters
@@ -2831,6 +2842,38 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
return promise return promise
} }
// Verse of the week: the admin's pick if set, otherwise rotate through covered passages by calendar week
const [weekIndex] = useState(() => Math.floor(Date.now() / (7 * 24 * 60 * 60 * 1000)))
const verseOfWeek = useMemo(() => {
const override = content.verseOfTheWeek?.trim()
const parsed = override ? parseSIRef(override) : null
if (parsed) {
const key = `${parsed.bookId}-${parsed.chapter}-${parsed.verseStart}-${parsed.endChapter}-${parsed.verseEnd}`
const existing = bookGroups.flatMap(g => g.rows).find(r => r.key === key)
return existing ?? {
key,
displayRef: `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? `-${parsed.verseEnd}` : ''}`,
bookId: parsed.bookId, chapter: parsed.chapter, endChapter: parsed.endChapter,
verseStart: parsed.verseStart, verseEnd: parsed.verseEnd, sources: [],
} as ScriptureRow
}
const pool = bookGroups.flatMap(g => g.rows)
if (pool.length === 0) return null
return pool[weekIndex % pool.length]
}, [bookGroups, content.verseOfTheWeek, weekIndex])
const [votText, setVotText] = useState<string | null>(null)
const votKey = verseOfWeek?.key
useEffect(() => {
if (!verseOfWeek || !verseOfWeek.bookId) return
let cancelled = false
const verseEnd = verseOfWeek.endChapter !== verseOfWeek.chapter ? Number.MAX_SAFE_INTEGER : verseOfWeek.verseEnd
fetchChapter(verseOfWeek.bookId, verseOfWeek.chapter)
.then(ch => { if (!cancelled) setVotText(extractVerseRange(ch, verseOfWeek.verseStart, verseEnd) || null) })
.catch(() => { if (!cancelled) setVotText(null) })
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [votKey])
function toggleVerse(row: ScriptureRow) { function toggleVerse(row: ScriptureRow) {
const open = shownVerses.has(row.key) const open = shownVerses.has(row.key)
setShownVerses(prev => { setShownVerses(prev => {
@@ -2884,6 +2927,7 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
<PageBanner eyebrow="Reference" title="Scripture Index" desc="Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse." /> <PageBanner eyebrow="Reference" title="Scripture Index" desc="Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse." />
<main className="si-main"> <main className="si-main">
<div className="section-inner"> <div className="section-inner">
<h1 className="si-print-title">Scripture Index — {siteTitle}</h1>
{loading ? ( {loading ? (
<p className="si-note">Loading…</p> <p className="si-note">Loading…</p>
) : !hasContent ? ( ) : !hasContent ? (
@@ -2913,6 +2957,38 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
</div> </div>
</div> </div>
{!filtering && verseOfWeek && (
<div className="si-vow">
<p className="si-vow-eyebrow">Verse of the week</p>
<h2 className="si-vow-ref">{verseOfWeek.displayRef}</h2>
{votText && <p className="si-vow-text">“{votText}”</p>}
{verseOfWeek.sources.length > 0 && (
<div className="si-sources">
{verseOfWeek.sources.slice(0, 3).map((src, i) => (
<Link key={i} to={src.url} className={`si-chip si-chip--${src.type}`}>
<span className="si-chip-kind">{src.type === 'episode' ? 'Episode' : 'Study'}</span>
<span className="si-chip-label">{src.label}</span>
</Link>
))}
</div>
)}
</div>
)}
{!filtering && topRows.length > 0 && (
<div className="si-top" aria-label="Most-studied passages">
<h2 className="si-top-title">Most-studied passages</h2>
<div className="si-top-list">
{topRows.map(r => (
<button key={r.key} type="button" className="si-top-item" onClick={() => setQuery(r.displayRef)}>
<span className="si-top-ref">{r.displayRef}</span>
<span className="si-top-count">{r.sources.length} sources</span>
</button>
))}
</div>
</div>
)}
{bookGroups.length > 1 && ( {bookGroups.length > 1 && (
<div className="si-books" role="group" aria-label="Books covered"> <div className="si-books" role="group" aria-label="Books covered">
{bookGroups.map(g => ( {bookGroups.map(g => (
@@ -2936,6 +3012,7 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
)} )}
<button type="button" className="si-link" onClick={() => setAll(true)}>Expand all</button> <button type="button" className="si-link" onClick={() => setAll(true)}>Expand all</button>
<button type="button" className="si-link" onClick={() => setAll(false)}>Collapse all</button> <button type="button" className="si-link" onClick={() => setAll(false)}>Collapse all</button>
<button type="button" className="si-link" onClick={() => { setAll(true); window.setTimeout(() => window.print(), 150) }}>Print index</button>
</span> </span>
</div> </div>
+18 -1
View File
@@ -308,7 +308,7 @@ export function AnalyticsPanel({
<article> <article>
<h3>Unique Visitors</h3> <h3>Unique Visitors</h3>
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p> <p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
<small>Tracked with consent</small> <small>{stats.uniqueVisitors7d != null ? `${stats.uniqueVisitors7d.toLocaleString()} this week · ` : ''}Tracked with consent</small>
</article> </article>
<article> <article>
<h3>Returning Rate</h3> <h3>Returning Rate</h3>
@@ -399,6 +399,23 @@ export function AnalyticsPanel({
)} )}
</div> </div>
</div> </div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Probed URLs (not real pages)</h3>
{(stats.topUnknownPaths ?? []).length === 0 ? (
<p className="admin-stats-note">No probe traffic recorded yet. Requests for URLs that are not site pages (like /.env or /wp-login) are listed here and counted as bots.</p>
) : (
<ul className="admin-bot-reason-list">
{(stats.topUnknownPaths ?? []).map(row => (
<li key={row.path} className="admin-bot-reason-item">
<span style={{ wordBreak: 'break-all' }}>{row.path}</span>
<strong>{row.hits.toLocaleString()}</strong>
</li>
))}
</ul>
)}
</div>
</div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}> <div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide"> <div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Top Pages — Real vs Bot ({rangeLabel})</h3> <h3 className="admin-analytics-card-title">Top Pages — Real vs Bot ({rangeLabel})</h3>
+16 -2
View File
@@ -5,12 +5,14 @@ import { useEffect } from 'react'
interface NoteCardProps { interface NoteCardProps {
anchorLabel: string anchorLabel: string
html: string html: string
updatedAt?: string | null
onDelete?: () => void
autoSaveMsg?: string autoSaveMsg?: string
onChange: (html: string) => void onChange: (html: string) => void
onClose: () => void onClose: () => void
} }
export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }: NoteCardProps) { export function NoteCard({ anchorLabel, html, updatedAt, onDelete, autoSaveMsg, onChange, onClose }: NoteCardProps) {
const editor = useEditor({ const editor = useEditor({
extensions: [StarterKit], extensions: [StarterKit],
shouldRerenderOnTransaction: true, shouldRerenderOnTransaction: true,
@@ -115,7 +117,19 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
</div> </div>
<EditorContent editor={editor} className="note-card-editor" /> <EditorContent editor={editor} className="note-card-editor" />
<div className="note-card-footer">{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}</div> <div className="note-card-footer">
{onDelete && (
<button
type="button"
className="note-card-delete"
onClick={() => { if (window.confirm('Delete this note?')) onDelete() }}
>Delete note</button>
)}
<span>
{updatedAt ? `Edited ${new Date(updatedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · ` : ''}
{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}
</span>
</div>
</div> </div>
) )
} }
+2
View File
@@ -319,6 +319,7 @@ export interface SiteContent {
platformCreatorUrl: string platformCreatorUrl: string
platformKofiUrl?: string platformKofiUrl?: string
platformKofiQrUrl?: string platformKofiQrUrl?: string
verseOfTheWeek?: string
// ── Welcome Email ── // ── Welcome Email ──
welcomeEmailSubject: string welcomeEmailSubject: string
welcomeEmailGreetingPrefix: string welcomeEmailGreetingPrefix: string
@@ -571,6 +572,7 @@ export const DEFAULTS: SiteContent = {
platformCreatorUrl: 'https://creators.spotify.com/pod/profile/nmemmert/', platformCreatorUrl: 'https://creators.spotify.com/pod/profile/nmemmert/',
platformKofiUrl: 'https://ko-fi.com/versebyversewithnate', platformKofiUrl: 'https://ko-fi.com/versebyversewithnate',
platformKofiQrUrl: '/images/kofi-qr.png', platformKofiQrUrl: '/images/kofi-qr.png',
verseOfTheWeek: '',
welcomeEmailSubject: 'Welcome to Verse by Verse with Nate', welcomeEmailSubject: 'Welcome to Verse by Verse with Nate',
welcomeEmailGreetingPrefix: 'Glad you\'re here', welcomeEmailGreetingPrefix: 'Glad you\'re here',
welcomeEmailIntro: 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.', welcomeEmailIntro: 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.',
+178 -76
View File
@@ -9,6 +9,8 @@ import { Breadcrumbs } from './components/Breadcrumbs'
import { StudyCertificate } from './components/StudyCertificate' import { StudyCertificate } from './components/StudyCertificate'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer' import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NoteCard } from './components/NoteCard' import { NoteCard } from './components/NoteCard'
import { NOTE_ANCHORS, NOTE_ANCHOR_LABELS, newNoteId, parseNoteEntries, serializeNoteEntries, noteHtmlToText } from '../shared/note-format.js'
import type { NoteAnchorId, NoteEntry } from '../shared/note-format.js'
import { StudySectionComments } from './components/StudySectionComments' import { StudySectionComments } from './components/StudySectionComments'
type Props = { content: SiteContent } type Props = { content: SiteContent }
@@ -240,31 +242,16 @@ function getReleaseDateDisplay(section: StudySection): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} }
function isFutureDate(iso: string) {
return new Date(iso).getTime() > Date.now()
}
function getNoteId(studySlug: string, sectionId: string) { function getNoteId(studySlug: string, sectionId: string) {
return `${studySlug}--${sectionId}` return `${studySlug}--${sectionId}`
} }
type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general' const ANCHOR_LABELS: Record<string, string> = NOTE_ANCHOR_LABELS
const ANCHOR_LABELS: Record<string, string> = {
scripture: 'Scripture Text',
commentary: 'Commentary',
questions: 'Reflection Questions',
general: 'General Notes',
}
function parseNoteMap(raw: string): Record<string, string> {
if (!raw?.trim()) return {}
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, string>
}
} catch {
// Not valid JSON — treat the raw string as legacy plain-text notes.
}
return { general: raw }
}
function getDisplayName(auth: StudyAuthState) { function getDisplayName(auth: StudyAuthState) {
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student' return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
@@ -1020,7 +1007,7 @@ export function StudyLandingPage({ content }: Props) {
<ul className="study-track-facts"> <ul className="study-track-facts">
{variant === 'planned' ? ( {variant === 'planned' ? (
<li> <li>
{study.startsAt && new Date(study.startsAt).getTime() > Date.now() {study.startsAt && isFutureDate(study.startsAt)
? `Starts ${new Date(study.startsAt).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}` ? `Starts ${new Date(study.startsAt).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}`
: 'Preparing lesson structure and media'} : 'Preparing lesson structure and media'}
</li> </li>
@@ -1862,8 +1849,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [enrollBusy, setEnrollBusy] = useState(false) const [enrollBusy, setEnrollBusy] = useState(false)
const [enrollMessage, setEnrollMessage] = useState('') const [enrollMessage, setEnrollMessage] = useState('')
const [noteMap, setNoteMap] = useState<Record<string, string>>({}) const [noteEntries, setNoteEntries] = useState<NoteEntry[]>([])
const [openAnchor, setOpenAnchor] = useState<NoteAnchorId | null>(null) const [activeNoteId, setActiveNoteId] = useState<string | null>(null)
const [noteLoading, setNoteLoading] = useState(false) const [noteLoading, setNoteLoading] = useState(false)
const [noteMessage, setNoteMessage] = useState('') const [noteMessage, setNoteMessage] = useState('')
const noteMapDirtyRef = useRef(false) const noteMapDirtyRef = useRef(false)
@@ -1882,12 +1869,13 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [bsbPassage, setBsbPassage] = useState<string | null>(null) const [bsbPassage, setBsbPassage] = useState<string | null>(null)
const [bsbPassageLoading, setBsbPassageLoading] = useState(false) const [bsbPassageLoading, setBsbPassageLoading] = useState(false)
const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length const activeEntry = noteEntries.find(e => e.id === activeNoteId) ?? null
const lessonAudioEmbedUrl = useMemo(() => { const openAnchor: NoteAnchorId | null = activeEntry?.anchor ?? null
const trimmed = section?.audioEmbedUrl?.trim() ?? '' const savedEntries = noteEntries.filter(e => e.html.trim())
if (!trimmed) return '' const savedNoteCount = savedEntries.length
return /^https?:\/\//i.test(trimmed) ? trimmed : '' const hasNoteOn = (anchor: NoteAnchorId) => savedEntries.some(e => e.anchor === anchor)
}, [section?.audioEmbedUrl]) const audioEmbedTrimmed = section?.audioEmbedUrl?.trim() ?? ''
const lessonAudioEmbedUrl = /^https?:\/\//i.test(audioEmbedTrimmed) ? audioEmbedTrimmed : ''
const currentStudySlug = study?.slug ?? 'colossians' const currentStudySlug = study?.slug ?? 'colossians'
const noteId = section?.id ? getNoteId(currentStudySlug, section.id) : '' const noteId = section?.id ? getNoteId(currentStudySlug, section.id) : ''
@@ -1968,7 +1956,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
useEffect(() => { useEffect(() => {
if (!auth.authenticated || !noteId || !isEnrolled) { if (!auth.authenticated || !noteId || !isEnrolled) {
setNoteMap({}) setNoteEntries([])
setActiveNoteId(null)
noteMapDirtyRef.current = false noteMapDirtyRef.current = false
return return
} }
@@ -1980,7 +1969,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`) readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
.then(data => { .then(data => {
if (cancelled) return if (cancelled) return
setNoteMap(parseNoteMap(data.note ?? '')) setNoteEntries(parseNoteEntries(data.note ?? ''))
setActiveNoteId(null)
noteMapDirtyRef.current = false noteMapDirtyRef.current = false
}) })
.catch(err => { .catch(err => {
@@ -2024,16 +2014,39 @@ export function ColossiansStudySectionPage({ content }: Props) {
} }
} }
function updateNote(anchor: NoteAnchorId, html: string) { function updateNoteEntry(id: string, html: string) {
noteMapDirtyRef.current = true noteMapDirtyRef.current = true
setNoteMap(prev => ({ ...prev, [anchor]: html })) setNoteEntries(prev => prev.map(e => e.id === id ? { ...e, html, updatedAt: new Date().toISOString() } : e))
}
function addNoteEntry(anchor: NoteAnchorId, html = '') {
const entry: NoteEntry = { id: newNoteId(), anchor, html, updatedAt: html ? new Date().toISOString() : null }
if (html) noteMapDirtyRef.current = true
setNoteEntries(prev => [...prev, entry])
setActiveNoteId(entry.id)
}
function deleteNoteEntry(id: string) {
noteMapDirtyRef.current = true
setNoteEntries(prev => prev.filter(e => e.id !== id))
setActiveNoteId(null)
}
function toggleAnchorNote(anchor: NoteAnchorId) {
if (openAnchor === anchor) {
setActiveNoteId(null)
return
}
const existing = [...noteEntries].reverse().find(e => e.anchor === anchor)
if (existing) setActiveNoteId(existing.id)
else addNoteEntry(anchor)
} }
useEffect(() => { useEffect(() => {
if (!openAnchor) return if (!openAnchor) return
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpenAnchor(null) if (e.key === 'Escape') setActiveNoteId(null)
} }
document.addEventListener('keydown', onKeyDown) document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown)
@@ -2049,7 +2062,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
await readJson<{ ok: boolean }>(`/api/study-notes/${encodeURIComponent(noteId)}`, { await readJson<{ ok: boolean }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: JSON.stringify(noteMap) }), body: JSON.stringify({ note: serializeNoteEntries(noteEntries) }),
}) })
setNoteMessage('') setNoteMessage('')
setAutoSaveMsg('Auto-saved.') setAutoSaveMsg('Auto-saved.')
@@ -2060,7 +2073,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
} }
}, 2000) }, 2000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [noteMap, noteId, auth.authenticated, noteLoading]) }, [noteEntries, noteId, auth.authenticated, noteLoading])
useEffect(() => { useEffect(() => {
if (!auth.authenticated || !isEnrolled || !study || !section?.id) { if (!auth.authenticated || !isEnrolled || !study || !section?.id) {
@@ -2111,12 +2124,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
function appendSelectedTextToNotes(selectedText: string, anchor: NoteAnchorId = 'general') { function appendSelectedTextToNotes(selectedText: string, anchor: NoteAnchorId = 'general') {
if (!selectedText.trim()) return if (!selectedText.trim()) return
const existing = noteMap[anchor] ?? '' const safe = selectedText.trim().replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const appended = existing const latest = [...noteEntries].reverse().find(e => e.anchor === anchor)
? `${existing}<p>${selectedText.trim()}</p>` if (latest) {
: `<p>${selectedText.trim()}</p>` updateNoteEntry(latest.id, `${latest.html}<p>${safe}</p>`)
updateNote(anchor, appended) setActiveNoteId(latest.id)
setOpenAnchor(anchor) } else {
addNoteEntry(anchor, `<p>${safe}</p>`)
}
} }
function handleLessonContentContextMenu(e: React.MouseEvent<HTMLDivElement>) { function handleLessonContentContextMenu(e: React.MouseEvent<HTMLDivElement>) {
@@ -2353,10 +2368,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
{isEnrolled && ( {isEnrolled && (
<button <button
type="button" type="button"
className={`note-anchor-btn${noteMap['scripture']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`} className={`note-anchor-btn${hasNoteOn('scripture') ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'scripture' ? null : 'scripture')} onClick={() => toggleAnchorNote('scripture')}
aria-pressed={openAnchor === 'scripture'} aria-pressed={openAnchor === 'scripture'}
>{noteMap['scripture']?.trim() ? '✎ Note' : '+ Note'}</button> >{hasNoteOn('scripture') ? '✎ Note' : '+ Note'}</button>
)} )}
<h2>Scripture Text</h2> <h2>Scripture Text</h2>
{bsbPassageLoading {bsbPassageLoading
@@ -2376,10 +2391,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
{isEnrolled && ( {isEnrolled && (
<button <button
type="button" type="button"
className={`note-anchor-btn${noteMap['commentary']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`} className={`note-anchor-btn${hasNoteOn('commentary') ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'commentary' ? null : 'commentary')} onClick={() => toggleAnchorNote('commentary')}
aria-pressed={openAnchor === 'commentary'} aria-pressed={openAnchor === 'commentary'}
>{noteMap['commentary']?.trim() ? '✎ Note' : '+ Note'}</button> >{hasNoteOn('commentary') ? '✎ Note' : '+ Note'}</button>
)} )}
<h2>Instructor Commentary</h2> <h2>Instructor Commentary</h2>
{section.commentary.split(/\n{2,}/).map((para, i) => ( {section.commentary.split(/\n{2,}/).map((para, i) => (
@@ -2395,10 +2410,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
{isEnrolled && ( {isEnrolled && (
<button <button
type="button" type="button"
className={`note-anchor-btn${noteMap['questions']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`} className={`note-anchor-btn${hasNoteOn('questions') ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'questions' ? null : 'questions')} onClick={() => toggleAnchorNote('questions')}
aria-pressed={openAnchor === 'questions'} aria-pressed={openAnchor === 'questions'}
>{noteMap['questions']?.trim() ? '✎ Note' : '+ Note'}</button> >{hasNoteOn('questions') ? '✎ Note' : '+ Note'}</button>
)} )}
<h2>Reflection Questions</h2> <h2>Reflection Questions</h2>
<ol className="study-detail-list study-detail-list--questions"> <ol className="study-detail-list study-detail-list--questions">
@@ -2620,20 +2635,52 @@ export function ColossiansStudySectionPage({ content }: Props) {
<> <>
{noteLoading ? ( {noteLoading ? (
<p style={{ color: '#7a7060', fontSize: '0.85rem' }}>Loading notes…</p> <p style={{ color: '#7a7060', fontSize: '0.85rem' }}>Loading notes…</p>
) : openAnchor ? (
<NoteCard
anchorLabel={ANCHOR_LABELS[openAnchor] ?? 'Notes'}
html={noteMap[openAnchor] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote(openAnchor as NoteAnchorId, html)}
onClose={() => setOpenAnchor(null)}
/>
) : ( ) : (
<p style={{ fontSize: '0.85rem', color: '#5a5040', lineHeight: 1.55 }}> <>
{savedNoteCount > 0 {savedEntries.length > 0 && (
? `${savedNoteCount} note${savedNoteCount !== 1 ? 's' : ''} saved. Click a section's Note button to edit.` <ul className="note-entry-list" aria-label="Notes on this lesson">
: 'Click the Note button on Scripture, Commentary, or Reflection Questions to write notes here.'} {savedEntries.map(entry => (
</p> <li key={entry.id}>
<button
type="button"
className={`note-entry${entry.id === activeNoteId ? ' note-entry--active' : ''}`}
onClick={() => setActiveNoteId(entry.id === activeNoteId ? null : entry.id)}
>
<span className="note-entry-label">{ANCHOR_LABELS[entry.anchor]}{entry.updatedAt ? ` · ${formatRelativeTime(entry.updatedAt)}` : ''}</span>
<span className="note-entry-snippet">{noteHtmlToText(entry.html).slice(0, 70)}</span>
</button>
</li>
))}
</ul>
)}
{activeEntry && (
<NoteCard
key={activeEntry.id}
anchorLabel={ANCHOR_LABELS[activeEntry.anchor] ?? 'Notes'}
html={activeEntry.html}
updatedAt={activeEntry.updatedAt}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNoteEntry(activeEntry.id, html)}
onDelete={() => deleteNoteEntry(activeEntry.id)}
onClose={() => setActiveNoteId(null)}
/>
)}
{isEnrolled && (
<div className="note-add-row">
<span>Add a note on:</span>
{NOTE_ANCHORS.map(anchor => (
<button key={anchor} type="button" className="note-add-chip" onClick={() => addNoteEntry(anchor)}>
+ {ANCHOR_LABELS[anchor]}
</button>
))}
</div>
)}
{!activeEntry && savedEntries.length === 0 && (
<p style={{ fontSize: '0.85rem', color: '#5a5040', lineHeight: 1.55 }}>
Click the Note button on Scripture, Commentary, or Reflection Questions, or use the buttons above, to write notes here.
</p>
)}
</>
)} )}
{autoSaveMsg && <p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginTop: '0.35rem' }}>{autoSaveMsg}</p>} {autoSaveMsg && <p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginTop: '0.35rem' }}>{autoSaveMsg}</p>}
{noteMessage && <p className="study-note-status" style={{ marginTop: '0.35rem' }}>{noteMessage}</p>} {noteMessage && <p className="study-note-status" style={{ marginTop: '0.35rem' }}>{noteMessage}</p>}
@@ -2729,6 +2776,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [activeNoteTab, setActiveNoteTab] = useState('') const [activeNoteTab, setActiveNoteTab] = useState('')
const [noteQuery, setNoteQuery] = useState('') const [noteQuery, setNoteQuery] = useState('')
const [noteSort, setNoteSort] = useState<'lesson' | 'recent'>('lesson')
usePageMeta( usePageMeta(
study ? `My Notes | ${study.title}` : 'My Study Notes', study ? `My Notes | ${study.title}` : 'My Study Notes',
@@ -2810,8 +2858,10 @@ export function ColossiansStudyNotesPage({ content }: Props) {
: [] : []
const stripHtml = (html: string) => DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }).replace(/\s+/g, ' ').trim() const stripHtml = (html: string) => DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }).replace(/\s+/g, ' ').trim()
const noteText = (raw: string) => Object.values(parseNoteMap(raw)).map(value => stripHtml(value ?? '')).filter(Boolean).join(' · ') const noteText = (raw: string) => parseNoteEntries(raw).map(e => stripHtml(e.html)).filter(Boolean).join(' · ')
const lastEdited = (raw: string) => parseNoteEntries(raw).reduce((max, e) => (e.updatedAt && e.updatedAt > max ? e.updatedAt : max), '')
const sorted = [...entries].sort((a, b) => { const sorted = [...entries].sort((a, b) => {
if (noteSort === 'recent') return lastEdited(b.note).localeCompare(lastEdited(a.note))
const aNum = a.section ? getLessonNumber(study.sections, a.section.id) : 0 const aNum = a.section ? getLessonNumber(study.sections, a.section.id) : 0
const bNum = b.section ? getLessonNumber(study.sections, b.section.id) : 0 const bNum = b.section ? getLessonNumber(study.sections, b.section.id) : 0
return aNum - bNum return aNum - bNum
@@ -2822,6 +2872,34 @@ export function ColossiansStudyNotesPage({ content }: Props) {
: sorted : sorted
const active = visible.find(item => item.sectionId === activeNoteTab) ?? visible[0] const active = visible.find(item => item.sectionId === activeNoteTab) ?? visible[0]
const otherStudyMatches = queryText
? enrolledStudies
.filter(s2 => s2.slug !== study.slug)
.flatMap(s2 => {
const p2 = `${s2.slug}--`
return Object.entries(notes)
.filter(([key]) => key.startsWith(p2))
.map(([key, note]) => ({ study: s2, section: s2.sections.find(sec => sec.id === key.slice(p2.length)), note }))
.filter(item => item.section && `${item.section.title} ${item.section.reference ?? ''} ${noteText(item.note)}`.toLowerCase().includes(queryText))
})
: []
function downloadStudyNotesMarkdown() {
const body = sorted.map(({ section, sectionId, note }) => {
const blocks = parseNoteEntries(note)
.map(e => `### ${ANCHOR_LABELS[e.anchor]}${e.updatedAt ? ` (${new Date(e.updatedAt).toLocaleDateString('en-US')})` : ''}\n\n${stripHtml(e.html)}`)
.join('\n\n')
return `## ${section?.title ?? sectionId}${section?.reference ? ` (${section.reference})` : ''}\n\n${blocks}`
}).join('\n\n---\n\n')
const blob = new Blob([`# ${study!.title} — My Notes\n\n${body}\n`], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${study!.slug}-notes-${new Date().toISOString().slice(0, 10)}.md`
a.click()
URL.revokeObjectURL(url)
}
return ( return (
<main className="notes-page" aria-label="My study notes"> <main className="notes-page" aria-label="My study notes">
<div className="notes-shell"> <div className="notes-shell">
@@ -2895,8 +2973,12 @@ export function ColossiansStudyNotesPage({ content }: Props) {
value={noteQuery} value={noteQuery}
onChange={e => setNoteQuery(e.target.value)} onChange={e => setNoteQuery(e.target.value)}
/> />
<div className="notes-sort" role="group" aria-label="Sort notes">
<button type="button" className={noteSort === 'lesson' ? 'notes-sort-btn notes-sort-btn--active' : 'notes-sort-btn'} onClick={() => setNoteSort('lesson')}>Lesson order</button>
<button type="button" className={noteSort === 'recent' ? 'notes-sort-btn notes-sort-btn--active' : 'notes-sort-btn'} onClick={() => setNoteSort('recent')}>Recently edited</button>
</div>
{visible.length === 0 ? ( {visible.length === 0 ? (
<p className="notes-empty-text">No notes match “{noteQuery.trim()}”.</p> <p className="notes-empty-text">No notes match “{noteQuery.trim()}” in this study.</p>
) : ( ) : (
<ul className="notes-list" role="tablist" aria-label="Lesson notes"> <ul className="notes-list" role="tablist" aria-label="Lesson notes">
{visible.map(({ sectionId, section, note }) => { {visible.map(({ sectionId, section, note }) => {
@@ -2914,7 +2996,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
<span className="notes-item-number" aria-hidden="true">{lessonNumber || '•'}</span> <span className="notes-item-number" aria-hidden="true">{lessonNumber || '•'}</span>
<span className="notes-item-body"> <span className="notes-item-body">
<strong>{section?.title ?? 'Saved note'}</strong> <strong>{section?.title ?? 'Saved note'}</strong>
<small>{section?.reference ?? ''}</small> <small>{section?.reference ?? ''}{lastEdited(note) ? ` · edited ${formatRelativeTime(lastEdited(note))}` : ''}</small>
<span className="notes-item-snippet">{noteText(note).slice(0, 110)}</span> <span className="notes-item-snippet">{noteText(note).slice(0, 110)}</span>
</span> </span>
</button> </button>
@@ -2923,6 +3005,25 @@ export function ColossiansStudyNotesPage({ content }: Props) {
})} })}
</ul> </ul>
)} )}
{otherStudyMatches.length > 0 && (
<div className="notes-other-matches">
<p className="notes-reader-label">Also in your other studies</p>
<ul className="notes-list">
{otherStudyMatches.map(({ study: s2, section, note }) => (
<li key={`${s2.slug}-${section!.id}`}>
<Link className="notes-item" to={`/study/${s2.slug}/notes`}>
<span className="notes-item-body">
<strong>{section!.title}</strong>
<small>{s2.title}</small>
<span className="notes-item-snippet">{noteText(note).slice(0, 110)}</span>
</span>
</Link>
</li>
))}
</ul>
</div>
)}
<button type="button" className="btn-secondary notes-export-btn" onClick={downloadStudyNotesMarkdown}>Download notes (.md)</button>
</aside> </aside>
{active && ( {active && (
@@ -2936,22 +3037,23 @@ export function ColossiansStudyNotesPage({ content }: Props) {
type="button" type="button"
className="notes-copy-btn" className="notes-copy-btn"
onClick={() => { onClick={() => {
const text = Object.entries(parseNoteMap(active.note)) const text = parseNoteEntries(active.note)
.filter(([, html]) => html?.trim()) .map(e => `${ANCHOR_LABELS[e.anchor]}\n${stripHtml(e.html)}`)
.map(([key, html]) => `${ANCHOR_LABELS[key] ?? key}\n${stripHtml(html)}`)
.join('\n\n') .join('\n\n')
navigator.clipboard?.writeText(text).then(() => setMessage('Note copied to clipboard.'), () => setMessage('Unable to copy this note.')) navigator.clipboard?.writeText(text).then(() => setMessage('Note copied to clipboard.'), () => setMessage('Unable to copy this note.'))
}} }}
>Copy note</button> >Copy note</button>
</p> </p>
{(() => { {(() => {
const anchorMap = parseNoteMap(active.note) const blocks = parseNoteEntries(active.note)
const blocks = Object.entries(anchorMap).filter(([, html]) => html?.trim())
if (blocks.length === 0) return <p className="notes-empty-text">No notes saved for this lesson yet.</p> if (blocks.length === 0) return <p className="notes-empty-text">No notes saved for this lesson yet.</p>
return blocks.map(([key, html]) => ( return blocks.map(entry => (
<section className="notes-block" key={key}> <section className="notes-block" key={entry.id}>
<h3>{ANCHOR_LABELS[key] ?? key}</h3> <h3>
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} /> {ANCHOR_LABELS[entry.anchor]}
{entry.updatedAt && <small className="notes-block-date"> · edited {new Date(entry.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</small>}
</h3>
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(entry.html) }} />
</section> </section>
)) ))
})()} })()}
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { isKnownSitePath } from '../server/study-helpers.js'
test('real site pages are known paths', () => {
for (const p of ['/', '/study', '/study/titus/titus-abc', '/episodes/123', '/salvation', '/scripture', '/privacy']) {
assert.equal(isKnownSitePath(p), true, p)
}
})
test('scanner and probe paths are not known paths', () => {
for (const p of ['/.env', '/.git/config', '/wp-login', '/actuator/health', '/cgi-bin/x', '/random-path-123']) {
assert.equal(isKnownSitePath(p), false, p)
}
})
import { checkPassageReference } from '../shared/bible-books.js'
test('passage reference check accepts what the index can read and flags what it cannot', () => {
assert.equal(checkPassageReference('Titus 1:1-2', ''), null)
assert.equal(checkPassageReference('1:10–13a', 'titus'), null)
assert.match(checkPassageReference('1:1-2', ''), /Bible Book/)
assert.match(checkPassageReference('Foo 1:1', 'titus'), /not a recognized/)
assert.match(checkPassageReference('chapter one', 'titus'), /Use the form/)
})
+32
View File
@@ -0,0 +1,32 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { parseNoteEntries, serializeNoteEntries, noteEntriesToText } from '../shared/note-format.js'
test('legacy anchor map upgrades to entries and round-trips as v2', () => {
const entries = parseNoteEntries(JSON.stringify({ scripture: '<p>a</p>', general: '', commentary: '<p>b</p>' }))
assert.deepEqual(entries.map(e => e.anchor), ['scripture', 'commentary'])
const again = parseNoteEntries(serializeNoteEntries(entries))
assert.equal(again.length, 2)
assert.equal(JSON.parse(serializeNoteEntries(entries)).v, 2)
})
test('plain-text legacy notes become a general entry', () => {
const entries = parseNoteEntries('just some text')
assert.equal(entries.length, 1)
assert.equal(entries[0].anchor, 'general')
})
test('multiple entries per anchor are kept and empty ones dropped', () => {
const raw = serializeNoteEntries([
{ id: '1', anchor: 'general', html: '<p>one</p>', updatedAt: '2026-01-01T00:00:00.000Z' },
{ id: '2', anchor: 'general', html: '<p>two</p>', updatedAt: null },
{ id: '3', anchor: 'general', html: ' ', updatedAt: null },
])
assert.equal(parseNoteEntries(raw).length, 2)
assert.equal(serializeNoteEntries([]), '')
})
test('plain-text export strips markup', () => {
const text = noteEntriesToText(serializeNoteEntries([{ id: '1', anchor: 'scripture', html: '<p>Hi &amp; bye</p>', updatedAt: null }]))
assert.equal(text, 'Scripture Text\nHi & bye')
})
-5
View File
@@ -47,11 +47,6 @@ export default defineConfig({
}, },
}), }),
], ],
resolve: {
alias: {
'react-simple-maps': 'react-simple-maps/dist/index.es.js',
},
},
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:4173', '/api': 'http://localhost:4173',