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

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