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:
1 parent
365fb1b567
commit
4b14b3fbe6
14 files changed
+425
-393
No files matched your search
+18
-373
@@ -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> </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) {
|
||||
|
||||
Reference in new issue
Block a user