Add cross-site improvements across three phases

Phase 1 — Quick wins:
- Image lazy-loading on series/resource cards
- Newsletter signup added to Episodes page (before highlights)
- Per-route meta tags via usePageMeta hook (title, og:title, og:description)
- Breadcrumbs on study index, section, and notes pages
- SVG completion checkmark badges on study section list
- Analytics time-range filter (7d / 30d / 90d) in admin panel

Phase 2 — Medium features:
- Related episodes on archived series detail pages
- Resource library two-tier filter (type + tag chips)
- Global search (Fuse.js) moved below sticky header as full-width bar
- Q&A anonymous upvoting with localStorage dedup + admin pin/unpin
- Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics

Phase 3 — Larger features:
- Study section comments (auto-approve for enrolled users, admin moderation panel)
- Study completion certificate (canvas render, PNG download, shareable public URL)
- Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI)
- Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion
- Public certificate route at /certificate/:token with og meta tags
- Comment moderation panel added to admin under Manage > Study Comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-09 09:10:29 -04:00
parent 9ad24df626
commit c4645e3475
24 changed files with 2991 additions and 76 deletions
+145
View File
@@ -15,6 +15,8 @@
"docx": "^9.6.1", "docx": "^9.6.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"fuse.js": "^7.4.2",
"mammoth": "^1.12.0",
"otplib": "^13.4.0", "otplib": "^13.4.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -1446,6 +1448,15 @@
} }
} }
}, },
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -1547,6 +1558,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.8", "version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
@@ -1560,6 +1591,12 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -2151,6 +2188,12 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/docx": { "node_modules/docx": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
@@ -2201,6 +2244,15 @@
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2740,6 +2792,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/fuse.js": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.2.tgz",
"integrity": "sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/krisk"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3584,6 +3649,17 @@
"loose-envify": "cli.js" "loose-envify": "cli.js"
} }
}, },
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3594,6 +3670,39 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/mammoth/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/markdown-table": { "node_modules/markdown-table": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -4613,6 +4722,12 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4748,6 +4863,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -5613,6 +5737,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/standardwebhooks": { "node_modules/standardwebhooks": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
@@ -5889,6 +6019,12 @@
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.16.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -6242,6 +6378,15 @@
"xml-js": "bin/cli.js" "xml-js": "bin/cli.js"
} }
}, },
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+2
View File
@@ -20,6 +20,8 @@
"docx": "^9.6.1", "docx": "^9.6.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"fuse.js": "^7.4.2",
"mammoth": "^1.12.0",
"otplib": "^13.4.0", "otplib": "^13.4.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
+12
View File
@@ -13,6 +13,9 @@ import {
loadStudyUsersFromDisk, loadStudyUsersFromDisk,
loadStudyCommunityFromDisk, loadStudyCommunityFromDisk,
loadStudyRemindersFromDisk, loadStudyRemindersFromDisk,
loadStudyCommentsFromDisk,
loadStudyCertificatesFromDisk,
loadEpisodeScriptsFromDisk,
migrateStudyNotesIfNeeded, migrateStudyNotesIfNeeded,
loadDownloadCountsFromDisk, loadDownloadCountsFromDisk,
loadPodcastChecklistFromDisk, loadPodcastChecklistFromDisk,
@@ -37,6 +40,9 @@ import { register as registerAdminAssets } from './server/routes/admin-assets.js
import { register as registerStudyAuth } from './server/routes/study-auth.js' import { register as registerStudyAuth } from './server/routes/study-auth.js'
import { register as registerStudyData } from './server/routes/study-data.js' import { register as registerStudyData } from './server/routes/study-data.js'
import { register as registerStudyAccount } from './server/routes/study-account.js' import { register as registerStudyAccount } from './server/routes/study-account.js'
import { register as registerStudyComments } from './server/routes/study-comments.js'
import { register as registerStudyCertificate } from './server/routes/study-certificate.js'
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
import { register as registerContact } from './server/routes/contact.js' import { register as registerContact } from './server/routes/contact.js'
import { register as registerQuestions } from './server/routes/questions.js' import { register as registerQuestions } from './server/routes/questions.js'
import { register as registerAnalytics } from './server/routes/analytics.js' import { register as registerAnalytics } from './server/routes/analytics.js'
@@ -56,6 +62,9 @@ registerAdminAssets(app)
registerStudyAuth(app) registerStudyAuth(app)
registerStudyData(app) registerStudyData(app)
registerStudyAccount(app) registerStudyAccount(app)
registerStudyComments(app)
registerStudyCertificate(app)
registerEpisodeScripts(app)
registerContact(app) registerContact(app)
registerQuestions(app) registerQuestions(app)
registerAnalytics(app) registerAnalytics(app)
@@ -93,6 +102,9 @@ Promise.all([
loadStudyUsersFromDisk(), loadStudyUsersFromDisk(),
loadStudyCommunityFromDisk(), loadStudyCommunityFromDisk(),
loadStudyRemindersFromDisk(), loadStudyRemindersFromDisk(),
loadStudyCommentsFromDisk(),
loadStudyCertificatesFromDisk(),
loadEpisodeScriptsFromDisk(),
migrateStudyNotesIfNeeded(), migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(), loadDownloadCountsFromDisk(),
loadPodcastChecklistFromDisk(), loadPodcastChecklistFromDisk(),
+6
View File
@@ -33,6 +33,10 @@ export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
export const DOWNLOAD_COUNTS_FILE = path.join(DATA_DIR, 'download-counts.json') export const DOWNLOAD_COUNTS_FILE = path.join(DATA_DIR, 'download-counts.json')
export const STUDY_REMINDERS_FILE = path.join(DATA_DIR, 'study-reminders.json') export const STUDY_REMINDERS_FILE = path.join(DATA_DIR, 'study-reminders.json')
export const STUDY_COMMENTS_FILE = path.join(DATA_DIR, 'study-section-comments.json')
export const STUDY_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.json')
export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
export const DIST_DIR = path.join(ROOT_DIR, 'dist') export const DIST_DIR = path.join(ROOT_DIR, 'dist')
export const INDEX_FILE = path.join(DIST_DIR, 'index.html') export const INDEX_FILE = path.join(DIST_DIR, 'index.html')
@@ -60,6 +64,8 @@ export const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.
export const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000 export const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000
export const MAX_QUESTIONS = 1000 export const MAX_QUESTIONS = 1000
export const MAX_STUDY_COMMENTS = 10000
export const MAX_STUDY_COMMENT_LENGTH = 2000
export const MAX_STUDY_USERS = 5000 export const MAX_STUDY_USERS = 5000
export const MAX_STUDY_ENROLLMENTS_PER_USER = 100 export const MAX_STUDY_ENROLLMENTS_PER_USER = 100
export const MAX_STUDY_NOTES_PER_USER = 500 export const MAX_STUDY_NOTES_PER_USER = 500
+73
View File
@@ -17,6 +17,9 @@ import {
STUDY_NOTES_FILE, STUDY_NOTES_FILE,
STUDY_PROGRESS_DIR, STUDY_PROGRESS_DIR,
STUDY_REMINDERS_FILE, STUDY_REMINDERS_FILE,
STUDY_COMMENTS_FILE,
STUDY_CERTIFICATES_FILE,
EPISODE_SCRIPTS_FILE,
REPLY_TEMPLATES_FILE, REPLY_TEMPLATES_FILE,
REPLY_HISTORY_FILE, REPLY_HISTORY_FILE,
PODCAST_CHECKLIST_FILE, PODCAST_CHECKLIST_FILE,
@@ -35,6 +38,7 @@ import {
MAX_STUDY_ENROLLMENTS_PER_USER, MAX_STUDY_ENROLLMENTS_PER_USER,
MAX_STUDY_NOTES_PER_USER, MAX_STUDY_NOTES_PER_USER,
MAX_STUDY_NOTE_LENGTH, MAX_STUDY_NOTE_LENGTH,
MAX_STUDY_COMMENTS,
DEFAULT_PODCAST_CHECKLIST_TASKS, DEFAULT_PODCAST_CHECKLIST_TASKS,
buildDefaultPodcastChecklist, buildDefaultPodcastChecklist,
} from './config.js' } from './config.js'
@@ -572,6 +576,75 @@ export async function loadStudyRemindersFromDisk() {
} }
} }
// ── Study section comments ─────────────────────────────────────────────────
export function queueStudyCommentsWrite() {
state.studyCommentsWritePromise = state.studyCommentsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(STUDY_COMMENTS_FILE, JSON.stringify(state.studyComments, null, 2), 'utf8')
})
.catch(err => {
console.error('[study-comments] failed to write:', err)
})
}
export async function loadStudyCommentsFromDisk() {
try {
const raw = await readFile(STUDY_COMMENTS_FILE, 'utf8')
const parsed = JSON.parse(raw)
state.studyComments = Array.isArray(parsed) ? parsed.slice(0, MAX_STUDY_COMMENTS) : []
} catch {
state.studyComments = []
}
}
// ── Study certificates ─────────────────────────────────────────────────────
export function queueStudyCertificatesWrite() {
state.studyCertificatesWritePromise = state.studyCertificatesWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(STUDY_CERTIFICATES_FILE, JSON.stringify(state.studyCertificates, null, 2), 'utf8')
})
.catch(err => {
console.error('[study-certificates] failed to write:', err)
})
}
export async function loadStudyCertificatesFromDisk() {
try {
const raw = await readFile(STUDY_CERTIFICATES_FILE, 'utf8')
const parsed = JSON.parse(raw)
state.studyCertificates = Array.isArray(parsed) ? parsed : []
} catch {
state.studyCertificates = []
}
}
// ── Episode scripts ───────────────────────────────────────────────────────
export function queueEpisodeScriptsWrite() {
state.episodeScriptsWritePromise = state.episodeScriptsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(EPISODE_SCRIPTS_FILE, JSON.stringify(state.episodeScripts, null, 2), 'utf8')
})
.catch(err => {
console.error('[episode-scripts] failed to write:', err)
})
}
export async function loadEpisodeScriptsFromDisk() {
try {
const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8')
const parsed = JSON.parse(raw)
state.episodeScripts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
} catch {
state.episodeScripts = {}
}
}
// ── Download counts ──────────────────────────────────────────────────────── // ── Download counts ────────────────────────────────────────────────────────
export function queueDownloadCountsWrite() { export function queueDownloadCountsWrite() {
+57 -10
View File
@@ -163,13 +163,47 @@ export function register(app) {
res.json({ ok: true }) res.json({ ok: true })
}) })
app.get('/api/admin-stats', requireAdminAuth, (_req, res) => { app.get('/api/admin-stats', requireAdminAuth, (req, res) => {
const topPaths = Object.entries(state.hitStats.byPath) // Time-range filter: 7d | 30d | 90d (default: all-time for aggregate, 30d for charts)
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits })) const rangeParam = req.query.range
const topPathsReal = Object.entries(state.hitStats.byPathReal) const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : 30
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits })) const rangeLabel = rangeParam === '7d' ? '7d' : rangeParam === '90d' ? '90d' : '30d'
const topPathsBot = Object.entries(state.hitStats.byPathBot)
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits })) // Compute a cutoff date string (YYYY-MM-DD) for filtering daily buckets
const cutoffDate = (() => {
const d = new Date()
d.setDate(d.getDate() - (rangeDays - 1))
return d.toISOString().slice(0, 10)
})()
// Filter byDayReal/byDayBot keys to only those within the range
const filteredDayKeys = Object.keys(state.hitStats.byDayReal ?? {}).filter(day => day >= cutoffDate)
// Aggregate hits for the range
const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0)
const rangeBotHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayBot?.[day] ?? 0), 0)
const rangeTotalHits = rangeRealHits + rangeBotHits
// Filter per-path stats by range — approximate using recent visitor rows scoped to range
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => {
if (!row.visitedAt) return true // include if no timestamp
return row.visitedAt >= cutoffDate
})
const rangePathCountsReal = {}
const rangePathCountsBot = {}
for (const row of rangeVisitorRows) {
const p = row.path ?? '/'
if (row.isBot) rangePathCountsBot[p] = (rangePathCountsBot[p] ?? 0) + 1
else rangePathCountsReal[p] = (rangePathCountsReal[p] ?? 0) + 1
}
const topPathsReal = Object.entries(rangePathCountsReal).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits }))
const topPathsBot = Object.entries(rangePathCountsBot).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits }))
const topPaths = [...topPathsReal, ...topPathsBot].reduce((acc, { path, hits }) => {
const existing = acc.find(x => x.path === path)
if (existing) existing.hits += hits; else acc.push({ path, hits })
return acc
}, []).sort((a, b) => b.hits - a.hits).slice(0, 10)
const last7Days = buildLastNDaysStats(7) const last7Days = buildLastNDaysStats(7)
const last7DaysReal = last7Days.map(item => ({ day: item.day, hits: state.hitStats.byDayReal?.[item.day] ?? 0 })) const last7DaysReal = last7Days.map(item => ({ day: item.day, hits: state.hitStats.byDayReal?.[item.day] ?? 0 }))
@@ -183,7 +217,7 @@ export function register(app) {
const botReasons = Object.entries(state.hitStats.botReasons ?? {}) const botReasons = Object.entries(state.hitStats.botReasons ?? {})
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count })) .sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count }))
const recentVisitorRows = state.visitorStats.recentVisits.slice(0, 100).map(row => { const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => {
const fullVisitor = state.visitorStats.visitors[row.visitorId] const fullVisitor = state.visitorStats.visitors[row.visitorId]
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] } return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
}) })
@@ -217,10 +251,22 @@ export function register(app) {
const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length
const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0) const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0)
// Enrollment funnel: signups → first study visit → first section completed
const funnelSignups = state.studyUsers.length
const funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length
const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length
res.json({ res.json({
totalHits: state.hitStats.totalHits, totalHits: state.hitStats.totalHits,
realHits: state.hitStats.realHits ?? 0, realHits: rangeRealHits,
botHits: state.hitStats.botHits ?? 0, botHits: rangeBotHits,
rangeTotalHits,
rangeLabel,
rangeDays,
// All-time totals for reference
allTimeRealHits: state.hitStats.realHits ?? 0,
allTimeBotHits: state.hitStats.botHits ?? 0,
allTimeTotalHits: state.hitStats.totalHits,
firstHitAt: state.hitStats.firstHitAt, firstHitAt: state.hitStats.firstHitAt,
lastHitAt: state.hitStats.lastHitAt, lastHitAt: state.hitStats.lastHitAt,
topPaths, topPathsReal, topPathsBot, topPaths, topPathsReal, topPathsBot,
@@ -273,6 +319,7 @@ export function register(app) {
totalEnrollments, totalEnrollments,
enrollmentsByStudy, enrollmentsByStudy,
users, users,
funnel: { signups: funnelSignups, firstVisit: funnelFirstVisit, firstCompletion: funnelFirstCompletion },
}, },
}) })
}) })
+134
View File
@@ -0,0 +1,134 @@
import mammoth from 'mammoth'
import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js'
import { queueEpisodeScriptsWrite } from '../data.js'
import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js'
/**
* Simple whitespace-normalised search over episode script text.
* Returns { episodeNumber, title, filename, snippet, score } for matches.
*/
function searchScripts(query) {
if (!query || query.trim().length < 2) return []
const q = query.trim().toLowerCase()
const words = q.split(/\s+/).filter(w => w.length >= 2)
const results = []
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
if (!entry?.text) continue
const text = entry.text.toLowerCase()
// Count how many words appear in the text
const matchCount = words.filter(w => text.includes(w)).length
if (matchCount === 0) continue
// Find a snippet around the first matching word
const firstWord = words.find(w => text.includes(w))
const idx = firstWord ? text.indexOf(firstWord) : 0
const start = Math.max(0, idx - 80)
const end = Math.min(entry.text.length, idx + 180)
let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim()
if (start > 0) snippet = '…' + snippet
if (end < entry.text.length) snippet = snippet + '…'
results.push({
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
snippet,
score: matchCount / words.length, // 01: fraction of query words found
})
}
return results.sort((a, b) => b.score - a.score).slice(0, 8)
}
export function register(app) {
// Public script search — called by the frontend global search
app.get('/api/episode-scripts/search', (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''
if (!q || q.length < 2) { res.json({ results: [] }); return }
res.json({ results: searchScripts(q) })
})
// Admin: list all uploaded scripts
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
uploadedAt: entry.uploadedAt ?? null,
wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0,
}))
list.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
res.json({ scripts: list })
})
// Admin: upload a docx script for an episode
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => {
const episodeNumber = req.params.episodeNumber?.trim()
if (!episodeNumber || !/^\d+$/.test(episodeNumber)) {
res.status(400).json({ message: 'Episode number must be a positive integer.' }); return
}
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : `episode-${episodeNumber}.docx`
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}`
if (!dataUrl.startsWith('data:')) {
res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return
}
const base64 = dataUrl.split(',')[1] ?? ''
if (!base64) { res.status(400).json({ message: 'Empty file.' }); return }
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > 20 * 1024 * 1024) {
res.status(400).json({ message: 'File must be between 1 byte and 20MB.' }); return
}
let text
try {
const result = await mammoth.extractRawText({ buffer })
text = result.value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
} catch (err) {
console.error('[episode-scripts] mammoth extraction failed:', err.message)
res.status(422).json({ message: 'Could not extract text from file. Make sure it is a valid .docx file.' }); return
}
if (!text || text.length < 10) {
res.status(422).json({ message: 'No readable text found in document.' }); return
}
// Trim to max length
if (text.length > MAX_EPISODE_SCRIPT_LENGTH) {
text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH)
}
state.episodeScripts[episodeNumber] = {
title,
filename,
text,
uploadedAt: new Date().toISOString(),
wordCount: text.split(/\s+/).filter(Boolean).length,
}
queueEpisodeScriptsWrite()
res.json({
ok: true,
episodeNumber,
wordCount: state.episodeScripts[episodeNumber].wordCount,
})
})
// Admin: delete a script
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => {
const episodeNumber = req.params.episodeNumber?.trim()
if (!state.episodeScripts[episodeNumber]) {
res.status(404).json({ message: 'Script not found.' }); return
}
delete state.episodeScripts[episodeNumber]
queueEpisodeScriptsWrite()
res.json({ ok: true })
})
}
+45 -1
View File
@@ -12,7 +12,14 @@ function ensureDraftQuestions() {
export function register(app) { export function register(app) {
app.get('/api/questions', (_req, res) => { app.get('/api/questions', (_req, res) => {
const sourceQuestions = state.draftQuestions ?? state.questions const sourceQuestions = state.draftQuestions ?? state.questions
const publicQuestions = sourceQuestions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0) const publicQuestions = sourceQuestions
.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
.sort((a, b) => {
// Pinned questions always float to top
if (a.pinned && !b.pinned) return -1
if (!a.pinned && b.pinned) return 1
return 0
})
res.json({ questions: publicQuestions }) res.json({ questions: publicQuestions })
}) })
@@ -113,4 +120,41 @@ export function register(app) {
res.json({ ok: true }) res.json({ ok: true })
}) })
// Anonymous upvote — no auth required, lightweight increment
app.post('/api/questions/:id/upvote', (req, res) => {
const { id } = req.params
// Look in live questions first, then draft
const liveQ = state.questions.find(q => q.id === id && q.isApproved === true)
const draftQ = state.draftQuestions ? state.draftQuestions.find(q => q.id === id) : null
const question = liveQ ?? draftQ
if (!question) {
res.status(404).json({ message: 'Question not found.' }); return
}
question.upvotes = ((question.upvotes ?? 0) + 1)
if (liveQ) queueQuestionsWrite()
if (draftQ) queueDraftQuestionsWrite()
res.json({ ok: true, upvotes: question.upvotes })
})
// Admin: pin / unpin a question
app.post('/api/admin-questions/:id/pin', requireAdminAuth, (req, res) => {
const { id } = req.params
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === id)
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
question.pinned = true
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
app.delete('/api/admin-questions/:id/pin', requireAdminAuth, (req, res) => {
const { id } = req.params
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === id)
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
question.pinned = false
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
} }
+115
View File
@@ -0,0 +1,115 @@
import { randomUUID } from 'node:crypto'
import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js'
import { state } from '../state.js'
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
/**
* Returns the list of required section IDs for a study (only released sections).
*/
function getRequiredSectionIds(studySlug) {
const content = state.cachedSiteContent
if (!content || !Array.isArray(content.studies)) return null
const study = content.studies.find(s => s?.slug === studySlug)
if (!study || !Array.isArray(study.sections)) return null
return study.sections
.filter(s => s?.status !== 'unreleased' && s?.id)
.map(s => s.id)
}
export function register(app) {
// GET — check eligibility and return existing certificate token if one exists
app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Not enrolled in this study.' }); return
}
const requiredIds = getRequiredSectionIds(studySlug)
if (!requiredIds || requiredIds.length === 0) {
res.status(404).json({ message: 'Study sections not found.' }); return
}
const progress = await loadUserProgress(user.id)
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
const eligible = requiredIds.every(id => completedIds.includes(id))
const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
res.json({
eligible,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
completedCount: completedIds.filter(id => requiredIds.includes(id)).length,
totalCount: requiredIds.length,
token: existing?.token ?? null,
issuedAt: existing?.issuedAt ?? null,
})
})
// POST — issue/re-issue a certificate (must be eligible)
app.post('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Not enrolled in this study.' }); return
}
const requiredIds = getRequiredSectionIds(studySlug)
if (!requiredIds || requiredIds.length === 0) {
res.status(404).json({ message: 'Study sections not found.' }); return
}
const progress = await loadUserProgress(user.id)
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
const eligible = requiredIds.every(id => completedIds.includes(id))
if (!eligible) {
res.status(403).json({ message: 'Complete all sections to earn your certificate.' }); return
}
// Find or create
let cert = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
if (!cert) {
cert = {
id: randomUUID(),
token: randomUUID(),
userId: user.id,
studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
displayName: user.displayName || user.username,
issuedAt: new Date().toISOString(),
}
state.studyCertificates.push(cert)
queueStudyCertificatesWrite()
}
res.json({ ok: true, token: cert.token, issuedAt: cert.issuedAt })
})
// GET — public certificate page data (no auth, by token)
app.get('/api/public/certificate/:token', (req, res) => {
const { token } = req.params
if (!token || typeof token !== 'string' || token.length > 100) {
res.status(400).json({ message: 'Invalid token.' }); return
}
const cert = state.studyCertificates.find(c => c.token === token)
if (!cert) { res.status(404).json({ message: 'Certificate not found.' }); return }
res.json({
studyTitle: cert.studyTitle,
displayName: cert.displayName,
issuedAt: cert.issuedAt,
})
})
// Admin: list all certificates
app.get('/api/admin-study-certificates', requireAdminAuth, (_req, res) => {
res.json({ certificates: state.studyCertificates })
})
}
+122
View File
@@ -0,0 +1,122 @@
import { randomUUID } from 'node:crypto'
import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, isStudyUserEnrolled } from '../study-helpers.js'
import { state } from '../state.js'
import { queueStudyCommentsWrite } from '../data.js'
import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js'
export function register(app) {
// GET public-approved comments for a section (no auth)
app.get('/api/study-comments/:studySlug/:sectionId', (req, res) => {
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
}
const comments = state.studyComments
.filter(c => c.studySlug === studySlug && c.sectionId === sectionId && c.isApproved === true)
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
.map(c => ({
id: c.id,
displayName: c.displayName,
text: c.text,
createdAt: c.createdAt,
}))
res.json({ comments })
})
// POST — enrolled study users post a comment (auto-approved for enrolled users)
app.post('/api/study-comments/:studySlug/:sectionId', requireStudyAuth, (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return
}
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
if (!text || text.length < 2 || text.length > MAX_STUDY_COMMENT_LENGTH) {
res.status(400).json({ message: `Comment must be between 2 and ${MAX_STUDY_COMMENT_LENGTH} characters.` }); return
}
// Auto-approve for enrolled users
const now = new Date().toISOString()
const comment = {
id: randomUUID(),
studySlug,
sectionId,
userId: user.id,
displayName: user.displayName || user.username,
text,
createdAt: now,
isApproved: true,
approvedAt: now,
}
state.studyComments.unshift(comment)
if (state.studyComments.length > MAX_STUDY_COMMENTS) {
state.studyComments = state.studyComments.slice(0, MAX_STUDY_COMMENTS)
}
queueStudyCommentsWrite()
res.status(201).json({
ok: true,
comment: {
id: comment.id,
displayName: comment.displayName,
text: comment.text,
createdAt: comment.createdAt,
},
})
})
// DELETE — enrolled users can delete their own comment
app.delete('/api/study-comments/:commentId', requireStudyAuth, (req, res) => {
const user = req.studyUser
const { commentId } = req.params
const idx = state.studyComments.findIndex(c => c.id === commentId && c.userId === user.id)
if (idx === -1) {
res.status(404).json({ message: 'Comment not found.' }); return
}
state.studyComments.splice(idx, 1)
queueStudyCommentsWrite()
res.json({ ok: true })
})
// Admin: list all comments (optionally filter by studySlug)
app.get('/api/admin-study-comments', requireAdminAuth, (req, res) => {
const { studySlug, sectionId, approved } = req.query
let comments = state.studyComments
if (studySlug) comments = comments.filter(c => c.studySlug === studySlug)
if (sectionId) comments = comments.filter(c => c.sectionId === sectionId)
if (approved === 'true') comments = comments.filter(c => c.isApproved === true)
if (approved === 'false') comments = comments.filter(c => c.isApproved !== true)
res.json({ comments: comments.slice(0, 500) })
})
// Admin: approve a comment
app.post('/api/admin-study-comments/:commentId/approve', requireAdminAuth, (req, res) => {
const comment = state.studyComments.find(c => c.id === req.params.commentId)
if (!comment) { res.status(404).json({ message: 'Comment not found.' }); return }
comment.isApproved = true
comment.approvedAt = new Date().toISOString()
queueStudyCommentsWrite()
res.json({ ok: true, comment })
})
// Admin: delete any comment
app.delete('/api/admin-study-comments/:commentId', requireAdminAuth, (req, res) => {
const idx = state.studyComments.findIndex(c => c.id === req.params.commentId)
if (idx === -1) { res.status(404).json({ message: 'Comment not found.' }); return }
state.studyComments.splice(idx, 1)
queueStudyCommentsWrite()
res.json({ ok: true })
})
}
+13
View File
@@ -154,6 +154,13 @@ export function register(app) {
const progress = await loadUserProgress(user.id) const progress = await loadUserProgress(user.id)
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? [] const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
// Record first visit timestamp on the user record (additive, never overwrite)
if (!user.firstVisitAt) {
user.firstVisitAt = new Date().toISOString()
queueStudyUsersWrite()
}
res.json({ studySlug, completedSectionIds }) res.json({ studySlug, completedSectionIds })
}) })
@@ -181,6 +188,12 @@ export function register(app) {
state.studyProgressCache.set(user.id, progress) state.studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id) queueUserProgressWrite(user.id)
// Record first completion timestamp on the user record (additive, never overwrite)
if (!user.firstCompletionAt) {
user.firstCompletionAt = new Date().toISOString()
queueStudyUsersWrite()
}
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
}) })
+10
View File
@@ -46,6 +46,16 @@ export const state = {
studyReminders: { users: {}, updatedAt: new Date().toISOString() }, studyReminders: { users: {}, updatedAt: new Date().toISOString() },
studyRemindersWritePromise: Promise.resolve(), studyRemindersWritePromise: Promise.resolve(),
studyComments: [],
studyCommentsWritePromise: Promise.resolve(),
studyCertificates: [],
studyCertificatesWritePromise: Promise.resolve(),
// episodeScripts: { [episodeNumber]: { text, title, filename, uploadedAt } }
episodeScripts: {},
episodeScriptsWritePromise: Promise.resolve(),
downloadCounts: {}, downloadCounts: {},
downloadCountsWritePromise: Promise.resolve(), downloadCountsWritePromise: Promise.resolve(),
+287 -5
View File
@@ -553,6 +553,7 @@ export interface AdminStats {
displayName: string displayName: string
enrolledStudies: Array<{ slug: string; title: string }> enrolledStudies: Array<{ slug: string; title: string }>
}> }>
funnel?: { signups: number; firstVisit: number; firstCompletion: number }
} }
} }
@@ -587,6 +588,8 @@ interface Question {
answeredAt: string | null answeredAt: string | null
isApproved: boolean isApproved: boolean
approvedAt: string | null approvedAt: string | null
upvotes?: number
pinned?: boolean
} }
interface ContactSubmission { interface ContactSubmission {
@@ -670,7 +673,7 @@ type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact' | 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
| 'downloads' | 'custom-links' | 'content-blocks' | 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'analytics' | 'assets' | 'colossians-study' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study'
| 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates' | 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'seo' | 'legal' | 'security' | 'brand' | 'global'
@@ -707,6 +710,7 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
group: 'Manage', group: 'Manage',
options: [ options: [
{ value: 'questions', label: 'Questions' }, { value: 'questions', label: 'Questions' },
{ value: 'study-comments', label: 'Study Comments' },
{ value: 'emails', label: 'Emails' }, { value: 'emails', label: 'Emails' },
{ value: 'contacts', label: 'Contacts' }, { value: 'contacts', label: 'Contacts' },
{ value: 'subscribers', label: 'Subscribers' }, { value: 'subscribers', label: 'Subscribers' },
@@ -774,7 +778,7 @@ const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
], ],
} }
type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | 'episode-scripts'
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates' type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates'
@@ -1019,6 +1023,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [podcastTab, setPodcastTab] = useState<PodcastTab>('current-series') const [podcastTab, setPodcastTab] = useState<PodcastTab>('current-series')
const [stats, setStats] = useState<AdminStats | null>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [statsRange, setStatsRange] = useState<'7d' | '30d' | '90d'>('30d')
const [maintenanceMsg, setMaintenanceMsg] = useState('') const [maintenanceMsg, setMaintenanceMsg] = useState('')
const [opsMsg, setOpsMsg] = useState('') const [opsMsg, setOpsMsg] = useState('')
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([]) const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
@@ -1063,6 +1068,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [subscriberSearch, setSubscriberSearch] = useState('') const [subscriberSearch, setSubscriberSearch] = useState('')
const [contactSearch, setContactSearch] = useState('') const [contactSearch, setContactSearch] = useState('')
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({}) const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
// Study comments moderation state
interface AdminComment { id: string; studySlug: string; sectionId: string; displayName: string; text: string; createdAt: string; isApproved: boolean; approvedAt: string | null }
const [studyComments, setStudyComments] = useState<AdminComment[]>([])
const [commentsLoading, setCommentsLoading] = useState(false)
const [commentsLoaded, setCommentsLoaded] = useState(false)
const [commentFilter, setCommentFilter] = useState<'all' | 'pending' | 'approved'>('all')
const [commentSearch, setCommentSearch] = useState('')
// Episode scripts state
const [scriptsList, setScriptsList] = useState<Array<{ episodeNumber: string; title: string; filename: string; uploadedAt: string | null; wordCount: number }>>([])
const [scriptsLoading, setScriptsLoading] = useState(false)
const [scriptUploadEpisode, setScriptUploadEpisode] = useState('')
const [scriptUploadTitle, setScriptUploadTitle] = useState('')
const [scriptUploadFile, setScriptUploadFile] = useState<File | null>(null)
const [scriptUploadBusy, setScriptUploadBusy] = useState(false)
const [scriptUploadMsg, setScriptUploadMsg] = useState('')
const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ tasks: [], episodes: [] }) const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ tasks: [], episodes: [] })
const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('') const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('')
@@ -1206,7 +1228,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}, []) }, [])
useEffect(() => { useEffect(() => {
fetch('/api/admin-stats') fetch(`/api/admin-stats?range=${statsRange}`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats')))) .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
.then(data => { .then(data => {
setStats(data as AdminStats) setStats(data as AdminStats)
@@ -1329,8 +1351,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}) })
}, [selectedBackup]) }, [selectedBackup])
async function reloadStats() { async function reloadStats(range?: '7d' | '30d' | '90d') {
const r = await fetch('/api/admin-stats') const r = await fetch(`/api/admin-stats?range=${range ?? statsRange}`)
if (!r.ok) throw new Error('Could not refresh stats') if (!r.ok) throw new Error('Could not refresh stats')
const data = await r.json() const data = await r.json()
setStats(data as AdminStats) setStats(data as AdminStats)
@@ -2607,6 +2629,99 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
} }
} }
async function handlePinQuestion(questionId: string, pin: boolean) {
try {
const method = pin ? 'POST' : 'DELETE'
const res = await fetch(`/api/admin-questions/${questionId}/pin`, { method })
if (!res.ok) throw new Error('Failed to update pin')
const data = await res.json().catch(() => ({})) as { question?: Question }
if (data.question) {
setQuestions(items => items.map(q => q.id === questionId ? { ...q, pinned: data.question!.pinned } : q))
}
} catch (err) {
console.error(err)
}
}
async function loadStudyComments() {
setCommentsLoading(true)
try {
const res = await fetch('/api/admin-study-comments')
const data = await res.json()
setStudyComments(Array.isArray(data.comments) ? data.comments : [])
setCommentsLoaded(true)
} catch { /* ignore */ }
finally { setCommentsLoading(false) }
}
async function handleApproveComment(commentId: string) {
try {
const res = await fetch(`/api/admin-study-comments/${commentId}/approve`, { method: 'POST' })
if (!res.ok) return
setStudyComments(prev => prev.map(c => c.id === commentId ? { ...c, isApproved: true, approvedAt: new Date().toISOString() } : c))
} catch { /* ignore */ }
}
async function handleDeleteComment(commentId: string) {
if (!confirm('Delete this comment permanently?')) return
try {
const res = await fetch(`/api/admin-study-comments/${commentId}`, { method: 'DELETE' })
if (!res.ok) return
setStudyComments(prev => prev.filter(c => c.id !== commentId))
} catch { /* ignore */ }
}
async function loadScriptsList() {
setScriptsLoading(true)
try {
const res = await fetch('/api/admin-episode-scripts')
const data = await res.json()
setScriptsList(Array.isArray(data.scripts) ? data.scripts : [])
} catch { /* ignore */ }
finally { setScriptsLoading(false) }
}
async function handleScriptUpload() {
if (!scriptUploadEpisode.trim() || !/^\d+$/.test(scriptUploadEpisode.trim())) {
setScriptUploadMsg('Enter a valid episode number.'); return
}
if (!scriptUploadFile) { setScriptUploadMsg('Select a .docx file.'); return }
setScriptUploadBusy(true)
setScriptUploadMsg('')
try {
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = e => resolve(e.target?.result as string)
reader.onerror = reject
reader.readAsDataURL(scriptUploadFile)
})
const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(scriptUploadEpisode.trim())}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dataUrl, filename: scriptUploadFile.name, title: scriptUploadTitle.trim() || `Episode ${scriptUploadEpisode.trim()}` }),
})
const data = await res.json()
if (!res.ok) { setScriptUploadMsg(data.message ?? 'Upload failed.'); return }
setScriptUploadMsg(`✓ Episode ${scriptUploadEpisode} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`)
setScriptUploadEpisode('')
setScriptUploadTitle('')
setScriptUploadFile(null)
loadScriptsList()
} catch {
setScriptUploadMsg('Upload failed. Please try again.')
} finally {
setScriptUploadBusy(false)
}
}
async function handleDeleteScript(episodeNumber: string) {
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return
try {
await fetch(`/api/admin-episode-scripts/${encodeURIComponent(episodeNumber)}`, { method: 'DELETE' })
setScriptsList(prev => prev.filter(s => s.episodeNumber !== episodeNumber))
} catch { alert('Failed to delete script.') }
}
async function handleDeleteQuestion(questionId: string) { async function handleDeleteQuestion(questionId: string) {
if (!confirm('Delete this question permanently?')) return if (!confirm('Delete this question permanently?')) return
try { try {
@@ -3640,6 +3755,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<button type="button" className={`admin-tab${podcastTab === 'episode-highlights' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-highlights')}>Ep. Highlights</button> <button type="button" className={`admin-tab${podcastTab === 'episode-highlights' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-highlights')}>Ep. Highlights</button>
<button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button> <button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button>
<button type="button" className={`admin-tab${podcastTab === 'archived-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('archived-series')}>Archived Series</button> <button type="button" className={`admin-tab${podcastTab === 'archived-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('archived-series')}>Archived Series</button>
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
</div> </div>
</section> </section>
)} )}
@@ -4646,6 +4762,163 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section> </section>
)} )}
{/* EPISODE SCRIPTS */}
{(adminView === 'podcast' && podcastTab === 'episode-scripts') && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Episode Scripts</h2>
<p>Upload a Word (.docx) script for each episode to make the full content searchable from the site&apos;s global search bar.</p>
</div>
{/* Upload form */}
<div className="admin-script-upload-form">
<div className="admin-field">
<label htmlFor="script-episode-num">Episode Number</label>
<input
id="script-episode-num"
type="number"
min="1"
placeholder="e.g. 42"
value={scriptUploadEpisode}
onChange={e => setScriptUploadEpisode(e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="script-episode-title">Episode Title <span style={{ color: '#5a5440', fontWeight: 400 }}>(optional will be pulled from search results)</span></label>
<input
id="script-episode-title"
type="text"
placeholder="e.g. Colossians 1:15 — The Image of God"
value={scriptUploadTitle}
onChange={e => setScriptUploadTitle(e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="script-file">Word Document (.docx)</label>
<input
id="script-file"
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
onChange={e => setScriptUploadFile(e.target.files?.[0] ?? null)}
/>
</div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="btn-admin-save" onClick={handleScriptUpload} disabled={scriptUploadBusy}>
{scriptUploadBusy ? 'Uploading…' : 'Upload Script'}
</button>
{scriptUploadMsg && <span style={{ fontSize: '0.85rem', color: scriptUploadMsg.startsWith('✓') ? '#6fcf97' : '#e07a7a' }}>{scriptUploadMsg}</span>}
</div>
</div>
{/* Indexed scripts list */}
<div className="admin-panel-head" style={{ marginTop: '2rem' }}>
<h3 style={{ margin: 0 }}>Indexed Scripts</h3>
<button type="button" className="btn-admin-reset" onClick={loadScriptsList} disabled={scriptsLoading} style={{ marginLeft: 'auto' }}>
{scriptsLoading ? 'Loading…' : 'Refresh'}
</button>
</div>
{scriptsList.length === 0 ? (
<p style={{ color: '#5a5440', fontSize: '0.9rem', marginTop: '0.75rem' }}>
No scripts uploaded yet.{' '}
<button type="button" className="btn-admin-reset" style={{ display: 'inline', padding: '0.1rem 0.5rem' }} onClick={loadScriptsList}>Load list</button>
</p>
) : (
<div className="admin-script-list">
{scriptsList.map(script => (
<div key={script.episodeNumber} className="admin-script-row">
<div className="admin-script-row-info">
<span className="admin-script-ep">Ep. {script.episodeNumber}</span>
<span className="admin-script-title">{script.title}</span>
<span className="admin-script-meta">{script.wordCount.toLocaleString()} words · {script.filename}</span>
</div>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteScript(script.episodeNumber)}>Remove</button>
</div>
))}
</div>
)}
</section>
)}
{/* STUDY COMMENTS */}
{adminView === 'study-comments' && (() => {
const filtered = studyComments.filter(c => {
if (commentFilter === 'pending') return !c.isApproved
if (commentFilter === 'approved') return c.isApproved
return true
}).filter(c => {
if (!commentSearch.trim()) return true
const q = commentSearch.toLowerCase()
return c.displayName.toLowerCase().includes(q) || c.text.toLowerCase().includes(q) || c.studySlug.includes(q) || c.sectionId.includes(q)
})
return (
<section className="admin-panel-section" aria-label="Study Comment Moderation">
<div className="admin-panel-head">
<h2>Study Comments</h2>
<p>Review, approve, and delete comments left on study section pages.</p>
</div>
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
<input
type="text"
value={commentSearch}
onChange={e => setCommentSearch(e.target.value)}
placeholder="Search by name, text, or section…"
style={{ minWidth: '280px', maxWidth: '480px', width: '100%' }}
/>
<button type="button" className={`btn-admin-reset${commentFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setCommentFilter('all')}>All</button>
<button type="button" className={`btn-admin-reset${commentFilter === 'pending' ? ' btn-admin-reset--active' : ''}`} onClick={() => setCommentFilter('pending')}>Pending</button>
<button type="button" className={`btn-admin-reset${commentFilter === 'approved' ? ' btn-admin-reset--active' : ''}`} onClick={() => setCommentFilter('approved')}>Approved</button>
<button type="button" className="btn-admin-reset" onClick={loadStudyComments} disabled={commentsLoading} style={{ marginLeft: 'auto' }}>
{commentsLoading ? 'Loading…' : 'Refresh'}
</button>
</div>
{!commentsLoaded ? (
<div style={{ marginTop: '1.5rem' }}>
<button type="button" className="btn-admin-reset" onClick={loadStudyComments} disabled={commentsLoading}>
{commentsLoading ? 'Loading…' : 'Load Comments'}
</button>
</div>
) : filtered.length === 0 ? (
<p className="admin-stats-note" style={{ marginTop: '1rem' }}>
{commentFilter === 'pending' ? 'No pending comments.' : commentFilter === 'approved' ? 'No approved comments.' : 'No comments yet.'}
</p>
) : (
<>
<p className="admin-stats-note">Showing {filtered.length} of {studyComments.length} comments.</p>
<div className="admin-comment-list">
{filtered.map(comment => (
<div key={comment.id} className={`admin-comment-card${comment.isApproved ? '' : ' admin-comment-card--pending'}`}>
<div className="admin-comment-card-header">
<div className="admin-comment-card-meta">
<span className="admin-comment-author">{comment.displayName}</span>
<span className="admin-comment-location">{comment.studySlug} {comment.sectionId}</span>
<span className="admin-comment-date">{new Date(comment.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</span>
</div>
{!comment.isApproved && (
<span className="admin-comment-badge admin-comment-badge--pending">Pending</span>
)}
{comment.isApproved && (
<span className="admin-comment-badge admin-comment-badge--approved">Approved</span>
)}
</div>
<p className="admin-comment-text">{comment.text}</p>
<div className="admin-comment-actions">
{!comment.isApproved && (
<button type="button" className="btn-admin-save" onClick={() => handleApproveComment(comment.id)}>Approve</button>
)}
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteComment(comment.id)}>Delete</button>
</div>
</div>
))}
</div>
</>
)}
</section>
)
})()}
{/* EMAIL TEMPLATES */} {/* EMAIL TEMPLATES */}
{adminView === 'email-templates' && ( {adminView === 'email-templates' && (
<EmailTemplatesPanel form={form} handleChange={handleChange} renderSaveStatus={renderSaveStatus} /> <EmailTemplatesPanel form={form} handleChange={handleChange} renderSaveStatus={renderSaveStatus} />
@@ -4815,6 +5088,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<button type="button" className={`btn-admin-${question.isApproved ? 'remove' : 'reset'}`} onClick={() => handleApproveQuestion(question.id, !question.isApproved)}> <button type="button" className={`btn-admin-${question.isApproved ? 'remove' : 'reset'}`} onClick={() => handleApproveQuestion(question.id, !question.isApproved)}>
{question.isApproved ? 'Unapprove' : 'Approve'} {question.isApproved ? 'Unapprove' : 'Approve'}
</button> </button>
<button type="button" className={`btn-admin-${question.pinned ? 'remove' : 'reset'}`} onClick={() => handlePinQuestion(question.id, !question.pinned)}>
{question.pinned ? '📌 Unpin' : '📌 Pin'}
</button>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteQuestion(question.id)}>Delete</button> <button type="button" className="btn-admin-remove" onClick={() => handleDeleteQuestion(question.id)}>Delete</button>
</div> </div>
)} )}
@@ -4855,6 +5131,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<AnalyticsPanel <AnalyticsPanel
stats={stats} stats={stats}
statsStatus={statsStatus} statsStatus={statsStatus}
statsRange={statsRange}
onRangeChange={async (range) => {
setStatsRange(range)
setStatsStatus('loading')
try { await reloadStats(range) } catch { setStatsStatus('error') }
}}
opsStatus={opsStatus} opsStatus={opsStatus}
formatDate={formatDate} formatDate={formatDate}
maskIp={maskIp} maskIp={maskIp}
+734
View File
@@ -26,8 +26,131 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
/* ── Global Search ── */
/* Search bar strip below the sticky header */
.site-search-bar {
background: #13120d;
border-bottom: 1px solid #28241a;
position: sticky;
top: 72px; /* sits flush below the header */
z-index: 99;
}
.site-search-bar-inner {
max-width: 860px;
margin: 0 auto;
padding: 0.5rem 1.5rem;
}
.global-search {
position: relative;
width: 100%;
}
.global-search-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.global-search-icon {
position: absolute;
left: 0.65rem;
color: #7a7060;
pointer-events: none;
display: flex;
align-items: center;
}
.global-search-input {
width: 100%;
padding: 0.4rem 2rem 0.4rem 2.1rem;
background: rgba(255,255,255,0.05);
border: 1px solid #2a2518;
border-radius: 20px;
color: #f0ead8;
font-size: 0.82rem;
font-family: var(--brand-font-body);
outline: none;
transition: border-color 0.15s, background 0.15s;
}
.global-search-input::placeholder { color: #5a5440; }
.global-search-input:focus {
border-color: rgba(201,168,76,0.5);
background: rgba(255,255,255,0.08);
}
.global-search-input::-webkit-search-cancel-button { display: none; }
.global-search-clear {
position: absolute;
right: 0.55rem;
background: none;
border: none;
color: #7a7060;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
padding: 0 0.2rem;
}
.global-search-clear:hover { color: #f0ead8; }
.global-search-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
min-width: 320px;
background: #18170f;
border: 1px solid #3a3320;
border-radius: 14px;
box-shadow: 0 12px 40px rgba(0,0,0,0.55);
z-index: 200;
overflow: hidden;
}
.global-search-result {
width: 100%;
display: flex;
align-items: flex-start;
gap: 0.7rem;
padding: 0.7rem 1rem;
background: none;
border: none;
border-bottom: 1px solid #25231a;
cursor: pointer;
text-align: left;
color: inherit;
transition: background 0.1s;
}
.global-search-result:last-of-type { border-bottom: none; }
.global-search-result:hover,
.global-search-result--active { background: rgba(201,168,76,0.08); }
.global-search-result-icon { font-size: 1rem; flex-shrink: 0; margin-top: 1px; }
.global-search-result-body { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.global-search-result-title {
color: #f0ead8;
font-size: 0.88rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.global-search-result-meta { display: flex; align-items: center; gap: 0; }
.global-search-result-type {
font-size: 0.72rem;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.global-search-result-sub { font-size: 0.72rem; color: #7a7060; }
.global-search-result-snippet {
font-size: 0.72rem;
color: #6a6050;
line-height: 1.45;
margin-top: 0.15rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.global-search-empty { padding: 0.85rem 1rem; color: #7a7060; font-size: 0.85rem; margin: 0; }
.global-search-hint { padding: 0.4rem 1rem; color: #3a3320; font-size: 0.7rem; margin: 0; border-top: 1px solid #25231a; }
.header-logo { .header-logo {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1661,6 +1784,35 @@
align-items: start; align-items: start;
} }
.study-breadcrumbs {
margin-bottom: 0.75rem;
}
.study-breadcrumbs-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0;
font-size: 0.8rem;
}
.study-breadcrumbs-link {
color: #7a7060;
text-decoration: none;
transition: color 0.15s;
}
.study-breadcrumbs-link:hover {
color: var(--brand-gold);
}
.study-breadcrumbs-current {
color: #b9b09b;
}
.study-breadcrumbs-sep {
color: #3a3320;
padding: 0 0.25rem;
}
.study-detail-back { .study-detail-back {
display: inline-block; display: inline-block;
margin-bottom: 0.9rem; margin-bottom: 0.9rem;
@@ -1736,6 +1888,319 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
/* ── Reflection Questions (quiz page) ───────────────────────────────────── */
.quiz-question-block {
margin-bottom: 1.5rem;
}
.quiz-question-label {
margin: 0 0 0.5rem;
font-weight: 600;
color: var(--brand-warm-white);
line-height: 1.5;
}
.quiz-question-textarea {
width: 100%;
box-sizing: border-box;
padding: 0.9rem;
border-radius: 12px;
border: 1px solid #2a2518;
background: #14130f;
color: #f0ead8;
font-family: var(--brand-font-body);
font-size: 0.95rem;
resize: vertical;
}
.quiz-question-textarea:focus {
outline: none;
border-color: #c8952a;
}
.quiz-share-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
cursor: pointer;
user-select: none;
}
.quiz-share-toggle input[type="checkbox"] {
accent-color: #e0b840;
width: 15px;
height: 15px;
flex-shrink: 0;
}
.quiz-share-label {
font-size: 0.82rem;
color: #8a7f5a;
}
.quiz-share-label--done {
color: #6fcf97;
}
/* ── Study Section Comments ──────────────────────────────────────────────── */
.study-comments {
margin-top: 0.5rem;
}
.study-comments-heading {
font-family: var(--brand-font-header);
font-size: 1.15rem;
color: var(--brand-gold);
margin: 0 0 1rem;
}
.study-comments-loading,
.study-comments-empty,
.study-comments-enroll-note {
font-family: var(--brand-font-body);
color: #7a7060;
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.study-comments-enroll-note a {
color: var(--brand-gold);
text-decoration: underline;
}
.study-comments-list {
list-style: none;
margin: 0 0 1.25rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.study-comment {
background: #14130f;
border: 1px solid #2a2518;
border-radius: 10px;
padding: 0.85rem 1rem;
position: relative;
}
.study-comment-header {
display: flex;
gap: 0.75rem;
align-items: baseline;
margin-bottom: 0.4rem;
}
.study-comment-author {
font-family: var(--brand-font-header);
font-size: 0.9rem;
color: #e0c88a;
}
.study-comment-date {
font-family: var(--brand-font-body);
font-size: 0.78rem;
color: #5a5040;
}
.study-comment-text {
font-family: var(--brand-font-body);
font-size: 0.9rem;
color: #c8bfa8;
margin: 0;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.study-comment-delete {
margin-top: 0.5rem;
background: none;
border: none;
cursor: pointer;
font-size: 0.78rem;
color: #5a5040;
padding: 0;
text-decoration: underline;
}
.study-comment-delete:hover { color: #e07a7a; }
.study-comment-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.study-comment-form-label {
font-family: var(--brand-font-body);
font-size: 0.85rem;
color: #8a7f5a;
}
.study-comment-textarea {
width: 100%;
box-sizing: border-box;
background: #14130f;
border: 1px solid #2a2518;
border-radius: 10px;
color: #f0ead8;
font-family: var(--brand-font-body);
font-size: 0.9rem;
padding: 0.75rem 1rem;
resize: vertical;
}
.study-comment-textarea:focus {
outline: none;
border-color: #c8952a;
}
.study-comment-form-footer {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.study-comment-char-count {
font-size: 0.78rem;
color: #5a5040;
margin-right: auto;
}
.study-comment-error {
font-size: 0.85rem;
color: #e07a7a;
}
.study-comment-success {
font-size: 0.85rem;
color: #6fcf97;
}
.study-comment-submit {
background: var(--brand-gold);
color: #14100a;
border: none;
border-radius: 8px;
padding: 0.5rem 1.25rem;
font-family: var(--brand-font-header);
font-size: 0.85rem;
cursor: pointer;
font-weight: 700;
}
.study-comment-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Study Certificate Panel ──────────────────────────────────────────────── */
.study-cert-panel {
margin-top: 1.5rem;
background: #14130f;
border: 1px solid #2a2518;
border-radius: 14px;
padding: 1.5rem;
}
.study-cert-heading {
font-family: var(--brand-font-header);
font-size: 1.1rem;
color: var(--brand-gold);
margin: 0 0 1rem;
}
.study-cert-loading {
font-size: 0.9rem;
color: #7a7060;
}
.study-cert-progress-label {
font-size: 0.9rem;
color: #8a7f5a;
margin: 0 0 0.5rem;
}
.study-cert-progress-bar-wrap {
background: #27231b;
border-radius: 999px;
height: 10px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.study-cert-progress-bar {
height: 100%;
background: #e0c070;
border-radius: 999px;
}
.study-cert-note {
font-size: 0.85rem;
color: #5a5040;
margin: 0;
}
.study-cert-eligible {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.study-cert-congrats {
font-size: 0.95rem;
color: #c8bfa8;
margin: 0;
}
.study-cert-error {
font-size: 0.85rem;
color: #e07a7a;
}
.study-cert-btn {
background: var(--brand-gold);
color: #14100a;
border: none;
border-radius: 8px;
padding: 0.6rem 1.4rem;
font-family: var(--brand-font-header);
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
}
.study-cert-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.study-cert-btn--secondary {
background: transparent;
color: var(--brand-gold);
border: 1px solid var(--brand-gold);
}
.study-cert-issued {
display: flex;
flex-direction: column;
gap: 1rem;
}
.study-cert-issued-label {
font-size: 0.85rem;
color: #7a7060;
margin: 0;
}
.study-cert-canvas {
width: 100%;
max-width: 900px;
height: auto;
border-radius: 8px;
}
.study-cert-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
/* ── Public Certificate Page ──────────────────────────────────────────────── */
.public-cert-page .thanks-card {
max-width: 540px;
}
.public-cert-title {
font-size: 1.8rem;
color: var(--brand-gold);
margin: 0.5rem 0;
}
.public-cert-divider {
width: 100%;
height: 1px;
background: #2a2518;
margin: 1rem 0;
}
.public-cert-label {
font-size: 0.9rem;
color: #7a7060;
margin: 0.25rem 0;
}
.public-cert-name {
font-family: var(--brand-font-header);
font-size: 1.6rem;
color: var(--brand-warm-white);
margin: 0.25rem 0 0.75rem;
}
.public-cert-study {
font-family: var(--brand-font-header);
font-size: 1.2rem;
color: #e0c88a;
margin: 0.25rem 0 0.75rem;
}
.public-cert-date {
font-size: 0.85rem;
color: #7a7060;
margin: 0;
}
.study-track-switcher { .study-track-switcher {
margin: 1rem 0; margin: 1rem 0;
} }
@@ -2853,6 +3318,61 @@
padding: 2rem 0; padding: 2rem 0;
} }
.download-filter-bar {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 2rem;
}
/* Related episodes on download detail pages */
.download-related-episodes {
margin-top: 2.5rem;
padding-top: 2rem;
border-top: 1px solid #2a2518;
text-align: left;
}
.download-related-heading {
font-size: 1.1rem;
font-weight: 700;
color: #f0e9cc;
margin: 0 0 1rem;
}
.download-related-list {
display: grid;
gap: 0.6rem;
}
.download-related-ep {
display: block;
padding: 0.7rem 0.9rem;
border: 1px solid #2a2518;
border-radius: 10px;
text-decoration: none;
background: #11100d;
transition: border-color 0.15s, background 0.15s;
}
.download-related-ep:hover {
border-color: rgba(201,168,76,0.4);
background: rgba(201,168,76,0.05);
}
.download-related-ep-meta {
display: flex;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.download-related-ep-title {
margin: 0;
font-size: 0.9rem;
color: #f0ead8;
line-height: 1.4;
}
.download-library-group { .download-library-group {
background: rgba(17, 17, 17, 0.82); background: rgba(17, 17, 17, 0.82);
border: 1px solid rgba(201, 168, 76, 0.14); border: 1px solid rgba(201, 168, 76, 0.14);
@@ -3461,6 +3981,59 @@
font-size: 1rem; font-size: 1rem;
} }
/* Enrollment Funnel */
.admin-funnel {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.admin-funnel-step {
display: flex;
align-items: center;
gap: 1rem;
}
.admin-funnel-bar-wrap {
flex: 1;
background: #2a2620;
border-radius: 4px;
height: 20px;
overflow: hidden;
}
.admin-funnel-bar {
height: 100%;
background: linear-gradient(90deg, #e0b840, #c8952a);
border-radius: 4px;
transition: width 0.4s ease;
min-width: 4px;
}
.admin-funnel-label {
display: flex;
gap: 0.75rem;
align-items: center;
min-width: 260px;
}
.admin-funnel-step-name {
font-family: var(--brand-font-body);
color: var(--brand-warm-white);
font-size: 0.9rem;
flex: 1;
}
.admin-funnel-count {
font-family: var(--brand-font-header);
color: #e0b840;
font-size: 1rem;
min-width: 40px;
text-align: right;
}
.admin-funnel-pct {
font-family: var(--brand-font-body);
color: #8a7f5a;
font-size: 0.85rem;
min-width: 36px;
text-align: right;
}
.admin-stats-lists { .admin-stats-lists {
margin-top: 1rem; margin-top: 1rem;
display: grid; display: grid;
@@ -4527,6 +5100,10 @@
position: relative; position: relative;
} }
.site-search-bar-inner {
padding: 0.4rem 1rem;
}
.header-menu-btn { .header-menu-btn {
display: inline-flex; display: inline-flex;
margin-left: auto; margin-left: auto;
@@ -4971,6 +5548,45 @@
cursor: pointer; cursor: pointer;
} }
.qa-card-scene--pinned {
border-color: rgba(201, 168, 76, 0.4) !important;
box-shadow: 0 0 0 1px rgba(201,168,76,0.15);
}
.qa-pinned-badge {
font-size: 0.85em;
margin-right: 0.15rem;
}
.qa-upvote-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 999px;
padding: 0.28rem 0.65rem;
font-size: 0.73rem;
font-family: var(--brand-font-body);
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
border: 1px solid rgba(201,168,76,0.3);
background: transparent;
color: #c9a84c;
transition: background 0.15s, border-color 0.15s;
}
.qa-upvote-btn:hover:not(:disabled) {
background: rgba(201,168,76,0.12);
border-color: #c9a84c;
}
.qa-upvote-btn--voted {
background: rgba(201,168,76,0.1);
border-color: rgba(201,168,76,0.5);
color: #a08030;
cursor: default;
}
.qa-card-actions { .qa-card-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5725,6 +6341,124 @@
margin: 0; margin: 0;
} }
/* Study comments moderation panel */
.admin-comment-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.75rem;
}
.admin-comment-card {
background: #14130f;
border: 1px solid #2a2518;
border-radius: 10px;
padding: 1rem 1.25rem;
}
.admin-comment-card--pending {
border-color: #5a3a1a;
background: #160f08;
}
.admin-comment-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.admin-comment-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
align-items: baseline;
}
.admin-comment-author {
font-family: var(--brand-font-header);
font-size: 0.9rem;
color: var(--brand-warm-white);
}
.admin-comment-location {
font-size: 0.78rem;
color: #8a7f5a;
}
.admin-comment-date {
font-size: 0.75rem;
color: #5a5040;
}
.admin-comment-badge {
font-size: 0.72rem;
font-weight: 700;
padding: 0.2rem 0.55rem;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.admin-comment-badge--pending {
background: #3a2010;
color: #e0903a;
}
.admin-comment-badge--approved {
background: #1a3a2a;
color: #6fcf97;
}
.admin-comment-text {
font-size: 0.9rem;
color: #c8bfa8;
margin: 0 0 0.75rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.admin-comment-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Episode scripts admin panel */
.admin-script-upload-form {
display: grid;
gap: 1rem;
max-width: 560px;
margin-bottom: 1rem;
}
.admin-script-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
}
.admin-script-row {
display: flex;
align-items: center;
gap: 1rem;
background: #14130f;
border: 1px solid #2a2518;
border-radius: 8px;
padding: 0.65rem 1rem;
}
.admin-script-row-info {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
}
.admin-script-ep {
font-family: var(--brand-font-header);
font-size: 0.85rem;
color: var(--brand-gold);
min-width: 48px;
}
.admin-script-title {
font-size: 0.9rem;
color: var(--brand-warm-white);
}
.admin-script-meta {
font-size: 0.78rem;
color: #5a5040;
width: 100%;
}
.admin-sidebar-backdrop { .admin-sidebar-backdrop {
display: none; display: none;
} }
+215 -24
View File
@@ -8,6 +8,9 @@ import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySect
import { SpotifyIcon } from './icons' import { SpotifyIcon } from './icons'
import type { SiteContent, ArchivedSeries, StudyProgram } from './content' import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
import { DEFAULTS } from './content' import { DEFAULTS } from './content'
import { usePageMeta } from './hooks/usePageMeta'
import { useGlobalSearch } from './hooks/useGlobalSearch'
import { GlobalSearch } from './components/GlobalSearch'
import './App.css' import './App.css'
const SPOTIFY_EMBED_URL = const SPOTIFY_EMBED_URL =
@@ -459,6 +462,17 @@ function formatPubDate(raw: string): string {
} }
} }
function SiteSearchBar({ content }: { content: SiteContent }) {
const { query, setQuery, results } = useGlobalSearch(content)
return (
<div className="site-search-bar">
<div className="site-search-bar-inner">
<GlobalSearch query={query} setQuery={setQuery} results={results} />
</div>
</div>
)
}
function SiteHeader({ content }: { content: SiteContent }) { function SiteHeader({ content }: { content: SiteContent }) {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const spotifyUrl = content.platformSpotifyUrl || '/spotify' const spotifyUrl = content.platformSpotifyUrl || '/spotify'
@@ -783,39 +797,72 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources') const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0) const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0)
const [activeTag, setActiveTag] = useState<string | null>(null) const [activeTag, setActiveTag] = useState<string | null>(null)
const [activeType, setActiveType] = useState<'all' | 'resource' | 'series'>('all')
if (resources.length === 0 && archivedWithResources.length === 0) return null if (resources.length === 0 && archivedWithResources.length === 0) return null
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean) const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const filteredResources = activeTag ? resources.filter(r => (r.tags ?? []).includes(activeTag)) : resources
// Filter resources
const filteredResources = resources.filter(r => {
const tagOk = !activeTag || (r.tags ?? []).includes(activeTag)
const typeOk = activeType === 'all' || activeType === 'resource'
return tagOk && typeOk
})
// Filter archived series
const filteredArchived = archivedWithResources.filter(() => {
return activeType === 'all' || activeType === 'series'
})
const hasTypeFilter = resources.length > 0 && archivedWithResources.length > 0
return ( return (
<section className="section-resources section-download-library" aria-label="Download library"> <section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner"> <div className="section-inner">
<div className="download-library-head"> <div className="download-library-head">
<p className="eyebrow">Download Library</p> <p className="eyebrow">Download Library</p>
<h2 className="section-heading">More guides, worksheets, and past study downloads.</h2> <h2 className="section-heading">Guides, worksheets, and past study downloads.</h2>
</div> </div>
{allTags.length > 0 && ( {/* Type + tag filter bar */}
<div className="download-tag-filter"> <div className="download-filter-bar">
<button {hasTypeFilter && (
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`} <div className="download-tag-filter" style={{ marginBottom: allTags.length > 0 ? '0.5rem' : 0 }}>
onClick={() => setActiveTag(null)} {(['all', 'resource', 'series'] as const).map(t => (
> <button
All key={t}
</button> type="button"
{allTags.map(tag => ( className={`download-tag-chip${activeType === t ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveType(t)}
>
{t === 'all' ? 'All types' : t === 'resource' ? 'Study guides' : 'Past series'}
</button>
))}
</div>
)}
{allTags.length > 0 && (activeType === 'all' || activeType === 'resource') && (
<div className="download-tag-filter">
<button <button
key={tag} type="button"
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`} className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(prev => prev === tag ? null : tag)} onClick={() => setActiveTag(null)}
> >
{tag} All topics
</button> </button>
))} {allTags.map(tag => (
</div> <button
)} key={tag}
type="button"
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
>
{tag}
</button>
))}
</div>
)}
</div>
{filteredResources.length > 0 && ( {filteredResources.length > 0 && (
<div className="download-library-group"> <div className="download-library-group">
@@ -824,7 +871,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link"> <Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header"> <div className="resource-download-header">
{resource.imageUrl && ( {resource.imageUrl && (
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" /> <img src={resource.imageUrl} alt={resource.label} className="resource-link-image" loading="lazy" />
)} )}
<div className="resource-download-meta"> <div className="resource-download-meta">
<span className="resource-link-label">{resource.label}</span> <span className="resource-link-label">{resource.label}</span>
@@ -841,17 +888,17 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
</div> </div>
)} )}
{activeTag !== null && filteredResources.length === 0 && ( {activeTag !== null && filteredResources.length === 0 && activeType !== 'series' && (
<p className="download-empty-state">No downloads tagged "{activeTag}".</p> <p className="download-empty-state">No downloads tagged "{activeTag}".</p>
)} )}
{archivedWithResources.length > 0 && ( {filteredArchived.length > 0 && (
<div className="download-library-group"> <div className="download-library-group">
<div className="download-library-group-head"> <div className="download-library-group-head">
<h3>Previous Studies</h3> <h3>Previous Studies</h3>
<p>Downloads from earlier series that you still want available.</p> <p>Downloads from earlier series.</p>
</div> </div>
{archivedWithResources.map(series => ( {filteredArchived.map(series => (
<div key={series.id} className="archive-series-resources"> <div key={series.id} className="archive-series-resources">
<div className="download-library-series-head"> <div className="download-library-series-head">
<h4>{series.title || 'Archived Study'}</h4> <h4>{series.title || 'Archived Study'}</h4>
@@ -862,7 +909,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
<Link key={link.id} to={`/downloads/${buildArchivedDownloadPageId(series.id, link.id)}`} className="resource-download-card resource-download-card--link"> <Link key={link.id} to={`/downloads/${buildArchivedDownloadPageId(series.id, link.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header"> <div className="resource-download-header">
{series.imageUrl && ( {series.imageUrl && (
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" /> <img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" loading="lazy" />
)} )}
<div className="resource-download-meta"> <div className="resource-download-meta">
<span className="resource-link-label">{link.label || series.title || 'Download Resource'}</span> <span className="resource-link-label">{link.label || series.title || 'Download Resource'}</span>
@@ -882,10 +929,43 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
) )
} }
function useEpisodesForSeries(seriesId: string | null, archivedSeries: ArchivedSeries[]) {
const [episodes, setEpisodes] = useState<Episode[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!seriesId) return
const series = archivedSeries.find(s => s.id === seriesId)
if (!series?.episodeRange) return
setLoading(true)
fetch('/api/episodes/all')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: Episode[] }) => {
const { from, to } = series.episodeRange!
const filtered = (data.episodes ?? []).filter(ep => {
const num = parseInt(ep.episode, 10)
return !isNaN(num) && num >= from && num <= to
})
setEpisodes(filtered)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [seriesId])
return { episodes, loading }
}
function DownloadDetailPage({ content }: { content: SiteContent }) { function DownloadDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const resource = resolveDownloadPageResource(content, id) const resource = resolveDownloadPageResource(content, id)
// Resolve related series for archived resources
const archivedSeriesId = id?.startsWith('archived--') ? id.split('--')[1] : null
const relatedSeries = archivedSeriesId ? (content.archivedSeries ?? []).find(s => s.id === archivedSeriesId) : null
const { episodes: relatedEpisodes, loading: epsLoading } = useEpisodesForSeries(archivedSeriesId, content.archivedSeries ?? [])
const spotifyUrl = content.platformSpotifyUrl || '/episodes'
if (!resource) { if (!resource) {
return ( return (
<main className="thanks-page" aria-label="Download not found"> <main className="thanks-page" aria-label="Download not found">
@@ -928,6 +1008,40 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
)} )}
<Link to="/resources" className="btn-secondary">Back to Downloads</Link> <Link to="/resources" className="btn-secondary">Back to Downloads</Link>
</div> </div>
{relatedSeries && (relatedEpisodes.length > 0 || epsLoading) && (
<div className="download-related-episodes">
<h2 className="download-related-heading">
Episodes from {relatedSeries.title}
</h2>
{epsLoading ? (
<p style={{ color: '#7a7060', fontSize: '0.9rem' }}>Loading episodes</p>
) : (
<div className="download-related-list">
{relatedEpisodes.slice(0, 10).map((ep, idx) => (
<a
key={idx}
href={ep.link || spotifyUrl}
target="_blank"
rel="noreferrer"
className="download-related-ep"
>
<div className="download-related-ep-meta">
{ep.episode && <span className="episode-number">Ep. {ep.episode}</span>}
{ep.pubDate && <span className="episode-date">{formatPubDate(ep.pubDate)}</span>}
</div>
<p className="download-related-ep-title">{ep.title}</p>
</a>
))}
{relatedEpisodes.length > 10 && (
<Link to="/episodes" className="btn-secondary" style={{ marginTop: '0.5rem', display: 'inline-block' }}>
View all {relatedEpisodes.length} episodes
</Link>
)}
</div>
)}
</div>
)}
</div> </div>
</main> </main>
) )
@@ -1175,6 +1289,7 @@ function LandingPage({ content }: { content: SiteContent }) {
return ( return (
<div className="site"> <div className="site">
<SiteHeader content={content} /> <SiteHeader content={content} />
<SiteSearchBar content={content} />
<CustomBlocksSection content={content} page="homepage" /> <CustomBlocksSection content={content} page="homepage" />
{/* ── HERO ── */} {/* ── HERO ── */}
@@ -1417,6 +1532,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
return ( return (
<div className="site"> <div className="site">
<SiteHeader content={content} /> <SiteHeader content={content} />
<SiteSearchBar content={content} />
<section className="section-player"> <section className="section-player">
<div className="section-inner" style={{ textAlign: 'center', padding: '4rem 1rem' }}> <div className="section-inner" style={{ textAlign: 'center', padding: '4rem 1rem' }}>
<h2>Episode not found</h2> <h2>Episode not found</h2>
@@ -1435,6 +1551,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
return ( return (
<div className="site"> <div className="site">
<SiteHeader content={content} /> <SiteHeader content={content} />
<SiteSearchBar content={content} />
<section className="section-episode-detail" aria-label={episode.title}> <section className="section-episode-detail" aria-label={episode.title}>
<div className="section-inner"> <div className="section-inner">
<Link to="/episodes" className="episode-detail-back"> Back to Episodes</Link> <Link to="/episodes" className="episode-detail-back"> Back to Episodes</Link>
@@ -1494,6 +1611,11 @@ function EpisodesPage({ content }: { content: SiteContent }) {
const [allEpisodes, setAllEpisodes] = useState<Episode[]>([]) const [allEpisodes, setAllEpisodes] = useState<Episode[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Episodes | ${siteTitle}`,
content.episodesSeoIntro || 'Browse all episodes of Verse by Verse with Nate — expository Bible teaching, one verse at a time.',
)
useEffect(() => { useEffect(() => {
fetch('/api/episodes/all') fetch('/api/episodes/all')
@@ -1589,6 +1711,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
return ( return (
<div className="site"> <div className="site">
<SiteHeader content={content} /> <SiteHeader content={content} />
<SiteSearchBar content={content} />
<section className="section-player" aria-label="Current series episodes"> <section className="section-player" aria-label="Current series episodes">
<div className="section-inner"> <div className="section-inner">
@@ -1669,6 +1792,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
) )
})} })}
<HomepageNewsletterSection />
<PodcastHighlightsSection content={content} /> <PodcastHighlightsSection content={content} />
<CustomBlocksSection content={content} page="episodes" /> <CustomBlocksSection content={content} page="episodes" />
<SiteFooter content={content} /> <SiteFooter content={content} />
@@ -1678,9 +1802,15 @@ function EpisodesPage({ content }: { content: SiteContent }) {
} }
function ResourcesPage({ content }: { content: SiteContent }) { function ResourcesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Resources & Downloads | ${siteTitle}`,
'Study guides, sermon notes, and free resources from Verse by Verse with Nate.',
)
return ( return (
<div className="site"> <div className="site">
<SiteHeader content={content} /> <SiteHeader content={content} />
<SiteSearchBar content={content} />
<StudyGuideSection content={content} /> <StudyGuideSection content={content} />
<DownloadLibrarySection content={content} /> <DownloadLibrarySection content={content} />
<CustomBlocksSection content={content} page="downloads" /> <CustomBlocksSection content={content} page="downloads" />
@@ -1692,9 +1822,15 @@ function ResourcesPage({ content }: { content: SiteContent }) {
} }
function AboutPage({ content }: { content: SiteContent }) { function AboutPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`About | ${siteTitle}`,
content.aboutNate || 'Learn about Verse by Verse with Nate — expository Bible teaching from Nate Emmert.',
)
return ( return (
<div className="site"> <div className="site">
<SiteHeader content={content} /> <SiteHeader content={content} />
<SiteSearchBar content={content} />
<AboutSection content={content} /> <AboutSection content={content} />
<CustomBlocksSection content={content} page="about" /> <CustomBlocksSection content={content} page="about" />
<SiteFooter content={content} /> <SiteFooter content={content} />
@@ -1707,6 +1843,7 @@ function ContactPage({ content }: { content: SiteContent }) {
return ( return (
<div className="site"> <div className="site">
<SiteHeader content={content} /> <SiteHeader content={content} />
<SiteSearchBar content={content} />
<ContactSection content={content} /> <ContactSection content={content} />
<CustomBlocksSection content={content} page="contact" /> <CustomBlocksSection content={content} page="contact" />
<SiteFooter content={content} /> <SiteFooter content={content} />
@@ -1925,6 +2062,58 @@ function LegalPage({ title, body }: { title: string; body: string[] }) {
) )
} }
function PublicCertificatePage() {
const { token } = useParams<{ token: string }>()
const [cert, setCert] = useState<{ studyTitle: string; displayName: string; issuedAt: string } | null>(null)
const [status, setStatus] = useState<'loading' | 'not-found' | 'ready'>('loading')
useEffect(() => {
if (!token) { setStatus('not-found'); return }
fetch(`/api/public/certificate/${encodeURIComponent(token)}`)
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { setCert(data); setStatus('ready') })
.catch(() => setStatus('not-found'))
}, [token])
usePageMeta(
cert ? `Certificate of Completion ${cert.studyTitle}` : 'Certificate',
cert ? `${cert.displayName} completed ${cert.studyTitle}` : undefined,
)
if (status === 'loading') {
return <main className="thanks-page"><div className="thanks-card"><p>Loading certificate</p></div></main>
}
if (status === 'not-found' || !cert) {
return (
<main className="thanks-page">
<div className="thanks-card">
<h1>Certificate Not Found</h1>
<p>This certificate link is invalid or has been removed.</p>
<Link to="/" className="btn-primary">Back to Site</Link>
</div>
</main>
)
}
return (
<main className="thanks-page public-cert-page" aria-label="Certificate of Completion">
<div className="thanks-card public-cert-card">
<p className="eyebrow">Verse by Verse with Nate</p>
<h1 className="public-cert-title">Certificate of Completion</h1>
<div className="public-cert-divider" />
<p className="public-cert-label">This certifies that</p>
<p className="public-cert-name">{cert.displayName}</p>
<p className="public-cert-label">has successfully completed</p>
<p className="public-cert-study">{cert.studyTitle}</p>
<p className="public-cert-date">
Awarded on {new Date(cert.issuedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</p>
<Link to="/" className="btn-primary" style={{ marginTop: '2rem' }}>Visit Verse by Verse with Nate</Link>
</div>
</main>
)
}
function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: SiteContent) => void }) { function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: SiteContent) => void }) {
const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking') const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
const [adminContent, setAdminContent] = useState<SiteContent>(content) const [adminContent, setAdminContent] = useState<SiteContent>(content)
@@ -2203,6 +2392,7 @@ function StudyRouteFrame({ content, child }: { content: SiteContent; child: Reac
return ( return (
<> <>
<SiteHeader content={content} /> <SiteHeader content={content} />
<SiteSearchBar content={content} />
{child} {child}
<SiteFooter content={content} /> <SiteFooter content={content} />
</> </>
@@ -2303,6 +2493,7 @@ export default function App() {
<Route path="/thanks" element={<ThankYouPage />} /> <Route path="/thanks" element={<ThankYouPage />} />
<Route path="/subscribe" element={<SubscribePage />} /> <Route path="/subscribe" element={<SubscribePage />} />
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} /> <Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} /> <Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
<Route path="/preview" element={<PreviewPage />} /> <Route path="/preview" element={<PreviewPage />} />
<Route <Route
+111 -30
View File
@@ -2,6 +2,10 @@ import { useEffect, useMemo, useState, useCallback } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { SiteContent, StudyProgram, StudySection } from './content' import type { SiteContent, StudyProgram, StudySection } from './content'
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData' import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData'
import { usePageMeta } from './hooks/usePageMeta'
import { Breadcrumbs } from './components/Breadcrumbs'
import { StudySectionComments } from './components/StudySectionComments'
import { StudyCertificate } from './components/StudyCertificate'
type Props = { content: SiteContent } type Props = { content: SiteContent }
@@ -726,7 +730,8 @@ export function StudyLandingPage({ content }: Props) {
const studyMeta = getStudyBySlug(studies, study.slug) const studyMeta = getStudyBySlug(studies, study.slug)
const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0 const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0
return ( return (
<article key={study.slug} className="study-module-row"> <div key={study.slug}>
<article className="study-module-row">
<div className="study-module-row-left"> <div className="study-module-row-left">
<p className="study-module-lesson">{study.enrolled ? 'Enrolled' : 'Track'}</p> <p className="study-module-lesson">{study.enrolled ? 'Enrolled' : 'Track'}</p>
<h3>{study.title}</h3> <h3>{study.title}</h3>
@@ -751,6 +756,10 @@ export function StudyLandingPage({ content }: Props) {
</div> </div>
</div> </div>
</article> </article>
{progress === 100 && (
<StudyCertificate studySlug={study.slug} />
)}
</div>
) )
})} })}
</div> </div>
@@ -1209,10 +1218,16 @@ export function ColossiansStudyIndexPage({ content }: Props) {
} }
} }
usePageMeta(
`${study.title} | Bible Study`,
study.description,
)
return ( return (
<main className="study-index-page" aria-label={`${study.title} study`}> <main className="study-index-page" aria-label={`${study.title} study`}>
<section className="section-study-course-hero"> <section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner"> <div className="section-inner study-course-hero-inner">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title }]} />
<p className="eyebrow">Online Bible Class</p> <p className="eyebrow">Online Bible Class</p>
<h1>{study.title}</h1> <h1>{study.title}</h1>
<p className="study-course-hero-copy">{study.description}</p> <p className="study-course-hero-copy">{study.description}</p>
@@ -1303,7 +1318,13 @@ export function ColossiansStudyIndexPage({ content }: Props) {
{!coming && !enrolled && <span style={{ backgroundColor: '#ef5350', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Enroll to open</span>} {!coming && !enrolled && <span style={{ backgroundColor: '#ef5350', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Enroll to open</span>}
{lockedByProgress && <span style={{ backgroundColor: '#5a5440', color: '#f0e9cc', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Complete previous lesson</span>} {lockedByProgress && <span style={{ backgroundColor: '#5a5440', color: '#f0e9cc', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Complete previous lesson</span>}
{enrolled && !lockedByProgress && studyProgress.completedSectionIds.includes(section.id) && ( {enrolled && !lockedByProgress && studyProgress.completedSectionIds.includes(section.id) && (
<span style={{ backgroundColor: '#2196f3', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Completed</span> <span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', backgroundColor: '#1a3a2a', color: '#6fcf97', padding: '0.2rem 0.55rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
<circle cx="6.5" cy="6.5" r="6" stroke="#6fcf97" strokeWidth="1.2" />
<path d="M3.5 6.5l2 2 3.5-3.5" stroke="#6fcf97" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Completed
</span>
)} )}
</div> </div>
<h3>{section.title}</h3> <h3>{section.title}</h3>
@@ -1369,9 +1390,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
const isEnrolled = isEnrolledInStudy(auth, currentStudySlug) const isEnrolled = isEnrolledInStudy(auth, currentStudySlug)
const lessonCompleted = section?.id ? completedSectionIds.includes(section.id) : false const lessonCompleted = section?.id ? completedSectionIds.includes(section.id) : false
useEffect(() => { usePageMeta(
document.title = section && study ? `${section.title} | ${study.title}` : 'Study' section && study ? `${section.title} | ${study.title}` : 'Study',
}, [section, study]) section?.summary,
)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -1655,6 +1677,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}> <section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
<div className="section-inner study-classroom-shell"> <div className="section-inner study-classroom-shell">
<div className="study-classroom-main"> <div className="study-classroom-main">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: `Lesson ${lessonNumber}` }]} />
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link> <Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
<p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p> <p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p>
<h1>{section.title}</h1> <h1>{section.title}</h1>
@@ -1695,14 +1718,17 @@ export function ColossiansStudySectionPage({ content }: Props) {
</article> </article>
<article className="study-class-block"> <article className="study-class-block">
<h2>Discussion Questions</h2> <h2>Reflection Questions</h2>
<p className="study-detail-copy" style={{ marginBottom: '0.75rem', color: '#8a7f5a', fontSize: '0.9rem' }}>
Work through these on your own, then share your thoughts in the Discussion below.
</p>
<ol className="study-detail-list"> <ol className="study-detail-list">
{section.studyQuestions.map((question, index) => ( {section.studyQuestions.map((question, index) => (
<li key={index}>{question}</li> <li key={index}>{question}</li>
))} ))}
</ol> </ol>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}> <div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to={`/study/${study.slug}/${section.id}/quiz`} className="btn-secondary">Discussion Questions</Link> <Link to={`/study/${study.slug}/${section.id}/quiz`} className="btn-secondary">Write My Answers</Link>
<Link to={`/study/${study.slug}/community?sectionId=${encodeURIComponent(section.id)}`} className="btn-primary">Go to Community</Link> <Link to={`/study/${study.slug}/community?sectionId=${encodeURIComponent(section.id)}`} className="btn-primary">Go to Community</Link>
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link> <Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
</div> </div>
@@ -1782,6 +1808,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
</article> </article>
) )
})()} })()}
<article className="study-class-block">
<StudySectionComments
studySlug={currentStudySlug}
sectionId={section.id}
isEnrolled={isEnrolled}
/>
</article>
</div> </div>
<aside className="study-classroom-sidebar" aria-label="Lesson tools"> <aside className="study-classroom-sidebar" aria-label="Lesson tools">
@@ -1967,9 +2001,10 @@ export function ColossiansStudyNotesPage({ content }: Props) {
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [activeNoteTab, setActiveNoteTab] = useState('') const [activeNoteTab, setActiveNoteTab] = useState('')
useEffect(() => { usePageMeta(
document.title = study ? `My Notes | ${study.title}` : 'My Study Notes' study ? `My Notes | ${study.title}` : 'My Study Notes',
}, [study]) study ? `Your personal lesson notes for ${study.title}.` : undefined,
)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -2048,6 +2083,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
<main className="study-section-page" aria-label="My study notes"> <main className="study-section-page" aria-label="My study notes">
<section className="section-study-classroom"> <section className="section-study-classroom">
<div className="section-inner"> <div className="section-inner">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: 'My Notes' }]} />
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link> <Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
<p className="study-lesson-label">Student Workspace</p> <p className="study-lesson-label">Student Workspace</p>
<h1>My Lesson Notes</h1> <h1>My Lesson Notes</h1>
@@ -2976,6 +3012,8 @@ export function StudyQuizPage({ content }: Props) {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [shareToDiscussion, setShareToDiscussion] = useState<Record<number, boolean>>({})
const [sharedIndexes, setSharedIndexes] = useState<Set<number>>(new Set())
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -3029,7 +3067,37 @@ export function StudyQuizPage({ content }: Props) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }), body: JSON.stringify({ answers }),
}) })
setMessage('Answers saved.')
// Post any checked answers to the shared discussion
const toShare = Object.entries(shareToDiscussion)
.filter(([, checked]) => checked)
.map(([idx]) => Number(idx))
.filter(idx => !sharedIndexes.has(idx) && (answers[idx] ?? '').trim().length >= 2)
const newlyShared: number[] = []
for (const idx of toShare) {
const questionText = section.studyQuestions?.[idx] ? `**${section.studyQuestions[idx]}**\n\n` : ''
const text = `${questionText}${answers[idx].trim()}`
try {
const res = await fetch(`/api/study-comments/${encodeURIComponent(studySlug)}/${encodeURIComponent(sectionId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.slice(0, 2000) }),
})
if (res.ok) newlyShared.push(idx)
} catch { /* ignore individual share failures */ }
}
if (newlyShared.length > 0) {
setSharedIndexes(prev => new Set([...prev, ...newlyShared]))
setShareToDiscussion(prev => {
const next = { ...prev }
newlyShared.forEach(idx => { next[idx] = false })
return next
})
}
setMessage(newlyShared.length > 0 ? `Answers saved and ${newlyShared.length} shared to the discussion.` : 'Answers saved.')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save answers.') setError(err instanceof Error ? err.message : 'Unable to save answers.')
} finally { } finally {
@@ -3041,9 +3109,9 @@ export function StudyQuizPage({ content }: Props) {
return ( return (
<main className="thanks-page" aria-label="Discussion questions not found"> <main className="thanks-page" aria-label="Discussion questions not found">
<div className="thanks-card"> <div className="thanks-card">
<p className="eyebrow">Discussion Questions</p> <p className="eyebrow">Reflection Questions</p>
<h1>Discussion questions not found</h1> <h1>Discussion questions not found</h1>
<p>The discussion questions you requested are not available.</p> <p>The reflection questions you requested are not available.</p>
<Link to="/study" className="btn-primary">Back to Studies</Link> <Link to="/study" className="btn-primary">Back to Studies</Link>
</div> </div>
</main> </main>
@@ -3056,8 +3124,8 @@ export function StudyQuizPage({ content }: Props) {
if (!auth.checked) { if (!auth.checked) {
return ( return (
<main className="thanks-page" aria-label="Loading discussion questions"> <main className="thanks-page" aria-label="Loading reflection questions">
<div className="thanks-card"><p>Loading discussion questions...</p></div> <div className="thanks-card"><p>Loading reflection questions...</p></div>
</main> </main>
) )
} }
@@ -3066,7 +3134,7 @@ export function StudyQuizPage({ content }: Props) {
return ( return (
<main className="thanks-page" aria-label="Sign in required"> <main className="thanks-page" aria-label="Sign in required">
<div className="thanks-card"> <div className="thanks-card">
<p className="eyebrow">Discussion Questions</p> <p className="eyebrow">Reflection Questions</p>
<h1>Sign In Required</h1> <h1>Sign In Required</h1>
<p>You need to sign in before you can view and save your discussion answers.</p> <p>You need to sign in before you can view and save your discussion answers.</p>
<Link to="/study/signup" className="btn-primary">Sign In</Link> <Link to="/study/signup" className="btn-primary">Sign In</Link>
@@ -3079,9 +3147,9 @@ export function StudyQuizPage({ content }: Props) {
return ( return (
<main className="thanks-page" aria-label="Enrollment required"> <main className="thanks-page" aria-label="Enrollment required">
<div className="thanks-card"> <div className="thanks-card">
<p className="eyebrow">Discussion Questions</p> <p className="eyebrow">Reflection Questions</p>
<h1>Enroll to Access Discussion Questions</h1> <h1>Enroll to Access Reflection Questions</h1>
<p>Please enroll in {study.title} to open the discussion questions for this lesson.</p> <p>Please enroll in {study.title} to open the reflection questions for this lesson.</p>
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link> <Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link>
</div> </div>
</main> </main>
@@ -3092,9 +3160,9 @@ export function StudyQuizPage({ content }: Props) {
<main className="study-index-page" aria-label="Discussion questions"> <main className="study-index-page" aria-label="Discussion questions">
<section className="section-study-course-hero"> <section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner"> <div className="section-inner study-course-hero-inner">
<p className="eyebrow">Discussion Questions</p> <p className="eyebrow">Reflection Questions</p>
<h1>{section.title}</h1> <h1>{section.title}</h1>
<p className="study-course-hero-copy">Work through the discussion questions below and save your responses for later review.</p> <p className="study-course-hero-copy">Write out your answers below they're saved privately. You can also choose to share individual answers to the lesson discussion.</p>
<div className="study-course-hero-actions"> <div className="study-course-hero-actions">
<Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Back to Lesson</Link> <Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Back to Lesson</Link>
</div> </div>
@@ -3104,34 +3172,47 @@ export function StudyQuizPage({ content }: Props) {
<section className="section-study-module" style={{ paddingTop: '2rem' }}> <section className="section-study-module" style={{ paddingTop: '2rem' }}>
<div className="section-inner"> <div className="section-inner">
{loading ? ( {loading ? (
<p>Loading discussion questions...</p> <p>Loading reflection questions...</p>
) : error ? ( ) : error ? (
<p className="study-note-status">{error}</p> <p className="study-note-status">{error}</p>
) : !canSubmit ? ( ) : !canSubmit ? (
<article className="study-class-block"> <article className="study-class-block">
<h2>No Discussion Questions</h2> <h2>No Reflection Questions</h2>
<p className="study-detail-copy">This lesson does not have discussion questions configured yet.</p> <p className="study-detail-copy">This lesson does not have reflection questions configured yet.</p>
</article> </article>
) : ( ) : (
<form onSubmit={e => { e.preventDefault(); saveQuiz() }}> <form onSubmit={e => { e.preventDefault(); saveQuiz() }}>
{sectionQuestions.map((question, index) => ( {sectionQuestions.map((question, index) => (
<div key={index} style={{ marginBottom: '1.25rem' }}> <div key={index} className="quiz-question-block">
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{`${index + 1}. ${question}`}</p> <p className="quiz-question-label">{`${index + 1}. ${question}`}</p>
<textarea <textarea
rows={4} rows={4}
className="quiz-question-textarea"
value={answers[index] ?? ''} value={answers[index] ?? ''}
onChange={e => setAnswers(prev => { onChange={e => setAnswers(prev => {
const next = [...prev] const next = [...prev]
next[index] = e.target.value next[index] = e.target.value
return next return next
})} })}
placeholder="Your answer..." placeholder="Write your answer here…"
style={{ width: '100%', padding: '0.9rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
/> />
{(answers[index] ?? '').trim().length >= 2 && (
<label className="quiz-share-toggle">
<input
type="checkbox"
checked={shareToDiscussion[index] ?? false}
disabled={sharedIndexes.has(index)}
onChange={e => setShareToDiscussion(prev => ({ ...prev, [index]: e.target.checked }))}
/>
{sharedIndexes.has(index)
? <span className="quiz-share-label quiz-share-label--done"> Shared to discussion</span>
: <span className="quiz-share-label">Share this answer to the lesson discussion</span>}
</label>
)}
</div> </div>
))} ))}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving...' : 'Save Answers'}</button> <button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving' : 'Save Answers'}</button>
<button type="button" className="btn-secondary" onClick={() => navigate(`/study/${study.slug}/${section.id}`)}>Back to Lesson</button> <button type="button" className="btn-secondary" onClick={() => navigate(`/study/${study.slug}/${section.id}`)}>Back to Lesson</button>
</div> </div>
{message && <p className="study-note-status" style={{ marginTop: '1rem' }}>{message}</p>} {message && <p className="study-note-status" style={{ marginTop: '1rem' }}>{message}</p>}
+72 -4
View File
@@ -30,6 +30,8 @@ ChartJS.register(
interface Props { interface Props {
stats: AdminStats | null stats: AdminStats | null
statsStatus: 'loading' | 'error' | 'ready' statsStatus: 'loading' | 'error' | 'ready'
statsRange?: '7d' | '30d' | '90d'
onRangeChange?: (range: '7d' | '30d' | '90d') => void
opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null
formatDate: (date: string | null) => string formatDate: (date: string | null) => string
maskIp: (ip: string) => string maskIp: (ip: string) => string
@@ -51,6 +53,8 @@ interface Props {
export function AnalyticsPanel({ export function AnalyticsPanel({
stats, stats,
statsStatus, statsStatus,
statsRange = '30d',
onRangeChange,
opsStatus, opsStatus,
formatDate, formatDate,
maskIp, maskIp,
@@ -224,6 +228,9 @@ export function AnalyticsPanel({
}, },
} }
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange
return ( return (
<section className="admin-panel-section" aria-label="Analytics"> <section className="admin-panel-section" aria-label="Analytics">
<div className="admin-panel-head"> <div className="admin-panel-head">
@@ -231,22 +238,51 @@ export function AnalyticsPanel({
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p> <p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p>
</div> </div>
{/* Time-range tabs */}
{onRangeChange && (
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
{(['7d', '30d', '90d'] as const).map(r => (
<button
key={r}
type="button"
onClick={() => onRangeChange(r)}
style={{
padding: '0.35rem 1rem',
borderRadius: '20px',
border: `1px solid ${statsRange === r ? '#c9a84c' : '#3a3320'}`,
background: statsRange === r ? 'rgba(201,168,76,0.15)' : 'transparent',
color: statsRange === r ? '#c9a84c' : '#7a7060',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: statsRange === r ? 600 : 400,
transition: 'all 0.15s',
}}
>
{r === '7d' ? 'Last 7 days' : r === '30d' ? 'Last 30 days' : 'Last 90 days'}
</button>
))}
<span style={{ color: '#5a5440', fontSize: '0.8rem', alignSelf: 'center', marginLeft: '0.25rem' }}>
Showing: {rangeLabel}
</span>
</div>
)}
{/* KPI Grid */} {/* KPI Grid */}
<div className="admin-stats-grid"> <div className="admin-stats-grid">
<article> <article>
<h3>Total Hits</h3> <h3>Total Hits</h3>
<p>{stats.totalHits.toLocaleString()}</p> <p>{rangeTotalHits.toLocaleString()}</p>
<small>All page requests</small> <small>{rangeLabel} all requests</small>
</article> </article>
<article> <article>
<h3>Real Traffic</h3> <h3>Real Traffic</h3>
<p>{realHits.toLocaleString()}</p> <p>{realHits.toLocaleString()}</p>
<small>{totalHits > 0 ? ((realHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small> <small>{rangeTotalHits > 0 ? ((realHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article> </article>
<article> <article>
<h3>Bot Traffic</h3> <h3>Bot Traffic</h3>
<p>{botHits.toLocaleString()}</p> <p>{botHits.toLocaleString()}</p>
<small>{totalHits > 0 ? ((botHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small> <small>{rangeTotalHits > 0 ? ((botHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article> </article>
<article> <article>
<h3>Last 30 Days</h3> <h3>Last 30 Days</h3>
@@ -496,6 +532,38 @@ export function AnalyticsPanel({
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article> <article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
</div> </div>
{/* Study Enrollment Funnel */}
{stats.studyEnrollment?.funnel && (
<>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Study Enrollment Funnel</h2>
<p>How many users progress from signup first visit first section completed.</p>
</div>
<div className="admin-funnel">
{(() => {
const { signups, firstVisit, firstCompletion } = stats.studyEnrollment.funnel!
const steps = [
{ label: 'Signed Up', count: signups, pct: 100 },
{ label: 'Visited a Study', count: firstVisit, pct: signups > 0 ? Math.round((firstVisit / signups) * 100) : 0 },
{ label: 'Completed a Section', count: firstCompletion, pct: signups > 0 ? Math.round((firstCompletion / signups) * 100) : 0 },
]
return steps.map((step, i) => (
<div key={i} className="admin-funnel-step">
<div className="admin-funnel-bar-wrap">
<div className="admin-funnel-bar" style={{ width: `${step.pct}%` }} />
</div>
<div className="admin-funnel-label">
<span className="admin-funnel-step-name">{step.label}</span>
<span className="admin-funnel-count">{step.count.toLocaleString()}</span>
<span className="admin-funnel-pct">{step.pct}%</span>
</div>
</div>
))
})()}
</div>
</>
)}
{/* Deployment & Cache Status */} {/* Deployment & Cache Status */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Deployment &amp; Cache Status</h2> <h2>Deployment &amp; Cache Status</h2>
+36
View File
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom'
export interface BreadcrumbItem {
label: string
href?: string
}
export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
if (items.length === 0) return null
return (
<nav aria-label="Breadcrumb" className="study-breadcrumbs">
<ol className="study-breadcrumbs-list">
{items.map((item, idx) => {
const isLast = idx === items.length - 1
return (
<li key={idx} className="study-breadcrumbs-item">
{!isLast && item.href ? (
<Link to={item.href} className="study-breadcrumbs-link">
{item.label}
</Link>
) : (
<span className="study-breadcrumbs-current" aria-current={isLast ? 'page' : undefined}>
{item.label}
</span>
)}
{!isLast && (
<span className="study-breadcrumbs-sep" aria-hidden="true"> </span>
)}
</li>
)
})}
</ol>
</nav>
)
}
+155
View File
@@ -0,0 +1,155 @@
import { useRef, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import type { SearchResult, SearchResultType } from '../hooks/useGlobalSearch'
const TYPE_LABEL: Record<SearchResultType, string> = {
episode: 'Episode',
question: 'Q&A',
resource: 'Download',
series: 'Series',
}
const TYPE_ICON: Record<SearchResultType, string> = {
episode: '🎙',
question: '❓',
resource: '📄',
series: '📚',
}
interface Props {
query: string
setQuery: (q: string) => void
results: SearchResult[]
}
export function GlobalSearch({ query, setQuery, results }: Props) {
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const showDropdown = open && (results.length > 0 || query.trim().length >= 2)
// Reset active index when results change
useEffect(() => { setActiveIdx(-1) }, [results])
// Close on outside click
useEffect(() => {
if (!showDropdown) return
function handleClick(e: MouseEvent) {
if (
!inputRef.current?.contains(e.target as Node) &&
!dropdownRef.current?.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showDropdown])
function handleSelect(result: SearchResult) {
setOpen(false)
setQuery('')
if (result.type === 'episode' && result.href.startsWith('http')) {
window.open(result.href, '_blank', 'noreferrer')
} else {
navigate(result.href)
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (!showDropdown) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIdx(i => Math.min(i + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIdx(i => Math.max(i - 1, 0))
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault()
handleSelect(results[activeIdx])
} else if (e.key === 'Escape') {
setOpen(false)
inputRef.current?.blur()
}
}
return (
<div className="global-search" role="search">
<div className="global-search-input-wrap">
<span className="global-search-icon" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle cx="6.5" cy="6.5" r="5" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.5 10.5L14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</span>
<input
ref={inputRef}
type="search"
className="global-search-input"
placeholder="Search episodes, Q&A, resources…"
value={query}
autoComplete="off"
aria-label="Search site"
aria-expanded={showDropdown}
aria-autocomplete="list"
onChange={e => { setQuery(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
/>
{query && (
<button
type="button"
className="global-search-clear"
aria-label="Clear search"
onClick={() => { setQuery(''); setOpen(false); inputRef.current?.focus() }}
>×</button>
)}
</div>
{showDropdown && (
<div
ref={dropdownRef}
className="global-search-dropdown"
role="listbox"
aria-label="Search results"
>
{results.length === 0 ? (
<p className="global-search-empty">No results for "{query}"</p>
) : results.map((result, idx) => (
<button
key={result.id}
type="button"
role="option"
aria-selected={idx === activeIdx}
className={`global-search-result${idx === activeIdx ? ' global-search-result--active' : ''}`}
onClick={() => handleSelect(result)}
onMouseEnter={() => setActiveIdx(idx)}
>
<span className="global-search-result-icon" aria-hidden="true">
{TYPE_ICON[result.type]}
</span>
<span className="global-search-result-body">
<span className="global-search-result-title">{result.title}</span>
<span className="global-search-result-meta">
<span className="global-search-result-type">{TYPE_LABEL[result.type]}</span>
{result.subtitle && result.subtitle !== TYPE_LABEL[result.type] && (
<span className="global-search-result-sub"> · {result.subtitle}</span>
)}
</span>
{result.scriptSnippet && (
<span className="global-search-result-snippet">{result.scriptSnippet}</span>
)}
</span>
</button>
))}
{results.length > 0 && (
<p className="global-search-hint"> navigate · Enter select · Esc close</p>
)}
</div>
)}
</div>
)
}
+44 -2
View File
@@ -8,6 +8,8 @@ interface PublicQuestion {
topic?: string topic?: string
submittedAt?: string submittedAt?: string
answeredAt?: string answeredAt?: string
upvotes?: number
pinned?: boolean
} }
interface DecoratedQuestion extends PublicQuestion { interface DecoratedQuestion extends PublicQuestion {
@@ -390,6 +392,12 @@ export default function QASection() {
const [copiedQuestionId, setCopiedQuestionId] = useState<string | null>(null) const [copiedQuestionId, setCopiedQuestionId] = useState<string | null>(null)
const [copiedFacebookCaptionId, setCopiedFacebookCaptionId] = useState<string | null>(null) const [copiedFacebookCaptionId, setCopiedFacebookCaptionId] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [votedIds, setVotedIds] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem('qa-voted-ids-v1')
return new Set(raw ? JSON.parse(raw) as string[] : [])
} catch { return new Set() }
})
const resultsTopRef = useRef<HTMLDivElement | null>(null) const resultsTopRef = useRef<HTMLDivElement | null>(null)
useEffect(() => { useEffect(() => {
@@ -404,6 +412,22 @@ export default function QASection() {
}) })
}, []) }, [])
async function handleUpvote(id: string) {
if (votedIds.has(id)) return
try {
const res = await fetch(`/api/questions/${encodeURIComponent(id)}/upvote`, { method: 'POST' })
if (!res.ok) return
const data = await res.json() as { upvotes: number }
setQuestions(prev => prev.map(q => q.id === id ? { ...q, upvotes: data.upvotes } : q))
setVotedIds(prev => {
const next = new Set(prev)
next.add(id)
localStorage.setItem('qa-voted-ids-v1', JSON.stringify([...next]))
return next
})
} catch { /* ignore */ }
}
useEffect(() => { useEffect(() => {
setEngagement(readEngagementFromStorage()) setEngagement(readEngagementFromStorage())
}, []) }, [])
@@ -839,22 +863,40 @@ export default function QASection() {
: question.answer : question.answer
return ( return (
<article key={question.id} id={`qa-${question.id}`} className={`qa-card-scene${isFocused ? ' qa-card-scene--focused' : ''}`}> <article key={question.id} id={`qa-${question.id}`} className={`qa-card-scene${isFocused ? ' qa-card-scene--focused' : ''}${question.pinned ? ' qa-card-scene--pinned' : ''}`}>
<div className="qa-question-header"> <div className="qa-question-header">
<span className="qa-face-label">Q</span> <span className="qa-face-label">Q</span>
<span className="qa-question-main"> <span className="qa-question-main">
<span className="qa-question-text">{renderHighlightedText(question.question, normalizedSearch)}</span> <span className="qa-question-text">
{question.pinned && (
<span className="qa-pinned-badge" aria-label="Pinned question">📌 </span>
)}
{renderHighlightedText(question.question, normalizedSearch)}
</span>
<span className="qa-question-meta"> <span className="qa-question-meta">
<span className="qa-topic-pill">{question._topic}</span> <span className="qa-topic-pill">{question._topic}</span>
{formatDate(question.answeredAt ?? question.submittedAt) && ( {formatDate(question.answeredAt ?? question.submittedAt) && (
<span>{formatDate(question.answeredAt ?? question.submittedAt)}</span> <span>{formatDate(question.answeredAt ?? question.submittedAt)}</span>
)} )}
{question._helpful > 0 && <span>{question._helpful} helpful</span>} {question._helpful > 0 && <span>{question._helpful} helpful</span>}
{(question.upvotes ?? 0) > 0 && (
<span>👍 {question.upvotes}</span>
)}
</span> </span>
</span> </span>
</div> </div>
<div className="qa-card-actions"> <div className="qa-card-actions">
<button
type="button"
aria-label={votedIds.has(question.id) ? 'Already upvoted' : 'Upvote this question'}
className={`qa-upvote-btn${votedIds.has(question.id) ? ' qa-upvote-btn--voted' : ''}`}
onClick={() => handleUpvote(question.id)}
disabled={votedIds.has(question.id)}
>
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" width="13" height="13"><path d="M8 2L2 9h4v5h4V9h4z"/></svg>
<span>{votedIds.has(question.id) ? 'Upvoted' : 'Upvote'}{(question.upvotes ?? 0) > 0 ? ` · ${question.upvotes}` : ''}</span>
</button>
<button <button
type="button" type="button"
aria-label="Share on X" aria-label="Share on X"
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useRef, useState } from 'react'
interface CertificateStatus {
eligible: boolean
studyTitle: string
completedCount: number
totalCount: number
token: string | null
issuedAt: string | null
}
interface Props {
studySlug: string
}
function formatDate(iso: string | null) {
if (!iso) return ''
try {
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
} catch {
return ''
}
}
function drawCertificate(
canvas: HTMLCanvasElement,
studyTitle: string,
displayName: string,
issuedAt: string,
) {
const W = 900
const H = 630
canvas.width = W
canvas.height = H
const ctx = canvas.getContext('2d')!
// Background
ctx.fillStyle = '#1a1710'
ctx.fillRect(0, 0, W, H)
// Gold border (outer)
ctx.strokeStyle = '#c8952a'
ctx.lineWidth = 8
ctx.strokeRect(16, 16, W - 32, H - 32)
// Gold border (inner)
ctx.strokeStyle = '#e0b840'
ctx.lineWidth = 2
ctx.strokeRect(28, 28, W - 56, H - 56)
// Header label
ctx.fillStyle = '#8a7f5a'
ctx.font = '700 15px Georgia, serif'
ctx.textAlign = 'center'
ctx.fillText('VERSE BY VERSE WITH NATE', W / 2, 80)
// "Certificate of Completion"
ctx.fillStyle = '#e0b840'
ctx.font = 'italic 700 38px Georgia, serif'
ctx.fillText('Certificate of Completion', W / 2, 145)
// Decorative line
ctx.strokeStyle = '#c8952a'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(W / 2 - 200, 162)
ctx.lineTo(W / 2 + 200, 162)
ctx.stroke()
// "This certifies that"
ctx.fillStyle = '#b8a87a'
ctx.font = '16px Georgia, serif'
ctx.fillText('This certifies that', W / 2, 210)
// Name
ctx.fillStyle = '#f5f0e8'
ctx.font = 'italic 700 44px Georgia, serif'
ctx.fillText(displayName, W / 2, 272)
// Underline the name
const nameWidth = ctx.measureText(displayName).width
ctx.strokeStyle = '#e0b840'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(W / 2 - nameWidth / 2, 282)
ctx.lineTo(W / 2 + nameWidth / 2, 282)
ctx.stroke()
// "has successfully completed"
ctx.fillStyle = '#b8a87a'
ctx.font = '16px Georgia, serif'
ctx.fillText('has successfully completed', W / 2, 322)
// Study title
ctx.fillStyle = '#f5f0e8'
ctx.font = '700 28px Georgia, serif'
ctx.fillText(studyTitle, W / 2, 372)
// Date line
ctx.fillStyle = '#8a7f5a'
ctx.font = '14px Georgia, serif'
ctx.fillText(`Awarded on ${formatDate(issuedAt)}`, W / 2, 430)
// Decorative flourish bottom
ctx.strokeStyle = '#c8952a'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(W / 2 - 180, 460)
ctx.lineTo(W / 2 + 180, 460)
ctx.stroke()
// Footer
ctx.fillStyle = '#5a5040'
ctx.font = '12px Georgia, serif'
ctx.fillText('versebyversewithnate.us', W / 2, 490)
}
export function StudyCertificate({ studySlug }: Props) {
const [status, setStatus] = useState<CertificateStatus | null>(null)
const [loading, setLoading] = useState(true)
const [issuing, setIssuing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [displayName, setDisplayName] = useState('')
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
fetch(`/api/study-certificate/${studySlug}`)
.then(r => r.json())
.then(data => {
setStatus(data)
// Try to get display name from account
return fetch('/api/study-account')
})
.then(r => r.json())
.then(data => {
if (data?.displayName) setDisplayName(data.displayName)
else if (data?.username) setDisplayName(data.username)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [studySlug])
useEffect(() => {
if (status?.token && canvasRef.current) {
drawCertificate(
canvasRef.current,
status.studyTitle,
displayName || 'Student',
status.issuedAt ?? new Date().toISOString(),
)
}
}, [status, displayName])
async function handleIssueCertificate() {
setIssuing(true)
setError(null)
try {
const res = await fetch(`/api/study-certificate/${studySlug}`, { method: 'POST' })
const data = await res.json()
if (!res.ok) { setError(data.message ?? 'Failed to issue certificate.'); return }
setStatus(prev => prev ? { ...prev, token: data.token, issuedAt: data.issuedAt } : prev)
} catch {
setError('Network error. Please try again.')
} finally {
setIssuing(false)
}
}
function handleDownload() {
if (!canvasRef.current) return
const link = document.createElement('a')
link.download = `certificate-${studySlug}.png`
link.href = canvasRef.current.toDataURL('image/png')
link.click()
}
function handleShare() {
if (!status?.token) return
const url = `${window.location.origin}/certificate/${status.token}`
if (navigator.share) {
navigator.share({ title: `Certificate ${status.studyTitle}`, url }).catch(() => {})
} else {
navigator.clipboard.writeText(url).then(() => alert('Certificate link copied!')).catch(() => {})
}
}
if (loading) return <p className="study-cert-loading">Checking completion status</p>
if (!status) return null
const progressPct = status.totalCount > 0
? Math.round((status.completedCount / status.totalCount) * 100)
: 0
return (
<div className="study-cert-panel">
<h3 className="study-cert-heading">Certificate of Completion</h3>
{!status.eligible && (
<div className="study-cert-progress">
<p className="study-cert-progress-label">
{status.completedCount} of {status.totalCount} sections completed ({progressPct}%)
</p>
<div className="study-cert-progress-bar-wrap">
<div
className="study-cert-progress-bar"
style={{ width: `${progressPct}%` }}
/>
</div>
<p className="study-cert-note">
Complete all sections to earn your certificate.
</p>
</div>
)}
{status.eligible && !status.token && (
<div className="study-cert-eligible">
<p className="study-cert-congrats">
🎉 Congratulations! You've completed <strong>{status.studyTitle}</strong>.
</p>
{error && <p className="study-cert-error">{error}</p>}
<button
type="button"
className="study-cert-btn"
onClick={handleIssueCertificate}
disabled={issuing}
>
{issuing ? 'Generating…' : 'Generate My Certificate'}
</button>
</div>
)}
{status.token && (
<div className="study-cert-issued">
<p className="study-cert-issued-label">
Issued on {formatDate(status.issuedAt)}
</p>
<canvas ref={canvasRef} className="study-cert-canvas" aria-label="Certificate of completion" />
<div className="study-cert-actions">
<button type="button" className="study-cert-btn" onClick={handleDownload}>
Download PNG
</button>
<button type="button" className="study-cert-btn study-cert-btn--secondary" onClick={handleShare}>
Share Link
</button>
</div>
</div>
)}
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from 'react'
interface Comment {
id: string
displayName: string
text: string
createdAt: string
}
interface Props {
studySlug: string
sectionId: string
isEnrolled: boolean
}
function formatDate(iso: string) {
try {
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return ''
}
}
export function StudySectionComments({ studySlug, sectionId, isEnrolled }: Props) {
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(true)
const [text, setText] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const textRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
setLoading(true)
fetch(`/api/study-comments/${studySlug}/${sectionId}`)
.then(r => r.json())
.then(data => setComments(Array.isArray(data.comments) ? data.comments : []))
.catch(() => setComments([]))
.finally(() => setLoading(false))
}, [studySlug, sectionId])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!text.trim()) return
setSubmitting(true)
setError(null)
setSuccess(false)
try {
const res = await fetch(`/api/study-comments/${studySlug}/${sectionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.trim() }),
})
const data = await res.json()
if (!res.ok) {
setError(data.message ?? 'Failed to post comment.')
return
}
setComments(prev => [...prev, data.comment])
setText('')
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
} catch {
setError('Network error. Please try again.')
} finally {
setSubmitting(false)
}
}
async function handleDelete(commentId: string) {
setDeletingId(commentId)
try {
await fetch(`/api/study-comments/${commentId}`, { method: 'DELETE' })
setComments(prev => prev.filter(c => c.id !== commentId))
} catch {
// ignore
} finally {
setDeletingId(null)
}
}
return (
<section className="study-comments">
<h3 className="study-comments-heading">Share Your Thoughts</h3>
{loading ? (
<p className="study-comments-loading">Loading comments</p>
) : comments.length === 0 ? (
<p className="study-comments-empty">No comments yet. Be the first to share a thought!</p>
) : (
<ul className="study-comments-list">
{comments.map(comment => (
<li key={comment.id} className="study-comment">
<div className="study-comment-header">
<span className="study-comment-author">{comment.displayName}</span>
<span className="study-comment-date">{formatDate(comment.createdAt)}</span>
</div>
<p className="study-comment-text">{comment.text}</p>
<button
type="button"
className="study-comment-delete"
onClick={() => handleDelete(comment.id)}
disabled={deletingId === comment.id}
aria-label="Delete comment"
>
{deletingId === comment.id ? 'Deleting…' : 'Delete'}
</button>
</li>
))}
</ul>
)}
{isEnrolled ? (
<form className="study-comment-form" onSubmit={handleSubmit}>
<label htmlFor="study-comment-input" className="study-comment-form-label">
Leave a comment
</label>
<textarea
id="study-comment-input"
ref={textRef}
className="study-comment-textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder="Share a thought, question, or encouragement…"
rows={3}
maxLength={2000}
disabled={submitting}
/>
<div className="study-comment-form-footer">
<span className="study-comment-char-count">{text.length}/2000</span>
{error && <span className="study-comment-error">{error}</span>}
{success && <span className="study-comment-success">Comment posted!</span>}
<button
type="submit"
className="study-comment-submit"
disabled={submitting || !text.trim()}
>
{submitting ? 'Posting…' : 'Post Comment'}
</button>
</div>
</form>
) : (
<p className="study-comments-enroll-note">
<a href="/study">Enroll in this study</a> to join the discussion.
</p>
)}
</section>
)
}
+165
View File
@@ -0,0 +1,165 @@
import { useMemo, useState, useEffect, useRef } from 'react'
import Fuse from 'fuse.js'
import type { SiteContent } from '../content'
export type SearchResultType = 'episode' | 'question' | 'resource' | 'series'
export interface SearchResult {
id: string
type: SearchResultType
title: string
subtitle?: string
description?: string
href: string
/** Snippet from script text — only present on script-matched episode results */
scriptSnippet?: string
}
interface PublicQuestion {
id: string
firstName?: string
question: string
answer?: string
topic?: string
}
interface ScriptSearchResult {
episodeNumber: string
title: string
filename: string
snippet: string
score: number
}
export function useGlobalSearch(content: SiteContent) {
const [query, setQuery] = useState('')
const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [episodeItems, setEpisodeItems] = useState<SearchResult[]>([])
const [scriptResults, setScriptResults] = useState<SearchResult[]>([])
const scriptDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Fetch questions once on mount
useEffect(() => {
fetch('/api/questions')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { questions?: PublicQuestion[] }) => {
setQuestions(Array.isArray(data.questions) ? data.questions : [])
})
.catch(() => {})
}, [])
// Fetch full episode list once on mount (for title/description matching)
useEffect(() => {
fetch('/api/episodes/all')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: Array<{ title: string; link: string; description?: string; episode?: string }> }) => {
const eps: SearchResult[] = (data.episodes ?? []).map(ep => ({
id: `episode-${ep.episode ?? ep.title}`,
type: 'episode' as const,
title: ep.title,
subtitle: ep.episode ? `Episode ${ep.episode}` : 'Episode',
description: ep.description,
href: ep.link || '/episodes',
}))
setEpisodeItems(eps)
})
.catch(() => {})
}, [])
// Debounced server-side script search — runs whenever query changes
useEffect(() => {
if (scriptDebounceRef.current) clearTimeout(scriptDebounceRef.current)
if (!query.trim() || query.trim().length < 2) {
setScriptResults([])
return
}
scriptDebounceRef.current = setTimeout(() => {
fetch(`/api/episode-scripts/search?q=${encodeURIComponent(query.trim())}`)
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { results?: ScriptSearchResult[] }) => {
const results = (data.results ?? []).map(r => ({
id: `script-${r.episodeNumber}`,
type: 'episode' as const,
title: r.title,
subtitle: `Episode ${r.episodeNumber} · Script`,
description: r.snippet,
scriptSnippet: r.snippet,
href: `/episodes`,
}))
setScriptResults(results)
})
.catch(() => setScriptResults([]))
}, 200)
}, [query])
// Build the Fuse corpus: episodes + resources + series + Q&A
const staticItems: SearchResult[] = useMemo(() => {
const results: SearchResult[] = []
for (const link of content.customLinks ?? []) {
if (link.placement === 'resources' || link.placement === 'otherSites' || link.placement === 'externalSites') {
results.push({
id: `resource-${link.id}`,
type: 'resource',
title: link.label,
subtitle: link.placement === 'resources' ? 'Download' : 'Resource',
description: link.description,
href: `/downloads/${link.id}`,
})
}
}
for (const series of content.archivedSeries ?? []) {
results.push({
id: `series-${series.id}`,
type: 'series',
title: series.title,
subtitle: series.label || 'Archived Series',
description: series.description,
href: `/episodes#${series.id}`,
})
}
for (const q of questions) {
results.push({
id: `qa-${q.id}`,
type: 'question',
title: q.question,
subtitle: q.topic ?? 'Q&A',
description: q.answer ? q.answer.slice(0, 120) : undefined,
href: `/questions`,
})
}
return results
}, [content, questions])
const allFuseItems = useMemo(() => [...episodeItems, ...staticItems], [episodeItems, staticItems])
const fuse = useMemo(() => new Fuse(allFuseItems, {
keys: [
{ name: 'title', weight: 2 },
{ name: 'subtitle', weight: 0.5 },
{ name: 'description', weight: 1 },
],
threshold: 0.35,
minMatchCharLength: 2,
includeScore: true,
}), [allFuseItems])
const results = useMemo(() => {
const q = query.trim()
if (!q || q.length < 2) return []
const fuseMatches = fuse.search(q).slice(0, 6).map(r => r.item)
// Merge script results — deduplicate by episode id (prefer script match if already in fuse)
const fuseIds = new Set(fuseMatches.map(r => r.id))
const dedupedScripts = scriptResults.filter(r => !fuseIds.has(r.id))
// Interleave: fuse results first, then script-only hits, total cap 8
return [...fuseMatches, ...dedupedScripts].slice(0, 8)
}, [fuse, query, scriptResults])
return { query, setQuery, results }
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect } from 'react'
/**
* Sets page <title> and og meta tags for each route.
* Uses direct DOM mutation no react-helmet required.
*/
export function usePageMeta(
title: string,
description?: string,
imageUrl?: string,
) {
useEffect(() => {
if (title) document.title = title
function setMeta(property: string, content: string | undefined, attr: 'property' | 'name' = 'property') {
if (!content) return
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${property}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, property)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}
setMeta('og:title', title)
setMeta('twitter:title', title, 'name')
if (description) {
setMeta('og:description', description)
setMeta('description', description, 'name')
setMeta('twitter:description', description, 'name')
}
if (imageUrl) {
setMeta('og:image', imageUrl)
setMeta('twitter:image', imageUrl, 'name')
}
}, [title, description, imageUrl])
}