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 (
+ 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. +
+Saved.
} +