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(); + }; +}