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:
Generated
+145
@@ -15,6 +15,8 @@
|
||||
"docx": "^9.6.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"fuse.js": "^7.4.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"otplib": "^13.4.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -1547,6 +1558,26 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.10.8",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
|
||||
@@ -1560,6 +1591,12 @@
|
||||
"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": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -2151,6 +2188,12 @@
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"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": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
|
||||
@@ -2201,6 +2244,15 @@
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2740,6 +2792,19 @@
|
||||
"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": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -3584,6 +3649,17 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3594,6 +3670,39 @@
|
||||
"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": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
@@ -4613,6 +4722,12 @@
|
||||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -4748,6 +4863,15 @@
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -5613,6 +5737,12 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
@@ -5889,6 +6019,12 @@
|
||||
"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": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
@@ -6242,6 +6378,15 @@
|
||||
"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": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"docx": "^9.6.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"fuse.js": "^7.4.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"otplib": "^13.4.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
loadStudyUsersFromDisk,
|
||||
loadStudyCommunityFromDisk,
|
||||
loadStudyRemindersFromDisk,
|
||||
loadStudyCommentsFromDisk,
|
||||
loadStudyCertificatesFromDisk,
|
||||
loadEpisodeScriptsFromDisk,
|
||||
migrateStudyNotesIfNeeded,
|
||||
loadDownloadCountsFromDisk,
|
||||
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 registerStudyData } from './server/routes/study-data.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 registerQuestions } from './server/routes/questions.js'
|
||||
import { register as registerAnalytics } from './server/routes/analytics.js'
|
||||
@@ -56,6 +62,9 @@ registerAdminAssets(app)
|
||||
registerStudyAuth(app)
|
||||
registerStudyData(app)
|
||||
registerStudyAccount(app)
|
||||
registerStudyComments(app)
|
||||
registerStudyCertificate(app)
|
||||
registerEpisodeScripts(app)
|
||||
registerContact(app)
|
||||
registerQuestions(app)
|
||||
registerAnalytics(app)
|
||||
@@ -93,6 +102,9 @@ Promise.all([
|
||||
loadStudyUsersFromDisk(),
|
||||
loadStudyCommunityFromDisk(),
|
||||
loadStudyRemindersFromDisk(),
|
||||
loadStudyCommentsFromDisk(),
|
||||
loadStudyCertificatesFromDisk(),
|
||||
loadEpisodeScriptsFromDisk(),
|
||||
migrateStudyNotesIfNeeded(),
|
||||
loadDownloadCountsFromDisk(),
|
||||
loadPodcastChecklistFromDisk(),
|
||||
|
||||
@@ -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 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_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 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 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_ENROLLMENTS_PER_USER = 100
|
||||
export const MAX_STUDY_NOTES_PER_USER = 500
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
STUDY_NOTES_FILE,
|
||||
STUDY_PROGRESS_DIR,
|
||||
STUDY_REMINDERS_FILE,
|
||||
STUDY_COMMENTS_FILE,
|
||||
STUDY_CERTIFICATES_FILE,
|
||||
EPISODE_SCRIPTS_FILE,
|
||||
REPLY_TEMPLATES_FILE,
|
||||
REPLY_HISTORY_FILE,
|
||||
PODCAST_CHECKLIST_FILE,
|
||||
@@ -35,6 +38,7 @@ import {
|
||||
MAX_STUDY_ENROLLMENTS_PER_USER,
|
||||
MAX_STUDY_NOTES_PER_USER,
|
||||
MAX_STUDY_NOTE_LENGTH,
|
||||
MAX_STUDY_COMMENTS,
|
||||
DEFAULT_PODCAST_CHECKLIST_TASKS,
|
||||
buildDefaultPodcastChecklist,
|
||||
} 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 ────────────────────────────────────────────────────────
|
||||
|
||||
export function queueDownloadCountsWrite() {
|
||||
|
||||
+57
-10
@@ -163,13 +163,47 @@ export function register(app) {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
||||
const topPaths = Object.entries(state.hitStats.byPath)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
const topPathsReal = Object.entries(state.hitStats.byPathReal)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
const topPathsBot = Object.entries(state.hitStats.byPathBot)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
app.get('/api/admin-stats', requireAdminAuth, (req, res) => {
|
||||
// Time-range filter: 7d | 30d | 90d (default: all-time for aggregate, 30d for charts)
|
||||
const rangeParam = req.query.range
|
||||
const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : 30
|
||||
const rangeLabel = rangeParam === '7d' ? '7d' : rangeParam === '90d' ? '90d' : '30d'
|
||||
|
||||
// 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 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 ?? {})
|
||||
.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]
|
||||
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 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({
|
||||
totalHits: state.hitStats.totalHits,
|
||||
realHits: state.hitStats.realHits ?? 0,
|
||||
botHits: state.hitStats.botHits ?? 0,
|
||||
realHits: rangeRealHits,
|
||||
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,
|
||||
lastHitAt: state.hitStats.lastHitAt,
|
||||
topPaths, topPathsReal, topPathsBot,
|
||||
@@ -273,6 +319,7 @@ export function register(app) {
|
||||
totalEnrollments,
|
||||
enrollmentsByStudy,
|
||||
users,
|
||||
funnel: { signups: funnelSignups, firstVisit: funnelFirstVisit, firstCompletion: funnelFirstCompletion },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import mammoth from 'mammoth'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueEpisodeScriptsWrite } from '../data.js'
|
||||
import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js'
|
||||
|
||||
/**
|
||||
* Simple whitespace-normalised search over episode script text.
|
||||
* Returns { episodeNumber, title, filename, snippet, score } for matches.
|
||||
*/
|
||||
function searchScripts(query) {
|
||||
if (!query || query.trim().length < 2) return []
|
||||
const q = query.trim().toLowerCase()
|
||||
const words = q.split(/\s+/).filter(w => w.length >= 2)
|
||||
const results = []
|
||||
|
||||
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
|
||||
if (!entry?.text) continue
|
||||
const text = entry.text.toLowerCase()
|
||||
|
||||
// Count how many words appear in the text
|
||||
const matchCount = words.filter(w => text.includes(w)).length
|
||||
if (matchCount === 0) continue
|
||||
|
||||
// Find a snippet around the first matching word
|
||||
const firstWord = words.find(w => text.includes(w))
|
||||
const idx = firstWord ? text.indexOf(firstWord) : 0
|
||||
const start = Math.max(0, idx - 80)
|
||||
const end = Math.min(entry.text.length, idx + 180)
|
||||
let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
if (start > 0) snippet = '…' + snippet
|
||||
if (end < entry.text.length) snippet = snippet + '…'
|
||||
|
||||
results.push({
|
||||
episodeNumber,
|
||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||
filename: entry.filename ?? '',
|
||||
snippet,
|
||||
score: matchCount / words.length, // 0–1: fraction of query words found
|
||||
})
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.score - a.score).slice(0, 8)
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
// Public script search — called by the frontend global search
|
||||
app.get('/api/episode-scripts/search', (req, res) => {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''
|
||||
if (!q || q.length < 2) { res.json({ results: [] }); return }
|
||||
res.json({ results: searchScripts(q) })
|
||||
})
|
||||
|
||||
// Admin: list all uploaded scripts
|
||||
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
|
||||
const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({
|
||||
episodeNumber,
|
||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||
filename: entry.filename ?? '',
|
||||
uploadedAt: entry.uploadedAt ?? null,
|
||||
wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0,
|
||||
}))
|
||||
list.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
|
||||
res.json({ scripts: list })
|
||||
})
|
||||
|
||||
// Admin: upload a docx script for an episode
|
||||
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => {
|
||||
const episodeNumber = req.params.episodeNumber?.trim()
|
||||
if (!episodeNumber || !/^\d+$/.test(episodeNumber)) {
|
||||
res.status(400).json({ message: 'Episode number must be a positive integer.' }); return
|
||||
}
|
||||
|
||||
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
||||
const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : `episode-${episodeNumber}.docx`
|
||||
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}`
|
||||
|
||||
if (!dataUrl.startsWith('data:')) {
|
||||
res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return
|
||||
}
|
||||
|
||||
const base64 = dataUrl.split(',')[1] ?? ''
|
||||
if (!base64) { res.status(400).json({ message: 'Empty file.' }); return }
|
||||
|
||||
const buffer = Buffer.from(base64, 'base64')
|
||||
if (buffer.length === 0 || buffer.length > 20 * 1024 * 1024) {
|
||||
res.status(400).json({ message: 'File must be between 1 byte and 20MB.' }); return
|
||||
}
|
||||
|
||||
let text
|
||||
try {
|
||||
const result = await mammoth.extractRawText({ buffer })
|
||||
text = result.value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
|
||||
} catch (err) {
|
||||
console.error('[episode-scripts] mammoth extraction failed:', err.message)
|
||||
res.status(422).json({ message: 'Could not extract text from file. Make sure it is a valid .docx file.' }); return
|
||||
}
|
||||
|
||||
if (!text || text.length < 10) {
|
||||
res.status(422).json({ message: 'No readable text found in document.' }); return
|
||||
}
|
||||
|
||||
// Trim to max length
|
||||
if (text.length > MAX_EPISODE_SCRIPT_LENGTH) {
|
||||
text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH)
|
||||
}
|
||||
|
||||
state.episodeScripts[episodeNumber] = {
|
||||
title,
|
||||
filename,
|
||||
text,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
wordCount: text.split(/\s+/).filter(Boolean).length,
|
||||
}
|
||||
queueEpisodeScriptsWrite()
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
episodeNumber,
|
||||
wordCount: state.episodeScripts[episodeNumber].wordCount,
|
||||
})
|
||||
})
|
||||
|
||||
// Admin: delete a script
|
||||
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => {
|
||||
const episodeNumber = req.params.episodeNumber?.trim()
|
||||
if (!state.episodeScripts[episodeNumber]) {
|
||||
res.status(404).json({ message: 'Script not found.' }); return
|
||||
}
|
||||
delete state.episodeScripts[episodeNumber]
|
||||
queueEpisodeScriptsWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,14 @@ function ensureDraftQuestions() {
|
||||
export function register(app) {
|
||||
app.get('/api/questions', (_req, res) => {
|
||||
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 })
|
||||
})
|
||||
|
||||
@@ -113,4 +120,41 @@ export function register(app) {
|
||||
|
||||
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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -154,6 +154,13 @@ export function register(app) {
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
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 })
|
||||
})
|
||||
|
||||
@@ -181,6 +188,12 @@ export function register(app) {
|
||||
state.studyProgressCache.set(user.id, progress)
|
||||
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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -46,6 +46,16 @@ export const state = {
|
||||
studyReminders: { users: {}, updatedAt: new Date().toISOString() },
|
||||
studyRemindersWritePromise: Promise.resolve(),
|
||||
|
||||
studyComments: [],
|
||||
studyCommentsWritePromise: Promise.resolve(),
|
||||
|
||||
studyCertificates: [],
|
||||
studyCertificatesWritePromise: Promise.resolve(),
|
||||
|
||||
// episodeScripts: { [episodeNumber]: { text, title, filename, uploadedAt } }
|
||||
episodeScripts: {},
|
||||
episodeScriptsWritePromise: Promise.resolve(),
|
||||
|
||||
downloadCounts: {},
|
||||
downloadCountsWritePromise: Promise.resolve(),
|
||||
|
||||
|
||||
+287
-5
@@ -553,6 +553,7 @@ export interface AdminStats {
|
||||
displayName: string
|
||||
enrolledStudies: Array<{ slug: string; title: string }>
|
||||
}>
|
||||
funnel?: { signups: number; firstVisit: number; firstCompletion: number }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,6 +588,8 @@ interface Question {
|
||||
answeredAt: string | null
|
||||
isApproved: boolean
|
||||
approvedAt: string | null
|
||||
upvotes?: number
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
interface ContactSubmission {
|
||||
@@ -670,7 +673,7 @@ type AdminView =
|
||||
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
||||
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks'
|
||||
| 'questions' | 'analytics' | 'assets' | 'colossians-study'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study'
|
||||
| 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
||||
|
||||
@@ -707,6 +710,7 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
|
||||
group: 'Manage',
|
||||
options: [
|
||||
{ value: 'questions', label: 'Questions' },
|
||||
{ value: 'study-comments', label: 'Study Comments' },
|
||||
{ value: 'emails', label: 'Emails' },
|
||||
{ value: 'contacts', label: 'Contacts' },
|
||||
{ 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'
|
||||
|
||||
@@ -1019,6 +1023,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [podcastTab, setPodcastTab] = useState<PodcastTab>('current-series')
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [statsRange, setStatsRange] = useState<'7d' | '30d' | '90d'>('30d')
|
||||
const [maintenanceMsg, setMaintenanceMsg] = useState('')
|
||||
const [opsMsg, setOpsMsg] = useState('')
|
||||
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
|
||||
@@ -1063,6 +1068,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [subscriberSearch, setSubscriberSearch] = useState('')
|
||||
const [contactSearch, setContactSearch] = useState('')
|
||||
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 [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('')
|
||||
@@ -1206,7 +1228,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}, [])
|
||||
|
||||
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(data => {
|
||||
setStats(data as AdminStats)
|
||||
@@ -1329,8 +1351,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
})
|
||||
}, [selectedBackup])
|
||||
|
||||
async function reloadStats() {
|
||||
const r = await fetch('/api/admin-stats')
|
||||
async function reloadStats(range?: '7d' | '30d' | '90d') {
|
||||
const r = await fetch(`/api/admin-stats?range=${range ?? statsRange}`)
|
||||
if (!r.ok) throw new Error('Could not refresh stats')
|
||||
const data = await r.json()
|
||||
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) {
|
||||
if (!confirm('Delete this question permanently?')) return
|
||||
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 === '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 === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -4646,6 +4762,163 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</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'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 */}
|
||||
{adminView === 'email-templates' && (
|
||||
<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)}>
|
||||
{question.isApproved ? 'Unapprove' : 'Approve'}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
@@ -4855,6 +5131,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
<AnalyticsPanel
|
||||
stats={stats}
|
||||
statsStatus={statsStatus}
|
||||
statsRange={statsRange}
|
||||
onRangeChange={async (range) => {
|
||||
setStatsRange(range)
|
||||
setStatsStatus('loading')
|
||||
try { await reloadStats(range) } catch { setStatsStatus('error') }
|
||||
}}
|
||||
opsStatus={opsStatus}
|
||||
formatDate={formatDate}
|
||||
maskIp={maskIp}
|
||||
|
||||
+734
@@ -26,8 +26,131 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1661,6 +1784,35 @@
|
||||
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 {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.9rem;
|
||||
@@ -1736,6 +1888,319 @@
|
||||
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 {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
@@ -2853,6 +3318,61 @@
|
||||
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 {
|
||||
background: rgba(17, 17, 17, 0.82);
|
||||
border: 1px solid rgba(201, 168, 76, 0.14);
|
||||
@@ -3461,6 +3981,59 @@
|
||||
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 {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
@@ -4527,6 +5100,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.site-search-bar-inner {
|
||||
padding: 0.4rem 1rem;
|
||||
}
|
||||
|
||||
.header-menu-btn {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
@@ -4971,6 +5548,45 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -5725,6 +6341,124 @@
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+201
-10
@@ -8,6 +8,9 @@ import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySect
|
||||
import { SpotifyIcon } from './icons'
|
||||
import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
|
||||
import { DEFAULTS } from './content'
|
||||
import { usePageMeta } from './hooks/usePageMeta'
|
||||
import { useGlobalSearch } from './hooks/useGlobalSearch'
|
||||
import { GlobalSearch } from './components/GlobalSearch'
|
||||
import './App.css'
|
||||
|
||||
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 }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
||||
@@ -783,31 +797,63 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
|
||||
const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0)
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||
const [activeType, setActiveType] = useState<'all' | 'resource' | 'series'>('all')
|
||||
|
||||
if (resources.length === 0 && archivedWithResources.length === 0) return null
|
||||
|
||||
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 (
|
||||
<section className="section-resources section-download-library" aria-label="Download library">
|
||||
<div className="section-inner">
|
||||
<div className="download-library-head">
|
||||
<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>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
{/* Type + tag filter bar */}
|
||||
<div className="download-filter-bar">
|
||||
{hasTypeFilter && (
|
||||
<div className="download-tag-filter" style={{ marginBottom: allTags.length > 0 ? '0.5rem' : 0 }}>
|
||||
{(['all', 'resource', 'series'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
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
|
||||
type="button"
|
||||
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
|
||||
onClick={() => setActiveTag(null)}
|
||||
>
|
||||
All
|
||||
All topics
|
||||
</button>
|
||||
{allTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
|
||||
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
|
||||
>
|
||||
@@ -816,6 +862,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredResources.length > 0 && (
|
||||
<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">
|
||||
<div className="resource-download-header">
|
||||
{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">
|
||||
<span className="resource-link-label">{resource.label}</span>
|
||||
@@ -841,17 +888,17 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTag !== null && filteredResources.length === 0 && (
|
||||
{activeTag !== null && filteredResources.length === 0 && activeType !== 'series' && (
|
||||
<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-head">
|
||||
<h3>Previous Studies</h3>
|
||||
<p>Downloads from earlier series that you still want available.</p>
|
||||
<p>Downloads from earlier series.</p>
|
||||
</div>
|
||||
{archivedWithResources.map(series => (
|
||||
{filteredArchived.map(series => (
|
||||
<div key={series.id} className="archive-series-resources">
|
||||
<div className="download-library-series-head">
|
||||
<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">
|
||||
<div className="resource-download-header">
|
||||
{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">
|
||||
<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 }) {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
</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>
|
||||
</main>
|
||||
)
|
||||
@@ -1175,6 +1289,7 @@ function LandingPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<CustomBlocksSection content={content} page="homepage" />
|
||||
|
||||
{/* ── HERO ── */}
|
||||
@@ -1417,6 +1532,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<section className="section-player">
|
||||
<div className="section-inner" style={{ textAlign: 'center', padding: '4rem 1rem' }}>
|
||||
<h2>Episode not found</h2>
|
||||
@@ -1435,6 +1551,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<section className="section-episode-detail" aria-label={episode.title}>
|
||||
<div className="section-inner">
|
||||
<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 [loading, setLoading] = useState(true)
|
||||
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(() => {
|
||||
fetch('/api/episodes/all')
|
||||
@@ -1589,6 +1711,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
|
||||
<section className="section-player" aria-label="Current series episodes">
|
||||
<div className="section-inner">
|
||||
@@ -1669,6 +1792,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
|
||||
)
|
||||
})}
|
||||
|
||||
<HomepageNewsletterSection />
|
||||
<PodcastHighlightsSection content={content} />
|
||||
<CustomBlocksSection content={content} page="episodes" />
|
||||
<SiteFooter content={content} />
|
||||
@@ -1678,9 +1802,15 @@ function EpisodesPage({ 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 (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<StudyGuideSection content={content} />
|
||||
<DownloadLibrarySection content={content} />
|
||||
<CustomBlocksSection content={content} page="downloads" />
|
||||
@@ -1692,9 +1822,15 @@ function ResourcesPage({ 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 (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<AboutSection content={content} />
|
||||
<CustomBlocksSection content={content} page="about" />
|
||||
<SiteFooter content={content} />
|
||||
@@ -1707,6 +1843,7 @@ function ContactPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<ContactSection content={content} />
|
||||
<CustomBlocksSection content={content} page="contact" />
|
||||
<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 }) {
|
||||
const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
|
||||
const [adminContent, setAdminContent] = useState<SiteContent>(content)
|
||||
@@ -2203,6 +2392,7 @@ function StudyRouteFrame({ content, child }: { content: SiteContent; child: Reac
|
||||
return (
|
||||
<>
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
{child}
|
||||
<SiteFooter content={content} />
|
||||
</>
|
||||
@@ -2303,6 +2493,7 @@ export default function App() {
|
||||
<Route path="/thanks" element={<ThankYouPage />} />
|
||||
<Route path="/subscribe" element={<SubscribePage />} />
|
||||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
||||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
||||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
||||
<Route path="/preview" element={<PreviewPage />} />
|
||||
<Route
|
||||
|
||||
+111
-30
@@ -2,6 +2,10 @@ import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import type { SiteContent, StudyProgram, StudySection } from './content'
|
||||
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 }
|
||||
|
||||
@@ -726,7 +730,8 @@ export function StudyLandingPage({ content }: Props) {
|
||||
const studyMeta = getStudyBySlug(studies, study.slug)
|
||||
const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0
|
||||
return (
|
||||
<article key={study.slug} className="study-module-row">
|
||||
<div key={study.slug}>
|
||||
<article className="study-module-row">
|
||||
<div className="study-module-row-left">
|
||||
<p className="study-module-lesson">{study.enrolled ? 'Enrolled' : 'Track'}</p>
|
||||
<h3>{study.title}</h3>
|
||||
@@ -751,6 +756,10 @@ export function StudyLandingPage({ content }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{progress === 100 && (
|
||||
<StudyCertificate studySlug={study.slug} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -1209,10 +1218,16 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
usePageMeta(
|
||||
`${study.title} | Bible Study`,
|
||||
study.description,
|
||||
)
|
||||
|
||||
return (
|
||||
<main className="study-index-page" aria-label={`${study.title} study`}>
|
||||
<section className="section-study-course-hero">
|
||||
<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>
|
||||
<h1>{study.title}</h1>
|
||||
<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>}
|
||||
{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) && (
|
||||
<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>
|
||||
<h3>{section.title}</h3>
|
||||
@@ -1369,9 +1390,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
const isEnrolled = isEnrolledInStudy(auth, currentStudySlug)
|
||||
const lessonCompleted = section?.id ? completedSectionIds.includes(section.id) : false
|
||||
|
||||
useEffect(() => {
|
||||
document.title = section && study ? `${section.title} | ${study.title}` : 'Study'
|
||||
}, [section, study])
|
||||
usePageMeta(
|
||||
section && study ? `${section.title} | ${study.title}` : 'Study',
|
||||
section?.summary,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -1655,6 +1677,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
|
||||
<div className="section-inner study-classroom-shell">
|
||||
<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>
|
||||
<p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p>
|
||||
<h1>{section.title}</h1>
|
||||
@@ -1695,14 +1718,17 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
</article>
|
||||
|
||||
<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">
|
||||
{section.studyQuestions.map((question, index) => (
|
||||
<li key={index}>{question}</li>
|
||||
))}
|
||||
</ol>
|
||||
<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={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
|
||||
</div>
|
||||
@@ -1782,6 +1808,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
</article>
|
||||
)
|
||||
})()}
|
||||
|
||||
<article className="study-class-block">
|
||||
<StudySectionComments
|
||||
studySlug={currentStudySlug}
|
||||
sectionId={section.id}
|
||||
isEnrolled={isEnrolled}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
||||
@@ -1967,9 +2001,10 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
||||
const [message, setMessage] = useState('')
|
||||
const [activeNoteTab, setActiveNoteTab] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
document.title = study ? `My Notes | ${study.title}` : 'My Study Notes'
|
||||
}, [study])
|
||||
usePageMeta(
|
||||
study ? `My Notes | ${study.title}` : 'My Study Notes',
|
||||
study ? `Your personal lesson notes for ${study.title}.` : undefined,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -2048,6 +2083,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
||||
<main className="study-section-page" aria-label="My study notes">
|
||||
<section className="section-study-classroom">
|
||||
<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>
|
||||
<p className="study-lesson-label">Student Workspace</p>
|
||||
<h1>My Lesson Notes</h1>
|
||||
@@ -2976,6 +3012,8 @@ export function StudyQuizPage({ content }: Props) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [shareToDiscussion, setShareToDiscussion] = useState<Record<number, boolean>>({})
|
||||
const [sharedIndexes, setSharedIndexes] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -3029,7 +3067,37 @@ export function StudyQuizPage({ content }: Props) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save answers.')
|
||||
} finally {
|
||||
@@ -3041,9 +3109,9 @@ export function StudyQuizPage({ content }: Props) {
|
||||
return (
|
||||
<main className="thanks-page" aria-label="Discussion questions not found">
|
||||
<div className="thanks-card">
|
||||
<p className="eyebrow">Discussion Questions</p>
|
||||
<p className="eyebrow">Reflection Questions</p>
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
@@ -3056,8 +3124,8 @@ export function StudyQuizPage({ content }: Props) {
|
||||
|
||||
if (!auth.checked) {
|
||||
return (
|
||||
<main className="thanks-page" aria-label="Loading discussion questions">
|
||||
<div className="thanks-card"><p>Loading discussion questions...</p></div>
|
||||
<main className="thanks-page" aria-label="Loading reflection questions">
|
||||
<div className="thanks-card"><p>Loading reflection questions...</p></div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -3066,7 +3134,7 @@ export function StudyQuizPage({ content }: Props) {
|
||||
return (
|
||||
<main className="thanks-page" aria-label="Sign in required">
|
||||
<div className="thanks-card">
|
||||
<p className="eyebrow">Discussion Questions</p>
|
||||
<p className="eyebrow">Reflection Questions</p>
|
||||
<h1>Sign In Required</h1>
|
||||
<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>
|
||||
@@ -3079,9 +3147,9 @@ export function StudyQuizPage({ content }: Props) {
|
||||
return (
|
||||
<main className="thanks-page" aria-label="Enrollment required">
|
||||
<div className="thanks-card">
|
||||
<p className="eyebrow">Discussion Questions</p>
|
||||
<h1>Enroll to Access Discussion Questions</h1>
|
||||
<p>Please enroll in {study.title} to open the discussion questions for this lesson.</p>
|
||||
<p className="eyebrow">Reflection Questions</p>
|
||||
<h1>Enroll to Access Reflection Questions</h1>
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
@@ -3092,9 +3160,9 @@ export function StudyQuizPage({ content }: Props) {
|
||||
<main className="study-index-page" aria-label="Discussion questions">
|
||||
<section className="section-study-course-hero">
|
||||
<div className="section-inner study-course-hero-inner">
|
||||
<p className="eyebrow">Discussion Questions</p>
|
||||
<p className="eyebrow">Reflection Questions</p>
|
||||
<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">
|
||||
<Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Back to Lesson</Link>
|
||||
</div>
|
||||
@@ -3104,34 +3172,47 @@ export function StudyQuizPage({ content }: Props) {
|
||||
<section className="section-study-module" style={{ paddingTop: '2rem' }}>
|
||||
<div className="section-inner">
|
||||
{loading ? (
|
||||
<p>Loading discussion questions...</p>
|
||||
<p>Loading reflection questions...</p>
|
||||
) : error ? (
|
||||
<p className="study-note-status">{error}</p>
|
||||
) : !canSubmit ? (
|
||||
<article className="study-class-block">
|
||||
<h2>No Discussion Questions</h2>
|
||||
<p className="study-detail-copy">This lesson does not have discussion questions configured yet.</p>
|
||||
<h2>No Reflection Questions</h2>
|
||||
<p className="study-detail-copy">This lesson does not have reflection questions configured yet.</p>
|
||||
</article>
|
||||
) : (
|
||||
<form onSubmit={e => { e.preventDefault(); saveQuiz() }}>
|
||||
{sectionQuestions.map((question, index) => (
|
||||
<div key={index} style={{ marginBottom: '1.25rem' }}>
|
||||
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{`${index + 1}. ${question}`}</p>
|
||||
<div key={index} className="quiz-question-block">
|
||||
<p className="quiz-question-label">{`${index + 1}. ${question}`}</p>
|
||||
<textarea
|
||||
rows={4}
|
||||
className="quiz-question-textarea"
|
||||
value={answers[index] ?? ''}
|
||||
onChange={e => setAnswers(prev => {
|
||||
const next = [...prev]
|
||||
next[index] = e.target.value
|
||||
return next
|
||||
})}
|
||||
placeholder="Your answer..."
|
||||
style={{ width: '100%', padding: '0.9rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
|
||||
placeholder="Write your answer here…"
|
||||
/>
|
||||
{(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 style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving...' : 'Save Answers'}</button>
|
||||
<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="button" className="btn-secondary" onClick={() => navigate(`/study/${study.slug}/${section.id}`)}>Back to Lesson</button>
|
||||
</div>
|
||||
{message && <p className="study-note-status" style={{ marginTop: '1rem' }}>{message}</p>}
|
||||
|
||||
@@ -30,6 +30,8 @@ ChartJS.register(
|
||||
interface Props {
|
||||
stats: AdminStats | null
|
||||
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
|
||||
formatDate: (date: string | null) => string
|
||||
maskIp: (ip: string) => string
|
||||
@@ -51,6 +53,8 @@ interface Props {
|
||||
export function AnalyticsPanel({
|
||||
stats,
|
||||
statsStatus,
|
||||
statsRange = '30d',
|
||||
onRangeChange,
|
||||
opsStatus,
|
||||
formatDate,
|
||||
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 (
|
||||
<section className="admin-panel-section" aria-label="Analytics">
|
||||
<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>
|
||||
</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 */}
|
||||
<div className="admin-stats-grid">
|
||||
<article>
|
||||
<h3>Total Hits</h3>
|
||||
<p>{stats.totalHits.toLocaleString()}</p>
|
||||
<small>All page requests</small>
|
||||
<p>{rangeTotalHits.toLocaleString()}</p>
|
||||
<small>{rangeLabel} all requests</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Real Traffic</h3>
|
||||
<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>
|
||||
<h3>Bot Traffic</h3>
|
||||
<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>
|
||||
<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>
|
||||
</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 */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Deployment & Cache Status</h2>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ interface PublicQuestion {
|
||||
topic?: string
|
||||
submittedAt?: string
|
||||
answeredAt?: string
|
||||
upvotes?: number
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
interface DecoratedQuestion extends PublicQuestion {
|
||||
@@ -390,6 +392,12 @@ export default function QASection() {
|
||||
const [copiedQuestionId, setCopiedQuestionId] = useState<string | null>(null)
|
||||
const [copiedFacebookCaptionId, setCopiedFacebookCaptionId] = useState<string | null>(null)
|
||||
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)
|
||||
|
||||
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(() => {
|
||||
setEngagement(readEngagementFromStorage())
|
||||
}, [])
|
||||
@@ -839,22 +863,40 @@ export default function QASection() {
|
||||
: question.answer
|
||||
|
||||
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">
|
||||
<span className="qa-face-label">Q</span>
|
||||
<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-topic-pill">{question._topic}</span>
|
||||
{formatDate(question.answeredAt ?? question.submittedAt) && (
|
||||
<span>{formatDate(question.answeredAt ?? question.submittedAt)}</span>
|
||||
)}
|
||||
{question._helpful > 0 && <span>{question._helpful} helpful</span>}
|
||||
{(question.upvotes ?? 0) > 0 && (
|
||||
<span>👍 {question.upvotes}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
aria-label="Share on X"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user