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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Opus 5.5 committed 2026-09-22 14:56:31 -04:00
1 parent 365fb1b567
commit 4b14b3fbe6
14 files changed
+425 -393

No files matched your search

+18 -373
View File
@@ -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 && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="hidden sm:inline">{authUser.email}</span>
{authUser.isAdmin && (
<button
type="button"
onClick={() => setCurrentPage('admin')}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
🛡 Admin
</button>
)}
<button
type="button"
onClick={() => setCurrentPage('settings')}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
⚙ Settings
</button>
<button
type="button"
onClick={logout}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
Log out
</button>
</div>
);
// ---------------------------------------------------------------------------
// Shared header
// ---------------------------------------------------------------------------
const headerButtons = (
<div className="flex flex-wrap items-center gap-3">
{currentPage !== 'home' && (
<button
type="button"
onClick={goHome}
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
>
← Home
</button>
)}
{project && currentPage === 'study' && (
<button
type="button"
onClick={() => setCurrentPage('setup')}
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
>
← Back to Chunks
</button>
)}
{project && (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={copyForClaude}
disabled={allChunks.length === 0}
className="rounded-md bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-violet-400 disabled:cursor-not-allowed disabled:bg-slate-500"
title="Copies your study notes as a Claude prompt, then opens claude.ai so you can paste and go"
>
<span className="hidden sm:inline">Study Guide → </span>Claude ↗
</button>
<button
type="button"
onClick={copyForPodcast}
disabled={allChunks.length === 0}
title="Copies a podcast script prompt for Claude, then opens claude.ai — 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"
>
🎙<span className="hidden sm:inline"> Podcast → Claude ↗</span>
</button>
<button
type="button"
onClick={() => setCurrentPage('pronunciation')}
disabled={allChunks.length === 0}
title="Open the pronunciation guide — all Greek & Hebrew words with transliterations"
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:bg-slate-500"
>
🗣<span className="hidden sm:inline"> Pronunciation Guide</span>
</button>
<div className="relative">
<button
type="button"
onClick={() => setExportMenuOpen((v) => !v)}
className="flex items-center gap-1.5 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400"
>
⬇ Export
</button>
{exportMenuOpen && (
<div className="absolute left-0 top-full z-20 mt-2 w-56 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
<button type="button" onClick={() => { exportChapter(); setExportMenuOpen(false); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export HTML
</button>
<button type="button" onClick={() => { exportChapterDocx(); setExportMenuOpen(false); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export DOCX
</button>
<button type="button" onClick={() => { exportChapterMarkdown(); setExportMenuOpen(false); }}
title="Handy for Obsidian, Notion, or any Markdown-based notes app"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export Markdown
</button>
<button type="button" onClick={() => { printChapterPdf(); setExportMenuOpen(false); }}
title="Opens a print-friendly version in a new tab — choose 'Save as PDF' in the print dialog"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
🖨 Print / Save PDF
</button>
</div>
)}
</div>
<div className="relative">
<button
type="button"
onClick={() => setSharePanelOpen((v) => !v)}
className={`rounded-md px-4 py-2 text-sm font-medium shadow-sm transition ${shareToken ? 'bg-teal-600 text-white hover:bg-teal-500' : 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}`}
>
🔗 {shareToken ? 'Shared' : 'Share'}
</button>
{sharePanelOpen && (
<div className="absolute right-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-lg">
<h3 className="text-sm font-semibold text-slate-900">Read-only share link</h3>
<p className="mt-1 text-xs text-slate-500">
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.
</p>
{shareToken ? (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<input
readOnly
value={`${window.location.origin}${window.location.pathname}?share=${shareToken}`}
onFocus={(e) => 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"
/>
<button type="button" onClick={handleCopyShareLink}
className="shrink-0 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-400">
{shareCopied ? 'Copied!' : 'Copy'}
</button>
</div>
<button type="button" onClick={handleDisableSharing} disabled={shareBusy}
className="w-full rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50">
{shareBusy ? 'Revoking…' : 'Revoke link'}
</button>
</div>
) : (
<button type="button" onClick={handleEnableSharing} disabled={shareBusy}
className="mt-3 w-full rounded-lg bg-teal-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50">
{shareBusy ? 'Enabling…' : 'Enable sharing'}
</button>
)}
</div>
)}
</div>
</div>
)}
<div className="text-right text-sm text-slate-300 space-y-0.5">
{loadingChapter ? (
<span>Loading…</span>
) : saveStatus ? (
<span className="text-emerald-300">{saveStatus}</span>
) : (
<span>&nbsp;</span>
)}
{syncStatus === 'syncing' && <div className="text-xs text-slate-400">Syncing…</div>}
{syncStatus === 'synced' && <div className="text-xs text-emerald-400">Synced ✓</div>}
{syncStatus === 'error' && (
<div className="flex items-center gap-2">
<span className="text-xs text-rose-400">⚠ Sync failed</span>
<button
type="button"
onClick={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);
}}
className="text-xs text-slate-300 underline hover:text-white"
>
Retry
</button>
</div>
)}
</div>
{authStatus}
</div>
);
// 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) {
+46 -1
View File
@@ -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(<App />);
expect(await screen.findByText(/read-only shared view/i)).toBeInTheDocument();
expect(screen.getByTitle('Shared study')).toBeInTheDocument();
} finally {
window.history.pushState({}, '', '/');
}
});
});
+35
View File
@@ -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 (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="hidden sm:inline">{authUser.email}</span>
{authUser.isAdmin && (
<button
type="button"
onClick={() => setCurrentPage('admin')}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
🛡 Admin
</button>
)}
<button
type="button"
onClick={() => setCurrentPage('settings')}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
⚙ Settings
</button>
<button
type="button"
onClick={logout}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
Log out
</button>
</div>
);
}
+191
View File
@@ -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 (
<div className="flex flex-wrap items-center gap-3">
{currentPage !== 'home' && (
<button
type="button"
onClick={goHome}
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
>
← Home
</button>
)}
{project && currentPage === 'study' && (
<button
type="button"
onClick={() => setCurrentPage('setup')}
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
>
← Back to Chunks
</button>
)}
{project && (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => copyPrompt(buildClaudePrompt(project))}
disabled={allChunks.length === 0}
className="rounded-md bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-violet-400 disabled:cursor-not-allowed disabled:bg-slate-500"
title="Copies your study notes as a Claude prompt, then opens claude.ai so you can paste and go"
>
<span className="hidden sm:inline">Study Guide → </span>Claude ↗
</button>
<button
type="button"
onClick={() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName))}
disabled={allChunks.length === 0}
title="Copies a podcast script prompt for Claude, then opens claude.ai — 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"
>
🎙<span className="hidden sm:inline"> Podcast → Claude ↗</span>
</button>
<button
type="button"
onClick={() => setCurrentPage('pronunciation')}
disabled={allChunks.length === 0}
title="Open the pronunciation guide — all Greek & Hebrew words with transliterations"
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:bg-slate-500"
>
🗣<span className="hidden sm:inline"> Pronunciation Guide</span>
</button>
<div className="relative">
<button
type="button"
onClick={() => setExportMenuOpen((v) => !v)}
className="flex items-center gap-1.5 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400"
>
⬇ Export
</button>
{exportMenuOpen && (
<div className="absolute left-0 top-full z-20 mt-2 w-56 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
<button type="button" onClick={() => { exportProjectHtml(project); setExportMenuOpen(false); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export HTML
</button>
<button type="button" onClick={() => { exportProjectDocx(project); setExportMenuOpen(false); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export DOCX
</button>
<button type="button" onClick={() => { exportProjectMarkdown(project); setExportMenuOpen(false); }}
title="Handy for Obsidian, Notion, or any Markdown-based notes app"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export Markdown
</button>
<button type="button" onClick={() => { printProjectPdf(project); setExportMenuOpen(false); }}
title="Opens a print-friendly version in a new tab — choose 'Save as PDF' in the print dialog"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
🖨 Print / Save PDF
</button>
</div>
)}
</div>
<div className="relative">
<button
type="button"
onClick={() => setSharePanelOpen((v) => !v)}
className={`rounded-md px-4 py-2 text-sm font-medium shadow-sm transition ${share.shareToken ? 'bg-teal-600 text-white hover:bg-teal-500' : 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}`}
>
🔗 {share.shareToken ? 'Shared' : 'Share'}
</button>
{sharePanelOpen && (
<div className="absolute right-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-lg">
<h3 className="text-sm font-semibold text-slate-900">Read-only share link</h3>
<p className="mt-1 text-xs text-slate-500">
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.
</p>
{share.shareToken ? (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<input
readOnly
value={share.shareUrl}
onFocus={(e) => 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"
/>
<button type="button" onClick={share.copy}
className="shrink-0 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-400">
{share.shareCopied ? 'Copied!' : 'Copy'}
</button>
</div>
<button type="button" onClick={share.revoke} disabled={share.shareBusy}
className="w-full rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50">
{share.shareBusy ? 'Revoking…' : 'Revoke link'}
</button>
</div>
) : (
<button type="button" onClick={share.enable} disabled={share.shareBusy}
className="mt-3 w-full rounded-lg bg-teal-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50">
{share.shareBusy ? 'Enabling…' : 'Enable sharing'}
</button>
)}
</div>
)}
</div>
</div>
)}
<div className="text-right text-sm text-slate-300 space-y-0.5">
{loading ? (
<span>Loading…</span>
) : saveStatus ? (
<span className="text-emerald-300">{saveStatus}</span>
) : (
<span>&nbsp;</span>
)}
{syncStatus === 'syncing' && <div className="text-xs text-slate-400">Syncing…</div>}
{syncStatus === 'synced' && <div className="text-xs text-emerald-400">Synced ✓</div>}
{syncStatus === 'error' && (
<div className="flex items-center gap-2">
<span className="text-xs text-rose-400">⚠ Sync failed</span>
<button
type="button"
onClick={retrySync}
className="text-xs text-slate-300 underline hover:text-white"
>
Retry
</button>
</div>
)}
</div>
<AuthStatus />
</div>
);
}
+48
View File
@@ -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 };
}
+20
View File
@@ -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 };
}
+3 -2
View File
@@ -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
</button>
{authStatus}
<AuthStatus />
</div>
</div>
</header>
+2 -2
View File
@@ -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
</button>
{authStatus}
<AuthStatus />
</div>
</div>
</header>
+3 -2
View File
@@ -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
</button>
{authStatus}
<AuthStatus />
</div>
</div>
</header>
+3 -4
View File
@@ -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
</button>
{authStatus}
<AuthStatus />
</div>
</div>
</header>
+2 -2
View File
@@ -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
</button>
{authStatus}
<AuthStatus />
</div>
</div>
</header>
+2 -2
View File
@@ -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'}
</h1>
</div>
{headerButtons}
<ProjectHeader loading={loadingChapter} />
</div>
</header>
+3 -5
View File
@@ -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() {
</p>
)}
</div>
{headerButtons}
<ProjectHeader />
</div>
</header>
+49
View File
@@ -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();
};
}