Generalize podcast-specific UI for study-only users

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 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-06 09:25:28 -04:00
parent 89e701bbda
commit 2735ef216c
4 changed files with 139 additions and 44 deletions
+12 -1
View File
@@ -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}`); console.log(`SQLite database ready at ${DB_PATH}`);
} }
@@ -98,7 +103,8 @@ function parseUserRow(row) {
const USER_SELECT = ` const USER_SELECT = `
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt, 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 FROM users
`; `;
@@ -124,6 +130,11 @@ export function disableTotp(userId) {
`).run(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). */ /** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
export function setBackupCodeHashes(userId, backupCodeHashes) { export function setBackupCodeHashes(userId, backupCodeHashes) {
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId); db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
+19 -5
View File
@@ -7,7 +7,7 @@ import { dirname, join } from 'path';
import { import {
initDb, getAllProjects, getProject, upsertProject, deleteProject, initDb, getAllProjects, getProject, upsertProject, deleteProject,
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
enableTotp, disableTotp, setBackupCodeHashes, enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
} from './db.js'; } from './db.js';
import { SqliteSessionStore } from './sessionStore.js'; import { SqliteSessionStore } from './sessionStore.js';
import { import {
@@ -80,7 +80,7 @@ app.post('/api/auth/register', async (req, res) => {
req.session.regenerate((err) => { req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' }); if (err) return res.status(500).json({ error: 'Could not create session.' });
req.session.userId = user.id; 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) { } catch (err) {
console.error('POST /api/auth/register error:', 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 }); return res.json({ mfaRequired: true });
} }
req.session.userId = user.id; 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) { } catch (err) {
console.error('POST /api/auth/login error:', 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) => { req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' }); if (err) return res.status(500).json({ error: 'Could not create session.' });
req.session.userId = user.id; 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) { } catch (err) {
console.error('POST /api/auth/mfa/verify error:', 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) => { app.get('/api/auth/me', (req, res) => {
const user = req.session?.userId ? getUserById(req.session.userId) : null; const user = req.session?.userId ? getUserById(req.session.userId) : null;
if (!user) return res.status(401).json({ error: 'Not signed in.' }); 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.' });
}
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+103 -38
View File
@@ -30,6 +30,7 @@ import {
startMfaSetup, startMfaSetup,
confirmMfaSetup, confirmMfaSetup,
disableMfa, disableMfa,
updateProfile,
} from './syncService.js'; } from './syncService.js';
const COMMENTARY_OPTIONS = [ const COMMENTARY_OPTIONS = [
@@ -636,7 +637,7 @@ function buildChunkBodies(project) {
const generalNotes = (chunk.generalNotes ?? '').trim(); const generalNotes = (chunk.generalNotes ?? '').trim();
const episodeLabelLine = (chunk.episodeNumber ?? '').trim() || (chunk.episodeTitle ?? '').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 const greekWords = chunk.greekWords.length === 0
@@ -721,17 +722,21 @@ export function buildPronunciationGuide(project) {
return lines.join('\n'); return lines.join('\n');
} }
export function buildPodcastPrompt(project) { export function buildPodcastPrompt(project, podcastName) {
const chapters = Array.isArray(project.chapters) ? project.chapters : []; const chapters = Array.isArray(project.chapters) ? project.chapters : [];
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', '); const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
const episodeLabel = 'EPISODE'; 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: Please write the script in this exact structure, using the section markers and tone shown:
VERSE BY VERSE WITH NATE ${showLine.toUpperCase()}
A Journey Through Scripture
${episodeLabel} ${episodeLabel}
${chapterLabel} (${project.translation}) · [estimate XXXX minutes based on content] ${chapterLabel} (${project.translation}) · [estimate XXXX minutes based on content]
@@ -742,14 +747,14 @@ A short prayer (4-6 sentences) tying into the themes of this passage.
— ✦ — — ✦ —
🎙️ COLD OPEN 🎙️ COLD OPEN
[Co-host delivers the cold open solo — hands off to Nate at the end] [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 Nate and the show. 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] 📖 SEGMENT [N] — [SEGMENT TITLE]
[Brief stage direction in brackets] [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 ✦ CLOSING
[Grounded and direct — send them away with something to carry] [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: A closing reflection that ties the segments together, a short pull-quote from the passage with its reference, then close out. ${signOffLine}
"I'm Nate, and this is Verse by Verse with Nate: A Journey Through Scripture." "Until next time — keep studying verse by verse."
"Until next time — keep studying verse by verse, and nugget by nugget."
— End of Episode — — 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 // Extracts a "Session # / Title / Passage" list from a docx, including both
// table rows and standalone "Ep. N — Title" paragraphs (e.g. part intros). // table rows and standalone "Ep. N — Title" / "Session N — Title" paragraphs (e.g. part intros).
async function parseEpisodeListDocx(file) { async function parseEpisodeListDocx(file) {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const { value: html } = await mammoth.convertToHtml({ arrayBuffer }); const { value: html } = await mammoth.convertToHtml({ arrayBuffer });
@@ -900,7 +904,7 @@ async function parseEpisodeListDocx(file) {
}); });
parsedDoc.querySelectorAll('p').forEach((p) => { 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; if (!match) return;
const [, epRaw, title] = match; const [, epRaw, title] = match;
if (!episodes.has(epRaw)) { if (!episodes.has(epRaw)) {
@@ -983,6 +987,9 @@ const App = () => {
const [mfaBackupCodes, setMfaBackupCodes] = useState(null); // shown once, right after enabling const [mfaBackupCodes, setMfaBackupCodes] = useState(null); // shown once, right after enabling
const [mfaDisablePassword, setMfaDisablePassword] = useState(''); const [mfaDisablePassword, setMfaDisablePassword] = useState('');
const [mfaDisableError, setMfaDisableError] = useState(''); const [mfaDisableError, setMfaDisableError] = useState('');
const [podcastNameInput, setPodcastNameInput] = useState('');
const [podcastNameSaving, setPodcastNameSaving] = useState(false);
const [podcastNameSaved, setPodcastNameSaved] = useState(false);
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study' | 'settings' // 'home' | 'setup' | 'study' | 'settings'
const [currentPage, setCurrentPage] = useState('home'); 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. // 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 // Runs on every authUser change (including logout -> different login) so a previous
// account's stale suggestions never linger after switching users. Projects that exist // 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 })]; const headingRuns = [new TextRun({ text: scriptureHeading, bold: true, size: 28, color: DOCX_ACCENT, font: DOCX_HEADING_FONT })];
if (chunk.episodeNumber || chunk.episodeTitle) { if (chunk.episodeNumber || chunk.episodeTitle) {
headingRuns.push(new TextRun({ headingRuns.push(new TextRun({
text: ` (Ep. ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`, text: ` (Session ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`,
italics: true, italics: true,
size: 22, size: 22,
color: '7A7060', color: '7A7060',
@@ -2894,7 +2906,7 @@ const App = () => {
const copyForPodcast = () => { const copyForPodcast = () => {
if (!project) return; if (!project) return;
const prompt = buildPodcastPrompt(project); const prompt = buildPodcastPrompt(project, authUser?.podcastName);
navigator.clipboard.writeText(prompt).then(() => { navigator.clipboard.writeText(prompt).then(() => {
setSaveStatus('Copied podcast prep!'); setSaveStatus('Copied podcast prep!');
window.setTimeout(() => setSaveStatus(''), 2000); window.setTimeout(() => setSaveStatus(''), 2000);
@@ -2946,7 +2958,7 @@ const App = () => {
try { try {
const episodes = await parseEpisodeListDocx(file); const episodes = await parseEpisodeListDocx(file);
if (episodes.length === 0) { 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) })); const specs = episodes.map((ep) => ({ ...ep, parsed: parseEpisodePassage(ep.passage) }));
setImportPreview(specs); setImportPreview(specs);
@@ -3057,7 +3069,7 @@ const App = () => {
const newProject = { const newProject = {
id: makeId(), id: makeId(),
title: importTitle.trim() || `${selectedBook.name} Episodes`, title: importTitle.trim() || `${selectedBook.name} Sessions`,
translation: importTranslation, translation: importTranslation,
lastEdited: Date.now(), lastEdited: Date.now(),
selectedChunkId: null, selectedChunkId: null,
@@ -3186,7 +3198,7 @@ const deleteProject = (id) => {
type="button" type="button"
onClick={copyForPodcast} onClick={copyForPodcast}
disabled={allChunks.length === 0} 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" 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 🎙 Prepare for Podcast
@@ -3465,6 +3477,18 @@ const deleteProject = (id) => {
setAuthUser((u) => ({ ...u, totpEnabled: false })); 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 ( return (
<div className="min-h-screen bg-slate-50 text-slate-900"> <div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm"> <header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
@@ -3494,6 +3518,34 @@ const deleteProject = (id) => {
</p> </p>
</section> </section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Podcast / show name</h2>
<p className="text-sm text-slate-500">
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.
</p>
</div>
<form onSubmit={submitPodcastName} className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={podcastNameInput}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={podcastNameSaving}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{podcastNameSaving ? 'Saving' : 'Save'}
</button>
</form>
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
</section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4"> <section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2> <h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2>
@@ -3625,7 +3677,7 @@ const deleteProject = (id) => {
onClick={openImportProject} 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" 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
</button> </button>
<button <button
type="button" type="button"
@@ -4188,7 +4240,7 @@ const deleteProject = (id) => {
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-5 sm:px-6 lg:px-8"> <div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-5 sm:px-6 lg:px-8">
<div> <div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p> <p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">Import Episode List</h1> <h1 className="mt-2 text-2xl font-semibold">Import Session List</h1>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
@@ -4205,10 +4257,20 @@ const deleteProject = (id) => {
<main className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 space-y-6"> <main className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-5"> <section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-5">
<p className="text-sm text-slate-500"> <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600 space-y-2">
Upload a .docx with an episode table (Ep. # / Title / Passage, e.g. "1:12") and it'll <p className="font-semibold text-slate-700">How this works</p>
create a new project with chapters and chunks already labeled from it. <p>
</p> This is a shortcut for setting up a multi-part study — a teaching series, sermon series, class
curriculum, or podcast — all at once, instead of building each chapter and chunk by hand.
</p>
<p>
Upload a .docx containing a table with three columns: session number, title, and passage
(e.g. <span className="font-mono text-xs">1:12</span>). Each row becomes one chunk, grouped
automatically by chapter. If you don't have a document like this, just use{' '}
<span className="font-semibold">+ New Project</span> on the home page instead this import
step is entirely optional.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<label className="block text-sm font-medium text-slate-700"> <label className="block text-sm font-medium text-slate-700">
@@ -4243,13 +4305,13 @@ const deleteProject = (id) => {
type="text" type="text"
value={importTitle} value={importTitle}
onChange={(e) => setImportTitle(e.target.value)} onChange={(e) => setImportTitle(e.target.value)}
placeholder={`${bookOptions.find((b) => b.abbrev === importBookAbbrev)?.name ?? ''} Episodes`} placeholder={`${bookOptions.find((b) => b.abbrev === importBookAbbrev)?.name ?? ''} Sessions`}
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/> />
</label> </label>
<label className="block text-sm font-medium text-slate-700"> <label className="block text-sm font-medium text-slate-700">
Episode list (.docx) Session list (.docx)
<input <input
type="file" type="file"
accept=".docx" accept=".docx"
@@ -4264,7 +4326,7 @@ const deleteProject = (id) => {
{importPreview && !importBusy && ( {importPreview && !importBusy && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-semibold text-slate-700"> <p className="text-sm font-semibold text-slate-700">
{importPreview.length} episode{importPreview.length === 1 ? '' : 's'} found {importPreview.length} session{importPreview.length === 1 ? '' : 's'} found
{' · '} {' · '}
{importPreview.filter((s) => s.parsed && s.parsed !== 'invalid').length} with passages {importPreview.filter((s) => s.parsed && s.parsed !== 'invalid').length} with passages
</p> </p>
@@ -4273,7 +4335,7 @@ const deleteProject = (id) => {
<tbody> <tbody>
{importPreview.map((spec) => ( {importPreview.map((spec) => (
<tr key={spec.episodeNumber} className="border-b border-slate-100 last:border-0"> <tr key={spec.episodeNumber} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-1.5 text-slate-500">Ep. {spec.episodeNumber}</td> <td className="px-3 py-1.5 text-slate-500">#{spec.episodeNumber}</td>
<td className="px-3 py-1.5 text-slate-900">{spec.title}</td> <td className="px-3 py-1.5 text-slate-900">{spec.title}</td>
<td className="px-3 py-1.5 text-right text-slate-500"> <td className="px-3 py-1.5 text-right text-slate-500">
{spec.parsed === 'invalid' {spec.parsed === 'invalid'
@@ -4857,23 +4919,26 @@ const deleteProject = (id) => {
</div> </div>
)} )}
{/* Episode metadata for podcast prep — unique per chunk */} {/* Session metadata — optional, used to label this chunk within a series */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}> <div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
<h3 className="text-sm font-semibold text-slate-900">Episode Info</h3> <h3 className="text-sm font-semibold text-slate-900">Session Info</h3>
<p className="text-xs text-slate-500">Used to label the script when preparing podcast content for this chunk.</p> <p className="text-xs text-slate-500">
Optional only fill this in if this chunk is part of a numbered series (a podcast episode,
sermon, or class session). Used to label it in exports and in "Prepare for Podcast."
</p>
<div className="mt-3 flex flex-col gap-3 sm:flex-row"> <div className="mt-3 flex flex-col gap-3 sm:flex-row">
<input <input
type="text" type="text"
value={selectedChunk?.episodeNumber ?? ''} value={selectedChunk?.episodeNumber ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { episodeNumber: e.target.value })} onChange={(e) => updateChunk(selectedChunk.id, { episodeNumber: e.target.value })}
placeholder="Episode #" placeholder="Session #"
className="w-full rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 sm:w-32" className="w-full rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 sm:w-32"
/> />
<input <input
type="text" type="text"
value={selectedChunk?.episodeTitle ?? ''} value={selectedChunk?.episodeTitle ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { episodeTitle: e.target.value })} onChange={(e) => updateChunk(selectedChunk.id, { episodeTitle: e.target.value })}
placeholder="Episode title" placeholder="Session title"
className="w-full rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="w-full rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/> />
</div> </div>
@@ -5357,8 +5422,8 @@ const deleteProject = (id) => {
className="flex w-full items-center justify-between gap-2 text-left" className="flex w-full items-center justify-between gap-2 text-left"
> >
<div> <div>
<h3 className="text-sm font-semibold text-slate-900">Final Script</h3> <h3 className="text-sm font-semibold text-slate-900">Final Script <span className="font-normal text-slate-400">(optional)</span></h3>
<p className="text-xs text-slate-500">Paste the finished script for this chunk once Claude has helped you write it — keeps the project as a complete archive.</p> <p className="text-xs text-slate-500">Paste the finished script for this chunk once Claude has helped you write it keeps the project as a complete archive. Useful for a podcast, sermon, or any spoken teaching, not required for personal study.</p>
</div> </div>
<span className="text-slate-400">{collapsedSections.finalScript ? '▸' : '▾'}</span> <span className="text-slate-400">{collapsedSections.finalScript ? '▸' : '▾'}</span>
</button> </button>
@@ -5384,7 +5449,7 @@ const deleteProject = (id) => {
value={selectedChunk?.finalScript ?? ''} value={selectedChunk?.finalScript ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { finalScript: e.target.value })} onChange={(e) => updateChunk(selectedChunk.id, { finalScript: e.target.value })}
rows={8} 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" 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"
/> />
</> </>
+5
View File
@@ -111,6 +111,11 @@ export async function verifyMfaLogin({ token, backupCode }) {
return request('POST', '/auth/mfa/verify', { 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) // Two-factor auth setup (Account Settings page)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------