From 2735ef216cb53d2bce92738ed9449c550ae5d37b Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 6 Jul 2026 09:25:28 -0400 Subject: [PATCH] Generalize podcast-specific UI for study-only users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Other people using this app just want to do personal Bible study, not produce a podcast episode, so the always-visible chrome (chunk metadata, DOCX import, exports) now says "Session" instead of "Episode" and marks podcast-only fields as optional. The "Prepare for Podcast" prompt no longer hardcodes "Verse by Verse with Nate" for every user — it now pulls from a new "Podcast / show name" field in Account Settings, so it still works exactly as before once you set yours, but produces a sensible generic prompt for anyone who hasn't. Also added an explanatory blurb to the session-list import page and loosened its docx parser to accept "Session N — Title" in addition to "Ep. N — Title". Co-Authored-By: Claude Sonnet 5 --- server/db.js | 13 ++++- server/index.js | 24 ++++++-- src/App.jsx | 141 +++++++++++++++++++++++++++++++++------------ src/syncService.js | 5 ++ 4 files changed, 139 insertions(+), 44 deletions(-) diff --git a/server/db.js b/server/db.js index d3a795e..11d3f08 100644 --- a/server/db.js +++ b/server/db.js @@ -58,6 +58,11 @@ export function initDb() { `); } + // Older databases predate the podcast-name profile setting. + if (!userCols.some((c) => c.name === 'podcast_name')) { + db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT'); + } + console.log(`SQLite database ready at ${DB_PATH}`); } @@ -98,7 +103,8 @@ function parseUserRow(row) { const USER_SELECT = ` SELECT id, email, password_hash AS passwordHash, created_at AS createdAt, - totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw + totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw, + podcast_name AS podcastName FROM users `; @@ -124,6 +130,11 @@ export function disableTotp(userId) { `).run(userId); } +/** Sets or clears the show name used in the "Prepare for Podcast" prompt. */ +export function setPodcastName(userId, podcastName) { + db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId); +} + /** Rewrites the remaining backup-code hashes after one is used (single-use codes). */ export function setBackupCodeHashes(userId, backupCodeHashes) { db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId); diff --git a/server/index.js b/server/index.js index e25e0a6..d87db02 100644 --- a/server/index.js +++ b/server/index.js @@ -7,7 +7,7 @@ import { dirname, join } from 'path'; import { initDb, getAllProjects, getProject, upsertProject, deleteProject, countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, - enableTotp, disableTotp, setBackupCodeHashes, + enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, } from './db.js'; import { SqliteSessionStore } from './sessionStore.js'; import { @@ -80,7 +80,7 @@ app.post('/api/auth/register', async (req, res) => { req.session.regenerate((err) => { if (err) return res.status(500).json({ error: 'Could not create session.' }); req.session.userId = user.id; - res.json({ id: user.id, email: user.email, totpEnabled: false }); + res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: null }); }); } catch (err) { console.error('POST /api/auth/register error:', err); @@ -108,7 +108,7 @@ app.post('/api/auth/login', async (req, res) => { return res.json({ mfaRequired: true }); } req.session.userId = user.id; - res.json({ id: user.id, email: user.email, totpEnabled: false }); + res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null }); }); } catch (err) { console.error('POST /api/auth/login error:', err); @@ -146,7 +146,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => { req.session.regenerate((err) => { if (err) return res.status(500).json({ error: 'Could not create session.' }); req.session.userId = user.id; - res.json({ id: user.id, email: user.email, totpEnabled: true }); + res.json({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null }); }); } catch (err) { console.error('POST /api/auth/mfa/verify error:', err); @@ -164,7 +164,21 @@ app.post('/api/auth/logout', (req, res) => { app.get('/api/auth/me', (req, res) => { const user = req.session?.userId ? getUserById(req.session.userId) : null; if (!user) return res.status(401).json({ error: 'Not signed in.' }); - res.json({ id: user.id, email: user.email, totpEnabled: user.totpEnabled }); + res.json({ id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null }); +}); + +// --------------------------------------------------------------------------- +// PATCH /api/auth/profile — update account-level settings (currently just podcastName) +// --------------------------------------------------------------------------- +app.patch('/api/auth/profile', requireAuth, (req, res) => { + try { + const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200); + setPodcastName(req.session.userId, podcastName || null); + res.json({ podcastName: podcastName || null }); + } catch (err) { + console.error('PATCH /api/auth/profile error:', err); + res.status(500).json({ error: 'Failed to save profile.' }); + } }); // --------------------------------------------------------------------------- diff --git a/src/App.jsx b/src/App.jsx index 74e9207..0c0b9a6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -30,6 +30,7 @@ import { startMfaSetup, confirmMfaSetup, disableMfa, + updateProfile, } from './syncService.js'; const COMMENTARY_OPTIONS = [ @@ -636,7 +637,7 @@ function buildChunkBodies(project) { const generalNotes = (chunk.generalNotes ?? '').trim(); const episodeLabelLine = (chunk.episodeNumber ?? '').trim() || (chunk.episodeTitle ?? '').trim() - ? `Episode: ${[chunk.episodeNumber?.trim(), chunk.episodeTitle?.trim()].filter(Boolean).join(' — ')}\n` + ? `Session: ${[chunk.episodeNumber?.trim(), chunk.episodeTitle?.trim()].filter(Boolean).join(' — ')}\n` : ''; const greekWords = chunk.greekWords.length === 0 @@ -721,17 +722,21 @@ export function buildPronunciationGuide(project) { return lines.join('\n'); } -export function buildPodcastPrompt(project) { +export function buildPodcastPrompt(project, podcastName) { const chapters = Array.isArray(project.chapters) ? project.chapters : []; const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', '); const episodeLabel = 'EPISODE'; + const showName = (podcastName ?? '').trim(); + const showLine = showName || 'MY BIBLE STUDY PODCAST'; + const signOffLine = showName + ? `Introduce yourself, then sign off with: "...and this is ${showName}."` + : 'Introduce yourself and the name of the show as a sign-off.'; - const header = `I'm recording an episode of my Bible study podcast "Verse by Verse with Nate: A Journey Through Scripture" and need a full script written from my study notes below. + const header = `I'm recording an episode of my Bible study podcast${showName ? ` "${showName}"` : ''} and need a full script written from my study notes below. Please write the script in this exact structure, using the section markers and tone shown: -VERSE BY VERSE WITH NATE -A Journey Through Scripture +${showLine.toUpperCase()} ${episodeLabel} ${chapterLabel} (${project.translation}) · [estimate XX–XX minutes based on content] @@ -742,14 +747,14 @@ A short prayer (4-6 sentences) tying into the themes of this passage. — ✦ — 🎙️ COLD OPEN -[Co-host delivers the cold open solo — hands off to Nate at the end] -A few short punchy lines previewing the passage and its hook, ending with a hand-off line introducing Nate and the show. +[Co-host delivers the cold open solo, if there is one — hands off to the host at the end] +A few short punchy lines previewing the passage and its hook, ending with a hand-off line introducing the host and the show. — ✦ — 📖 SEGMENT [N] — [SEGMENT TITLE] [Brief stage direction in brackets] -One segment per passage chunk (use the chunk reference, observation, interpretation, and application notes below as the raw material). Conversational, spoken-word style — not academic prose. Work through the text the way Nate would talk it through out loud, weaving in the OIA notes and cross-references naturally. +One segment per passage chunk (use the chunk reference, observation, interpretation, and application notes below as the raw material). Conversational, spoken-word style — not academic prose. Work through the text the way the host would talk it through out loud, weaving in the OIA notes and cross-references naturally. — ✦ — @@ -771,13 +776,12 @@ For each Greek/Hebrew word collected below, a block in this format: ✦ CLOSING [Grounded and direct — send them away with something to carry] -A closing reflection that ties the segments together, a short pull-quote from the passage with its reference, then sign off with: -"I'm Nate, and this is Verse by Verse with Nate: A Journey Through Scripture." -"Until next time — keep studying verse by verse, and nugget by nugget." +A closing reflection that ties the segments together, a short pull-quote from the passage with its reference, then close out. ${signOffLine} +"Until next time — keep studying verse by verse." — End of Episode — -Verse by Verse with Nate · ${episodeLabel} · ${chapterLabel} +${showLine} · ${episodeLabel} · ${chapterLabel} --- @@ -882,8 +886,8 @@ function parseEpisodePassage(raw) { }; } -// Extracts an "Ep. # / Title / Passage" episode list from a docx, including both -// table rows and standalone "Ep. N — Title" paragraphs (e.g. part intros). +// Extracts a "Session # / Title / Passage" list from a docx, including both +// table rows and standalone "Ep. N — Title" / "Session N — Title" paragraphs (e.g. part intros). async function parseEpisodeListDocx(file) { const arrayBuffer = await file.arrayBuffer(); const { value: html } = await mammoth.convertToHtml({ arrayBuffer }); @@ -900,7 +904,7 @@ async function parseEpisodeListDocx(file) { }); parsedDoc.querySelectorAll('p').forEach((p) => { - const match = p.textContent.trim().match(/^Ep\.\s*(\d+)\s*[-–—]\s*(.+)$/); + const match = p.textContent.trim().match(/^(?:Ep\.|Episode|Session)\s*(\d+)\s*[-–—]\s*(.+)$/i); if (!match) return; const [, epRaw, title] = match; if (!episodes.has(epRaw)) { @@ -983,6 +987,9 @@ const App = () => { const [mfaBackupCodes, setMfaBackupCodes] = useState(null); // shown once, right after enabling const [mfaDisablePassword, setMfaDisablePassword] = useState(''); const [mfaDisableError, setMfaDisableError] = useState(''); + const [podcastNameInput, setPodcastNameInput] = useState(''); + const [podcastNameSaving, setPodcastNameSaving] = useState(false); + const [podcastNameSaved, setPodcastNameSaved] = useState(false); const [project, setProject] = useState(null); // 'home' | 'setup' | 'study' | 'settings' const [currentPage, setCurrentPage] = useState('home'); @@ -1387,6 +1394,11 @@ const App = () => { }); }, []); + // Keep the Settings page's podcast-name field in sync with whichever account is signed in. + useEffect(() => { + setPodcastNameInput(authUser?.podcastName ?? ''); + }, [authUser?.id]); + // Once we know who's signed in, reconcile the local project index against the server. // Runs on every authUser change (including logout -> different login) so a previous // account's stale suggestions never linger after switching users. Projects that exist @@ -2731,7 +2743,7 @@ const App = () => { const headingRuns = [new TextRun({ text: scriptureHeading, bold: true, size: 28, color: DOCX_ACCENT, font: DOCX_HEADING_FONT })]; if (chunk.episodeNumber || chunk.episodeTitle) { headingRuns.push(new TextRun({ - text: ` (Ep. ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`, + text: ` (Session ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`, italics: true, size: 22, color: '7A7060', @@ -2894,7 +2906,7 @@ const App = () => { const copyForPodcast = () => { if (!project) return; - const prompt = buildPodcastPrompt(project); + const prompt = buildPodcastPrompt(project, authUser?.podcastName); navigator.clipboard.writeText(prompt).then(() => { setSaveStatus('Copied podcast prep!'); window.setTimeout(() => setSaveStatus(''), 2000); @@ -2946,7 +2958,7 @@ const App = () => { try { const episodes = await parseEpisodeListDocx(file); if (episodes.length === 0) { - throw new Error('No "Ep. / Title / Passage" table found in that document.'); + throw new Error('No "Session # / Title / Passage" table found in that document.'); } const specs = episodes.map((ep) => ({ ...ep, parsed: parseEpisodePassage(ep.passage) })); setImportPreview(specs); @@ -3057,7 +3069,7 @@ const App = () => { const newProject = { id: makeId(), - title: importTitle.trim() || `${selectedBook.name} Episodes`, + title: importTitle.trim() || `${selectedBook.name} Sessions`, translation: importTranslation, lastEdited: Date.now(), selectedChunkId: null, @@ -3186,7 +3198,7 @@ const deleteProject = (id) => { type="button" onClick={copyForPodcast} disabled={allChunks.length === 0} - title="Copy a prompt for Claude to write a full episode script in the Verse by Verse with Nate format, ready to record" + title="Copy a prompt for Claude to write a full spoken-word episode script from your notes, ready to record. Optional — only useful if you're producing a podcast or similar audio series. Set your show name in Account Settings first." className="rounded-md bg-fuchsia-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-fuchsia-400 disabled:cursor-not-allowed disabled:bg-slate-500" > 🎙 Prepare for Podcast @@ -3465,6 +3477,18 @@ const deleteProject = (id) => { setAuthUser((u) => ({ ...u, totpEnabled: false })); }; + const submitPodcastName = async (e) => { + e.preventDefault(); + setPodcastNameSaving(true); + setPodcastNameSaved(false); + const result = await updateProfile({ podcastName: podcastNameInput.trim() }); + setPodcastNameSaving(false); + if (!result.ok) return; + setAuthUser((u) => ({ ...u, podcastName: result.data.podcastName })); + setPodcastNameSaved(true); + window.setTimeout(() => setPodcastNameSaved(false), 2500); + }; + return (
@@ -3494,6 +3518,34 @@ const deleteProject = (id) => {

+
+
+

Podcast / show name

+

+ Only needed if you use "🎙 Prepare for Podcast" on the study page — it fills in your show's + name when asking Claude to write an episode script. Leave blank if you're just doing personal + study; that button still works, it just won't name a specific show. +

+
+
+ setPodcastNameInput(e.target.value)} + placeholder="e.g. Verse by Verse with Nate: A Journey Through Scripture" + className="w-full flex-1 rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" + /> + +
+ {podcastNameSaved &&

Saved.

} +
+

Two-factor authentication

@@ -3625,7 +3677,7 @@ const deleteProject = (id) => { onClick={openImportProject} className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700" > - 📥 Import Episodes + 📥 Import Session List @@ -5384,7 +5449,7 @@ const deleteProject = (id) => { value={selectedChunk?.finalScript ?? ''} onChange={(e) => updateChunk(selectedChunk.id, { finalScript: e.target.value })} rows={8} - placeholder="Paste the final recorded/recordable episode script here…" + placeholder="Paste the final recorded/recordable script here…" className="mt-4 w-full resize-y rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm leading-6 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" /> diff --git a/src/syncService.js b/src/syncService.js index 42c1d5d..0148641 100644 --- a/src/syncService.js +++ b/src/syncService.js @@ -111,6 +111,11 @@ export async function verifyMfaLogin({ token, backupCode }) { return request('POST', '/auth/mfa/verify', { token, backupCode }); } +/** Updates account-level profile settings (currently just the podcast/show name). */ +export async function updateProfile({ podcastName }) { + return request('PATCH', '/auth/profile', { podcastName }); +} + // --------------------------------------------------------------------------- // Two-factor auth setup (Account Settings page) // ---------------------------------------------------------------------------