Redesign project header and study navigation
Build and Push Docker Image / build-and-push (push) Failing after 20s
Build and Push Docker Image / build-and-push (push) Failing after 20s
- Header: navigation buttons grouped; Study Guide, Podcast, and Pronunciation Guide move into a Tools menu (disabled until a chunk exists), set apart from Export and Share by a divider. - Only one header dropdown is open at a time, and it closes on an outside click or Escape. - Study page: drop the duplicate Prev/Next and counter at the top; the fixed bottom bar is the single chunk navigation and now shows a readable "Chunk N of M". - Mobile empty-state hint points to Back to Chunks. - Tests: Tools menu, one-dropdown-at-a-time, Escape/outside click. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
33e3f2bdc6
commit
5287b471d0
3 files changed
+152
-111
No files matched your search
@@ -702,3 +702,35 @@ describe('Chunk builder', () => {
|
||||
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveClass('bg-slate-900');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header redesign: Tools menu + one-dropdown-at-a-time
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Header menus', () => {
|
||||
test('Tools is disabled until a chunk exists, then opens the Pronunciation Guide', async () => {
|
||||
const user = await loadChapter();
|
||||
expect(screen.getByRole('button', { name: /tools/i })).toBeDisabled();
|
||||
addChunk('Paul, a servant', 'Paul, a servant');
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /tools/i }));
|
||||
await user.click(screen.getByRole('button', { name: /pronunciation guide/i }));
|
||||
expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opening one dropdown closes the other; Escape and outside clicks close it', async () => {
|
||||
const user = await loadChapter();
|
||||
await user.click(screen.getByRole('button', { name: /export/i }));
|
||||
expect(screen.getByRole('button', { name: /export markdown/i })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /share/i }));
|
||||
expect(screen.queryByRole('button', { name: /export markdown/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/read-only share link/i)).toBeInTheDocument();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByText(/read-only share link/i)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /export/i }));
|
||||
await user.click(screen.getByText(/scripture & chunks/i));
|
||||
expect(screen.queryByRole('button', { name: /export markdown/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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 { useEffect, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useShareLink } from '../hooks/useShareLink.js';
|
||||
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
|
||||
@@ -24,8 +24,26 @@ export default function ProjectHeader({ loading = false }) {
|
||||
syncStatus,
|
||||
retrySync,
|
||||
} = useApp();
|
||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||
const [sharePanelOpen, setSharePanelOpen] = useState(false);
|
||||
// Only one dropdown open at a time: 'tools' | 'export' | 'share' | null
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const toggleMenu = (name) => setOpenMenu((current) => (current === name ? null : name));
|
||||
const closeMenu = () => setOpenMenu(null);
|
||||
const menusRef = useRef(null);
|
||||
|
||||
// Close the open dropdown on an outside click or Escape.
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const onPointerDown = (e) => {
|
||||
if (!menusRef.current?.contains(e.target)) setOpenMenu(null);
|
||||
};
|
||||
const onKeyDown = (e) => { if (e.key === 'Escape') setOpenMenu(null); };
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [openMenu]);
|
||||
const share = useShareLink(project?.id);
|
||||
|
||||
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
|
||||
@@ -40,76 +58,94 @@ export default function ProjectHeader({ loading = false }) {
|
||||
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>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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 ref={menusRef} className="flex flex-wrap items-center gap-3 border-white/10 sm:border-l sm:pl-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportMenuOpen((v) => !v)}
|
||||
onClick={() => toggleMenu('tools')}
|
||||
aria-expanded={openMenu === 'tools'}
|
||||
disabled={allChunks.length === 0}
|
||||
title={allChunks.length === 0 ? 'Add a chunk first to use these tools' : undefined}
|
||||
className="flex items-center gap-1.5 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 disabled:opacity-60 disabled:shadow-none"
|
||||
>
|
||||
🧰 Tools
|
||||
</button>
|
||||
{openMenu === 'tools' && allChunks.length > 0 && (
|
||||
<div className="absolute left-0 top-full z-20 mt-2 w-64 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { copyPrompt(buildClaudePrompt(project)); closeMenu(); }}
|
||||
title="Copies your study notes as a Claude prompt, then opens claude.ai so you can paste and go"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Study Guide → Claude ↗
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { copyPrompt(buildPodcastPrompt(project, authUser?.podcastName)); closeMenu(); }}
|
||||
title="Copies a podcast script prompt for Claude, then opens claude.ai — set your show name in Account Settings first"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
🎙 Podcast → Claude ↗
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCurrentPage('pronunciation'); closeMenu(); }}
|
||||
title="Open the pronunciation guide — all Greek & Hebrew words with transliterations"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
🗣 Pronunciation Guide
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMenu('export')}
|
||||
aria-expanded={openMenu === 'export'}
|
||||
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 && (
|
||||
{openMenu === 'export' && (
|
||||
<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); }}
|
||||
<button type="button" onClick={() => { exportProjectHtml(project); closeMenu(); }}
|
||||
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); }}
|
||||
<button type="button" onClick={() => { exportProjectDocx(project); closeMenu(); }}
|
||||
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); }}
|
||||
<button type="button" onClick={() => { exportProjectMarkdown(project); closeMenu(); }}
|
||||
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); }}
|
||||
<button type="button" onClick={() => { printProjectPdf(project); closeMenu(); }}
|
||||
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
|
||||
@@ -120,12 +156,13 @@ export default function ProjectHeader({ loading = false }) {
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharePanelOpen((v) => !v)}
|
||||
onClick={() => toggleMenu('share')}
|
||||
aria-expanded={openMenu === 'share'}
|
||||
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 && (
|
||||
{openMenu === 'share' && (
|
||||
<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">
|
||||
@@ -162,30 +199,30 @@ export default function ProjectHeader({ loading = false }) {
|
||||
</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> </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 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> </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>
|
||||
);
|
||||
}
|
||||
+4
-32
@@ -282,34 +282,6 @@ export default function StudyPage() {
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{selectedChunk && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToPreviousChunk}
|
||||
disabled={selectedChunkGlobalIndex <= 0}
|
||||
className="rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
|
||||
{`Chunk ${selectedChunkGlobalIndex + 1} of ${allChunks.length}`}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToNextChunk}
|
||||
disabled={selectedChunkGlobalIndex >= allChunks.length - 1}
|
||||
className="rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!selectedChunk && (
|
||||
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
|
||||
Choose a chunk to study.
|
||||
</div>
|
||||
)}
|
||||
<div className="max-sm:hidden flex divide-x divide-slate-200 overflow-hidden rounded-2xl border border-slate-300">
|
||||
{[
|
||||
{ id: 'stacked', label: '☰ Stacked' },
|
||||
@@ -997,7 +969,7 @@ export default function StudyPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-3xl border border-slate-200 bg-slate-50 p-8 text-center text-slate-500">
|
||||
<span className="sm:hidden">Use ‹ Prev / Next › above to pick a chunk.</span>
|
||||
<span className="sm:hidden">Add a chunk from ← Back to Chunks to start studying.</span>
|
||||
<span className="hidden sm:inline">Select a chunk from the left panel to edit its notes and Greek studies.</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -1005,7 +977,7 @@ export default function StudyPage() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Prev / Next — fixed bottom bar, visible while scrolling long chunks */}
|
||||
{/* Prev / Next — fixed bottom bar, stays visible while scrolling long chunks */}
|
||||
{selectedChunk && (
|
||||
<div className="fixed bottom-0 inset-x-0 z-20 border-t border-slate-200 bg-white/95 backdrop-blur-sm">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
|
||||
@@ -1017,8 +989,8 @@ export default function StudyPage() {
|
||||
>
|
||||
← Previous Chunk
|
||||
</button>
|
||||
<span className="text-xs text-slate-400">
|
||||
{selectedChunkGlobalIndex + 1} / {allChunks.length}
|
||||
<span aria-live="polite" className="text-sm font-medium text-slate-600">
|
||||
{`Chunk ${selectedChunkGlobalIndex + 1} of ${allChunks.length}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in new issue
Block a user