Compare commits
8
Commits
09177f47ed
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03c3dd72fe | ||
|
|
30a7e8a6cb | ||
|
|
a11adf8ad2 | ||
|
|
e2cd66386b | ||
|
|
597f996474 | ||
|
|
a1adfa0ef1 | ||
|
|
e9d9dd0d50 | ||
|
|
6a5d525d6e |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.42.1",
|
"version": "2.45.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.42.1",
|
"version": "2.45.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1135.0",
|
"@aws-sdk/client-s3": "^3.1135.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.43.0",
|
"version": "2.46.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export const EMPTY_HIT_STATS = {
|
|||||||
byPath: {},
|
byPath: {},
|
||||||
byPathReal: {},
|
byPathReal: {},
|
||||||
byPathBot: {},
|
byPathBot: {},
|
||||||
|
byPathUnknown: {},
|
||||||
byDay: {},
|
byDay: {},
|
||||||
byDayReal: {},
|
byDayReal: {},
|
||||||
byDayBot: {},
|
byDayBot: {},
|
||||||
|
|||||||
@@ -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
@@ -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 => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.')
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
+30
-1
@@ -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>
|
||||||
|
|||||||
+39
-1
@@ -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) {
|
||||||
|
|||||||
+85
-8
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user