Files
study-bible-project/src/components/study/PageShell.jsx
T
nmemmertandClaude Sonnet 5.5 d13f55faf7 Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:08:08 -04:00

108 lines
6.2 KiB
React

// Themed frame for pages outside a study project (Home, Import, Settings, Admin):
// a calm app bar (brand, three destinations, account menu) and a page heading row.
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
// `short` is what a phone shows; the full label is still the button's accessible name.
const NAV = [
{ id: 'studies', label: 'Studies', short: 'Studies' },
{ id: 'reader', label: 'Read Bible', short: 'Bible' },
{ id: 'plans', label: 'Reading Plans', short: 'Plans' },
];
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
function AccountMenu() {
const { authUser, readerTheme, setReaderTheme, setCurrentPage, logout } = useApp();
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return undefined;
const onDown = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
}, [open]);
const go = (page) => { setOpen(false); setCurrentPage(page); };
const item = 'block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5';
return (
<div ref={ref} className="relative">
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-xs font-medium uppercase hover:bg-black/5">
{authUser?.email?.[0] ?? '·'}
</button>
{open && (
<div role="menu" className="absolute right-0 top-full z-40 mt-2 w-60 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-2 shadow-xl">
{authUser && <p className="truncate px-3 pb-1 pt-2 text-xs text-[var(--r-sub)]">{authUser.email}</p>}
<div className="px-3 pb-2 pt-1">
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Theme</p>
<div className="flex overflow-hidden rounded-lg border border-[var(--r-line)]">
{Object.entries(READER_THEMES).map(([id, t]) => (
<button key={id} type="button" onClick={() => setReaderTheme(id)} aria-pressed={readerTheme === id}
className={`flex-1 px-2 py-1.5 text-xs ${readerTheme === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}</button>
))}
</div>
</div>
{authUser && (
<div className="border-t border-[var(--r-line)] pt-1">
<button type="button" role="menuitem" onClick={() => go('settings')} className={item}>Settings</button>
{authUser.isAdmin && <button type="button" role="menuitem" onClick={() => go('admin')} className={item}>Admin</button>}
<button type="button" role="menuitem" onClick={() => { setOpen(false); logout(); }} className={item}>Log out</button>
</div>
)}
</div>
)}
</div>
);
}
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPalette } = useApp();
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
return (
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<a href="#content" className="skip-link">Skip to content</a>
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}>
<button type="button" onClick={goHome} className="flex shrink-0 items-center gap-2" aria-label="Bible Study home">
<img src="/logo.svg" alt="" className="h-6 w-6" />
<span className="hidden text-sm font-medium sm:inline">Bible Study</span>
</button>
<nav aria-label="Main" className="flex items-center gap-0.5">
{NAV.map((n) => (
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-sm sm:px-3 ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
<span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span>
</button>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
{openPalette && (
<button type="button" onClick={openPalette} aria-label="Search and go to"
className="flex items-center gap-2 rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1 text-xs text-[var(--r-sub)] hover:bg-black/5">
<SearchIcon /><span className="hidden sm:inline">Search</span><kbd className="hidden rounded border border-[var(--r-line)] px-1 text-[10px] md:inline">⌘K</kbd>
</button>
)}
<AccountMenu />
</div>
</div>
</header>
<div id="content" tabIndex={-1} className="focus:outline-none">
<div className={`mx-auto flex ${maxWidth} flex-wrap items-center gap-3 px-4 pt-6`}>
{onBack && (
<button type="button" onClick={onBack} aria-label="Back" className="-ml-2 flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
)}
<h1 className="text-2xl font-medium tracking-tight">{title}</h1>
{actions && <div className="ml-auto flex flex-wrap items-center gap-2">{actions}</div>}
</div>
{children}
</div>
</div>
);
}