From 4b14b3fbe67f21331c9a3f028476b6e17453ba59 Mon Sep 17 00:00:00 2001
From: nmemmert <67961292+nmemmert@users.noreply.github.com>
Date: Tue, 22 Sep 2026 14:56:31 -0400
Subject: [PATCH] Extract project header, share link, exports, and auth status
- ProjectHeader component replaces the headerButtons JSX in context; it
owns the export menu and share panel state.
- useShareLink (share status/enable/revoke/copy) and useSharedView
(?share= read-only view, now used directly by the router).
- utils/projectExport: HTML/Markdown/DOCX download and print-to-PDF.
- AuthStatus component replaces the authStatus JSX in context.
- Pages import wordStudy helpers directly instead of via context.
- Remove dead copyPronunciationGuide (PronunciationPage has its own).
- Tests: Markdown export, enabling a share link, the ?share= view.
Co-Authored-By: Claude Opus 5.5
---
src/App.jsx | 391 ++-----------------------------
src/App.test.jsx | 47 +++-
src/components/AuthStatus.jsx | 35 +++
src/components/ProjectHeader.jsx | 191 +++++++++++++++
src/hooks/useShareLink.js | 48 ++++
src/hooks/useSharedView.js | 20 ++
src/pages/AdminPage.jsx | 5 +-
src/pages/HomePage.jsx | 4 +-
src/pages/ImportPage.jsx | 5 +-
src/pages/ReaderPage.jsx | 7 +-
src/pages/SettingsPage.jsx | 4 +-
src/pages/SetupPage.jsx | 4 +-
src/pages/StudyPage.jsx | 8 +-
src/utils/projectExport.js | 49 ++++
14 files changed, 425 insertions(+), 393 deletions(-)
create mode 100644 src/components/AuthStatus.jsx
create mode 100644 src/components/ProjectHeader.jsx
create mode 100644 src/hooks/useShareLink.js
create mode 100644 src/hooks/useSharedView.js
create mode 100644 src/utils/projectExport.js
diff --git a/src/App.jsx b/src/App.jsx
index cc362f0..17e165b 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -5,6 +5,7 @@ import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useReadingPlan } from './hooks/useReadingPlan.js';
+import { useSharedView } from './hooks/useSharedView.js';
import {
loadNtGloss,
loadNtConcordance,
@@ -13,11 +14,9 @@ import {
GREEK_FUNCTION_WORDS,
shortHebrewGloss,
ENGLISH_STOP_WORDS,
- externalLookupLinks,
buildGreekDefinitionHtml,
fetchBollsDefinition,
formatCrossRef,
- speakOriginalWord,
} from './utils/wordStudy.js';
import HomePage from './pages/HomePage.jsx';
@@ -32,28 +31,17 @@ import {
parseBibleChapter,
loadProjectIndex,
switchStorageUser,
- buildPronunciationGuide,
} from './utils/bibleUtils.jsx';
import {
saveProjectToStorage,
migrateOldStorageKeys,
- buildChapterSummary,
} from './utils/projectStorage.js';
-import {
- buildMarkdownExport,
- buildClaudePrompt,
- buildPodcastPrompt,
-} from './utils/exportBuilders.js';
import {
parseCrossRefString,
} from './utils/importParsers.js';
import {
saveRemoteProject,
- getShareStatus,
- enableSharing,
- disableSharing,
- getSharedProject,
} from './syncService.js';
// Less-common pages are split into their own chunks so the first load only
@@ -84,15 +72,6 @@ const App = () => {
title: 'Titus 1 Study',
});
const [titleEdited, setTitleEdited] = useState(false);
- const [exportMenuOpen, setExportMenuOpen] = useState(false);
- // Read-only share links
- const [shareToken, setShareToken] = useState(null);
- const [sharePanelOpen, setSharePanelOpen] = useState(false);
- const [shareBusy, setShareBusy] = useState(false);
- const [shareCopied, setShareCopied] = useState(false);
- const [sharedViewToken] = useState(() => new URLSearchParams(window.location.search).get('share'));
- const [sharedProject, setSharedProject] = useState(null);
- const [sharedError, setSharedError] = useState('');
const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study' | 'settings'
const [currentPage, setCurrentPage] = useState('home');
@@ -209,28 +188,6 @@ const App = () => {
setProjectIndex(loadProjectIndex());
}, []);
- // A ?share=TOKEN URL loads a read-only view of someone else's project — no
- // login required, so this runs independent of auth state entirely.
- useEffect(() => {
- if (!sharedViewToken) return;
- getSharedProject(sharedViewToken).then((result) => {
- if (result.ok) {
- setSharedProject(result.data);
- } else {
- setSharedError(result.error ?? 'This share link is invalid or has been revoked.');
- }
- });
- }, [sharedViewToken]);
-
- // Loads current share status whenever the study page's project changes.
- useEffect(() => {
- if (!project?.id || currentPage !== 'study') return;
- setShareToken(null);
- getShareStatus(project.id).then((result) => {
- if (result.ok) setShareToken(result.data.shareToken);
- });
- }, [project?.id, currentPage]);
-
useEffect(() => {
if (!titleEdited) {
setSetup((current) => ({
@@ -1269,123 +1226,6 @@ const App = () => {
}
};
- // ---------------------------------------------------------------------------
- // Export
- // ---------------------------------------------------------------------------
-
- const exportChapter = () => {
- if (!project) return;
- const html = buildExportHtml(project);
- const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
- const url = URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = `${project.title.replace(/\s+/g, '-')}-study.html`;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
- };
-
- const printChapterPdf = () => {
- if (!project) return;
- const html = buildExportHtml(project);
- const printWindow = window.open('', '_blank');
- if (!printWindow) {
- alert('Please allow pop-ups for this site to print or save as PDF.');
- return;
- }
- printWindow.document.write(html);
- printWindow.document.close();
- printWindow.onload = () => {
- printWindow.focus();
- printWindow.print();
- };
- };
-
- const handleEnableSharing = async () => {
- if (!project) return;
- setShareBusy(true);
- const result = await enableSharing(project.id);
- setShareBusy(false);
- if (result.ok) setShareToken(result.data.shareToken);
- };
-
- const handleDisableSharing = async () => {
- if (!project) return;
- if (!window.confirm('Revoke this share link? Anyone using it will lose access immediately.')) return;
- setShareBusy(true);
- const result = await disableSharing(project.id);
- setShareBusy(false);
- if (result.ok) setShareToken(null);
- };
-
- const handleCopyShareLink = () => {
- if (!shareToken) return;
- const url = `${window.location.origin}${window.location.pathname}?share=${shareToken}`;
- navigator.clipboard.writeText(url).then(() => {
- setShareCopied(true);
- window.setTimeout(() => setShareCopied(false), 2000);
- });
- };
-
- const exportChapterMarkdown = () => {
- if (!project) return;
- const markdown = buildMarkdownExport(project);
- const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
- const url = URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = `${project.title.replace(/\s+/g, '-')}-study.md`;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
- };
-
- const exportChapterDocx = async () => {
- if (!project) return;
- const { buildStudyDocxBlob } = await import('./utils/docxExport.js');
- const blob = await buildStudyDocxBlob(project, buildChapterSummary(project));
- const url = URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = `${project.title.replace(/\s+/g, '-')}-study.docx`;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
- };
-
- const copyForClaude = () => {
- if (!project) return;
- const prompt = buildClaudePrompt(project);
- navigator.clipboard.writeText(prompt).then(() => {
- setSaveStatus('Copied! Opening Claude…');
- window.setTimeout(() => setSaveStatus(''), 3000);
- window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
- });
- };
-
- const copyForPodcast = () => {
- if (!project) return;
- const prompt = buildPodcastPrompt(project, authUser?.podcastName);
- navigator.clipboard.writeText(prompt).then(() => {
- setSaveStatus('Copied! Opening Claude…');
- window.setTimeout(() => setSaveStatus(''), 3000);
- window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
- });
- };
-
- const copyPronunciationGuide = () => {
- if (!project) return;
- const guide = buildPronunciationGuide(project);
- navigator.clipboard.writeText(guide).then(() => {
- setSaveStatus('Copied pronunciation guide!');
- window.setTimeout(() => setSaveStatus(''), 2000);
- });
- };
-
// ---------------------------------------------------------------------------
// Home page helpers
// ---------------------------------------------------------------------------
@@ -1417,197 +1257,16 @@ const App = () => {
setStatusMessage('');
};
- const authStatus = authUser && (
-
- {authUser.email}
- {authUser.isAdmin && (
-
- )}
-
-
-
- );
-
- // ---------------------------------------------------------------------------
- // Shared header
- // ---------------------------------------------------------------------------
- const headerButtons = (
-
- {currentPage !== 'home' && (
-
- )}
- {project && currentPage === 'study' && (
-
- )}
- {project && (
-
-
-
-
-
-
- {exportMenuOpen && (
-
-
-
-
-
-
- )}
-
-
-
- {sharePanelOpen && (
-
-
Read-only share link
-
- Anyone with this link can view (not edit) this study — no account needed. Good for a co-teacher
- or a listener who wants to follow along.
-
- {shareToken ? (
-
-
- e.target.select()}
- className="w-full flex-1 truncate rounded-lg border border-slate-300 bg-slate-50 px-2 py-1.5 text-xs text-slate-600"
- />
-
-
-
-
- ) : (
-
- )}
-
- )}
-
-
- )}
-
- {loadingChapter ? (
-
Loading…
- ) : saveStatus ? (
-
{saveStatus}
- ) : (
-
- )}
- {syncStatus === 'syncing' &&
Syncing…
}
- {syncStatus === 'synced' &&
Synced ✓
}
- {syncStatus === 'error' && (
-
- ⚠ Sync failed
-
-
- )}
-
- {authStatus}
-
- );
+ // Manual retry after a failed background sync (shown in the project header).
+ const retrySync = async () => {
+ if (!project) return;
+ setSyncStatus('syncing');
+ const toSave = { ...project, lastEdited: Date.now() };
+ saveProjectToStorage(toSave);
+ const result = await saveRemoteProject(toSave);
+ setSyncStatus(result.ok ? 'synced' : 'error');
+ if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
+ };
// ---------------------------------------------------------------------------
// Context value — every state, handler, and computed value pages need.
@@ -1617,14 +1276,14 @@ const App = () => {
currentPage, setCurrentPage,
goHome,
authServerDown,
- // Shared view
- sharedViewToken,
- sharedProject,
- sharedError,
// Auth (login/2FA/settings form state lives in AuthPage and SettingsPage)
authUser, setAuthUser,
- authStatus,
signIn,
+ logout,
+ // Save / sync status (shown in ProjectHeader)
+ saveStatus, setSaveStatus,
+ syncStatus,
+ retrySync,
// Home
projectIndex,
...library,
@@ -1707,16 +1366,8 @@ const App = () => {
suggestGreekWordsForChunk,
suggestHebrewWordsForChunk,
confirmSuggestWords,
- speakOriginalWord,
loadVerseText,
importFinalScriptDocx,
- // Functions defined inside App (cannot be module-level exports)
- formatCrossRef,
- buildGreekDefinitionHtml,
- externalLookupLinks,
- // Computed JSX
- authStatus,
- headerButtons,
};
return (
@@ -1737,14 +1388,8 @@ function PageLoading() {
}
function AppRouter() {
- const {
- currentPage,
- authUser,
- authServerDown,
- sharedViewToken,
- sharedProject,
- sharedError,
- } = useApp();
+ const { currentPage, authUser, authServerDown } = useApp();
+ const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView();
if (sharedViewToken) {
if (sharedError) {
diff --git a/src/App.test.jsx b/src/App.test.jsx
index 6162b78..bf195dc 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -80,7 +80,8 @@ async function loadChapter() {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
- await user.click(screen.getByRole('button', { name: /load chapter/i }));
+ // SetupPage is lazy-loaded, so wait for it on first open
+ await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
return user;
}
@@ -586,3 +587,47 @@ describe('Home page', () => {
await waitFor(() => expect(JSON.parse(localStorage.getItem('readingPlan'))?.bookAbbrev).toBe('GEN'));
});
});
+
+// ---------------------------------------------------------------------------
+// Project header (ProjectHeader + useShareLink) and the shared read-only view
+// ---------------------------------------------------------------------------
+describe('Project header and sharing', () => {
+ const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
+
+ test('Export → Markdown downloads a file', async () => {
+ const user = await loadChapter();
+ await user.click(screen.getByRole('button', { name: /export/i }));
+ await user.click(screen.getByRole('button', { name: /export markdown/i }));
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ });
+
+ test('enabling sharing shows a copyable link', async () => {
+ const fallback = buildFetchMock();
+ const fetchMock = vi.fn((url, opts = {}) => {
+ if (url.startsWith('/api/projects/') && url.endsWith('/share')) {
+ return (opts.method ?? 'GET') === 'POST' ? json(200, { shareToken: 'tok123' }) : json(200, { shareToken: null });
+ }
+ return fallback(url, opts);
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ const user = await loadChapter();
+ await user.click(screen.getByRole('button', { name: /share/i }));
+ await user.click(screen.getByRole('button', { name: /enable sharing/i }));
+ expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
+ });
+
+ test('a ?share= link shows the read-only view without signing in', async () => {
+ window.history.pushState({}, '', '/?share=abc');
+ const fallback = buildFetchMock();
+ vi.stubGlobal('fetch', vi.fn((url, opts) => (url === '/api/share/abc'
+ ? json(200, { id: 's1', title: 'Shared Titus', translation: 'BSB', chapters: [] })
+ : fallback(url, opts))));
+ try {
+ render();
+ expect(await screen.findByText(/read-only shared view/i)).toBeInTheDocument();
+ expect(screen.getByTitle('Shared study')).toBeInTheDocument();
+ } finally {
+ window.history.pushState({}, '', '/');
+ }
+ });
+});
diff --git a/src/components/AuthStatus.jsx b/src/components/AuthStatus.jsx
new file mode 100644
index 0000000..53ab03e
--- /dev/null
+++ b/src/components/AuthStatus.jsx
@@ -0,0 +1,35 @@
+// Signed-in email plus Admin / Settings / Log out buttons for page headers.
+import { useApp } from '../context/AppContext.js';
+
+export default function AuthStatus() {
+ const { authUser, setCurrentPage, logout } = useApp();
+ if (!authUser) return null;
+ return (
+
+ {authUser.email}
+ {authUser.isAdmin && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/ProjectHeader.jsx b/src/components/ProjectHeader.jsx
new file mode 100644
index 0000000..97a8cb4
--- /dev/null
+++ b/src/components/ProjectHeader.jsx
@@ -0,0 +1,191 @@
+// Header for an open project (Setup and Study pages): navigation, Claude
+// prompts, pronunciation guide, export menu, share link, and save/sync status.
+import { useState } from 'react';
+import { useApp } from '../context/AppContext.js';
+import { useShareLink } from '../hooks/useShareLink.js';
+import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
+import {
+ exportProjectDocx,
+ exportProjectHtml,
+ exportProjectMarkdown,
+ printProjectPdf,
+} from '../utils/projectExport.js';
+import AuthStatus from './AuthStatus.jsx';
+
+// loading: true while the Setup page is fetching a chapter.
+export default function ProjectHeader({ loading = false }) {
+ const {
+ authUser,
+ currentPage, setCurrentPage,
+ goHome,
+ project,
+ allChunks,
+ saveStatus, setSaveStatus,
+ syncStatus,
+ retrySync,
+ } = useApp();
+ const [exportMenuOpen, setExportMenuOpen] = useState(false);
+ const [sharePanelOpen, setSharePanelOpen] = useState(false);
+ const share = useShareLink(project?.id);
+
+ // Copies a prompt, then opens claude.ai so it can be pasted straight in.
+ const copyPrompt = (prompt) => {
+ navigator.clipboard.writeText(prompt).then(() => {
+ setSaveStatus('Copied! Opening Claude…');
+ window.setTimeout(() => setSaveStatus(''), 3000);
+ window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
+ });
+ };
+
+ return (
+
+ {currentPage !== 'home' && (
+
+ )}
+ {project && currentPage === 'study' && (
+
+ )}
+ {project && (
+
+
+
+
+
+
+ {exportMenuOpen && (
+
+
+
+
+
+
+ )}
+
+
+
+ {sharePanelOpen && (
+
+
Read-only share link
+
+ Anyone with this link can view (not edit) this study — no account needed. Good for a co-teacher
+ or a listener who wants to follow along.
+
+ {share.shareToken ? (
+
+
+ e.target.select()}
+ className="w-full flex-1 truncate rounded-lg border border-slate-300 bg-slate-50 px-2 py-1.5 text-xs text-slate-600"
+ />
+
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ )}
+
+ {loading ? (
+
Loading…
+ ) : saveStatus ? (
+
{saveStatus}
+ ) : (
+
+ )}
+ {syncStatus === 'syncing' &&
Syncing…
}
+ {syncStatus === 'synced' &&
Synced ✓
}
+ {syncStatus === 'error' && (
+
+ ⚠ Sync failed
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/hooks/useShareLink.js b/src/hooks/useShareLink.js
new file mode 100644
index 0000000..6e83261
--- /dev/null
+++ b/src/hooks/useShareLink.js
@@ -0,0 +1,48 @@
+// Read-only share link for the open project: status, enable, revoke, copy.
+import { useEffect, useState } from 'react';
+import { disableSharing, enableSharing, getShareStatus } from '../syncService.js';
+
+export function useShareLink(projectId) {
+ const [shareToken, setShareToken] = useState(null);
+ const [shareBusy, setShareBusy] = useState(false);
+ const [shareCopied, setShareCopied] = useState(false);
+
+ useEffect(() => {
+ setShareToken(null);
+ if (!projectId) return;
+ let active = true;
+ getShareStatus(projectId).then((result) => {
+ if (active && result.ok) setShareToken(result.data.shareToken);
+ });
+ return () => { active = false; };
+ }, [projectId]);
+
+ const shareUrl = shareToken
+ ? `${window.location.origin}${window.location.pathname}?share=${shareToken}`
+ : '';
+
+ const enable = async () => {
+ setShareBusy(true);
+ const result = await enableSharing(projectId);
+ setShareBusy(false);
+ if (result.ok) setShareToken(result.data.shareToken);
+ };
+
+ const revoke = async () => {
+ if (!window.confirm('Revoke this share link? Anyone using it will lose access immediately.')) return;
+ setShareBusy(true);
+ const result = await disableSharing(projectId);
+ setShareBusy(false);
+ if (result.ok) setShareToken(null);
+ };
+
+ const copy = () => {
+ if (!shareUrl) return;
+ navigator.clipboard.writeText(shareUrl).then(() => {
+ setShareCopied(true);
+ window.setTimeout(() => setShareCopied(false), 2000);
+ });
+ };
+
+ return { shareToken, shareUrl, shareBusy, shareCopied, enable, revoke, copy };
+}
diff --git a/src/hooks/useSharedView.js b/src/hooks/useSharedView.js
new file mode 100644
index 0000000..033f153
--- /dev/null
+++ b/src/hooks/useSharedView.js
@@ -0,0 +1,20 @@
+// A ?share=TOKEN URL shows a read-only view of someone else's project. No
+// login is needed, so this runs independently of auth.
+import { useEffect, useState } from 'react';
+import { getSharedProject } from '../syncService.js';
+
+export function useSharedView() {
+ const [token] = useState(() => new URLSearchParams(window.location.search).get('share'));
+ const [project, setProject] = useState(null);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ if (!token) return;
+ getSharedProject(token).then((result) => {
+ if (result.ok) setProject(result.data);
+ else setError(result.error ?? 'This share link is invalid or has been revoked.');
+ });
+ }, [token]);
+
+ return { token, project, error };
+}
diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx
index 2dde6a8..597e81a 100644
--- a/src/pages/AdminPage.jsx
+++ b/src/pages/AdminPage.jsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
+import AuthStatus from '../components/AuthStatus.jsx';
import { buildExportHtml } from '../utils/bibleUtils.jsx';
import {
adminListUsers,
@@ -11,7 +12,7 @@ import {
} from '../syncService.js';
export default function AdminPage() {
- const { authUser, authStatus, goHome } = useApp();
+ const { authUser, goHome } = useApp();
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects'
const [adminUsers, setAdminUsers] = useState([]);
const [adminProjects, setAdminProjects] = useState([]);
@@ -82,7 +83,7 @@ export default function AdminPage() {
>
← Back
- {authStatus}
+
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
index 8af607e..66763cb 100644
--- a/src/pages/HomePage.jsx
+++ b/src/pages/HomePage.jsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
+import AuthStatus from '../components/AuthStatus.jsx';
import {
bookOptions,
CHAPTER_COUNTS,
@@ -11,7 +12,6 @@ import { loadProjectById } from '../utils/projectStorage.js';
export default function HomePage() {
const {
- authStatus,
autoRestoredCount,
staleLocalProjects,
projectIndex,
@@ -109,7 +109,7 @@ export default function HomePage() {
>
+ New Project
- {authStatus}
+
diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx
index 8aa81da..45a24c1 100644
--- a/src/pages/ImportPage.jsx
+++ b/src/pages/ImportPage.jsx
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
+import AuthStatus from '../components/AuthStatus.jsx';
import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js';
import { bookOptions, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { parseEpisodeListDocx, parseEpisodePassage } from '../utils/importParsers.js';
export default function ImportPage() {
- const { authStatus, setCurrentPage, setProject, setActiveChapterIndex } = useApp();
+ const { setCurrentPage, setProject, setActiveChapterIndex } = useApp();
const availableTranslations = useAvailableTranslations();
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
const [importTranslation, setImportTranslation] = useState('BSB');
@@ -165,7 +166,7 @@ export default function ImportPage() {
>
← Back
- {authStatus}
+
diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx
index b14f402..7c96ff3 100644
--- a/src/pages/ReaderPage.jsx
+++ b/src/pages/ReaderPage.jsx
@@ -1,5 +1,7 @@
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
import { useApp } from '../context/AppContext.js';
+import { formatCrossRef, speakOriginalWord } from '../utils/wordStudy.js';
+import AuthStatus from '../components/AuthStatus.jsx';
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
import DrawCanvas from './DrawCanvas.jsx';
@@ -7,7 +9,6 @@ const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every ren
export default function ReaderPage() {
const {
- authStatus,
goHome,
readerBookAbbrev,
readerChapter, setReaderChapter,
@@ -41,9 +42,7 @@ export default function ReaderPage() {
handlePlayReaderAudio,
handleToggleReaderAudioPause,
handleStopBookAudio,
- speakOriginalWord,
copyVerse,
- formatCrossRef,
setReaderCrossRefs,
readerInkByPage,
updateReaderPageInk,
@@ -270,7 +269,7 @@ export default function ReaderPage() {
>
← Back to Studies
- {authStatus}
+
diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx
index ea73ef0..0f5e8f2 100644
--- a/src/pages/SettingsPage.jsx
+++ b/src/pages/SettingsPage.jsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
+import AuthStatus from '../components/AuthStatus.jsx';
import {
startMfaSetup,
confirmMfaSetup,
@@ -11,7 +12,6 @@ import {
export default function SettingsPage() {
const {
authUser, setAuthUser,
- authStatus,
goHome,
} = useApp();
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
@@ -123,7 +123,7 @@ export default function SettingsPage() {
>
← Back
- {authStatus}
+
diff --git a/src/pages/SetupPage.jsx b/src/pages/SetupPage.jsx
index a320f44..32776c0 100644
--- a/src/pages/SetupPage.jsx
+++ b/src/pages/SetupPage.jsx
@@ -1,4 +1,5 @@
import { useApp } from '../context/AppContext.js';
+import ProjectHeader from '../components/ProjectHeader.jsx';
import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js';
import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx';
@@ -81,7 +82,6 @@ function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter,
export default function SetupPage() {
const {
- headerButtons,
project,
setup,
titleEdited, setTitleEdited,
@@ -125,7 +125,7 @@ export default function SetupPage() {
{project ? project.title : 'New Study'}
- {headerButtons}
+
diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx
index a448d41..ca5510c 100644
--- a/src/pages/StudyPage.jsx
+++ b/src/pages/StudyPage.jsx
@@ -1,11 +1,12 @@
import { createPortal } from 'react-dom';
import DOMPurify from 'dompurify';
import { useApp } from '../context/AppContext.js';
+import { buildGreekDefinitionHtml, externalLookupLinks, speakOriginalWord } from '../utils/wordStudy.js';
+import ProjectHeader from '../components/ProjectHeader.jsx';
import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../utils/bibleUtils.jsx';
export default function StudyPage() {
const {
- headerButtons,
project,
allChunks,
selectedChunk,
@@ -50,9 +51,6 @@ export default function StudyPage() {
suggestGreekWordsForChunk,
suggestHebrewWordsForChunk,
confirmSuggestWords,
- speakOriginalWord,
- externalLookupLinks,
- buildGreekDefinitionHtml,
loadVerseText,
importFinalScriptDocx,
formatChunkReference: _ignored,
@@ -71,7 +69,7 @@ export default function StudyPage() {
)}
- {headerButtons}
+
diff --git a/src/utils/projectExport.js b/src/utils/projectExport.js
new file mode 100644
index 0000000..35f422a
--- /dev/null
+++ b/src/utils/projectExport.js
@@ -0,0 +1,49 @@
+// File exports of a study project: HTML, Markdown, DOCX, and print-to-PDF.
+import { buildExportHtml } from './bibleUtils.jsx';
+import { buildMarkdownExport } from './exportBuilders.js';
+import { buildChapterSummary } from './projectStorage.js';
+
+function downloadBlob(blob, filename) {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+}
+
+const fileBase = (project) => `${project.title.replace(/\s+/g, '-')}-study`;
+
+export function exportProjectHtml(project) {
+ const html = buildExportHtml(project);
+ downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), `${fileBase(project)}.html`);
+}
+
+export function exportProjectMarkdown(project) {
+ const markdown = buildMarkdownExport(project);
+ downloadBlob(new Blob([markdown], { type: 'text/markdown;charset=utf-8' }), `${fileBase(project)}.md`);
+}
+
+export async function exportProjectDocx(project) {
+ // docx is large, so it's only downloaded when someone actually exports.
+ const { buildStudyDocxBlob } = await import('./docxExport.js');
+ const blob = await buildStudyDocxBlob(project, buildChapterSummary(project));
+ downloadBlob(blob, `${fileBase(project)}.docx`);
+}
+
+export function printProjectPdf(project) {
+ const html = buildExportHtml(project);
+ const printWindow = window.open('', '_blank');
+ if (!printWindow) {
+ alert('Please allow pop-ups for this site to print or save as PDF.');
+ return;
+ }
+ printWindow.document.write(html);
+ printWindow.document.close();
+ printWindow.onload = () => {
+ printWindow.focus();
+ printWindow.print();
+ };
+}