Files
study-bible-project/src/components/study/PageShell.jsx
T
nmemmertandClaude Sonnet 5.5 512c6b6889
Build and Push Docker Image / build-and-push (push) Successful in 5m7s
Put every preference in Settings; same header on every page
- Settings > Preferences: typeface, line spacing, verse numbers, highlight colour names,
  My Studies grid/list and sort, verse-of-the-day Bible and picture, commentary sources, study templates
- Header uses one fixed width and reserves scrollbar space so the nav sits in the same place on every page

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

150 lines
9.0 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';
import { useIsPhone } from '../../hooks/useIsPhone.js';
// `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' },
{ id: 'prayer', label: 'Prayer', short: 'Prayer' },
];
const ICONS = {
studies: 'M4 5.5A1.5 1.5 0 0 1 5.5 4H18a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H6a2 2 0 0 1-2-2V5.5ZM8 4v15M11 8h5M11 11.5h5',
reader: 'M12 6.5C10.5 5 8 4.5 4.5 4.5V18c3.5 0 6 .5 7.5 2 1.5-1.5 4-2 7.5-2V4.5c-3.5 0-6 .5-7.5 2ZM12 6.5V20',
plans: 'M5 6.5h14M5 6.5v12A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5v-12M5 6.5A1.5 1.5 0 0 1 6.5 5H8m11 1.5A1.5 1.5 0 0 0 17.5 5H16M8 3.5v3m8-3v3M9 12.5l2 2 4-4',
prayer: 'M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10Z',
};
const TabIcon = ({ id }) => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={ICONS[id]} /></svg>
);
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
function AccountMenu({ tab = false, active = false }) {
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">
{tab ? (
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
className={`flex min-h-[52px] w-full flex-col items-center justify-center gap-0.5 text-[11px] ${open || active ? 'text-[var(--r-text)]' : 'text-[var(--r-sub)]'}`}>
<span className="flex h-[22px] w-[22px] items-center justify-center rounded-full border border-current text-[11px] font-medium uppercase">{authUser?.email?.[0] ?? '·'}</span>
<span aria-hidden="true">Account</span>
</button>
) : (
<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 z-40 w-60 ${tab ? 'bottom-full right-2 mb-2' : 'right-0 top-full mt-2'} 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, openPrayer, openPalette } = useApp();
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans, prayer: openPrayer };
const phone = useIsPhone();
// On a phone the destinations sit in a bar at the bottom, where a thumb reaches; toasts lift above it.
useEffect(() => {
if (!phone) return undefined;
document.documentElement.style.setProperty('--bottom-nav', '56px');
return () => document.documentElement.style.removeProperty('--bottom-nav');
}, [phone]);
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 max-w-6xl items-center gap-2 px-3 sm:gap-4 sm:px-4">
<button type="button" onClick={goHome} className="-m-2 flex shrink-0 items-center gap-2 p-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>
{!phone && <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" data-tip="Search studies, notes and passages (⌘K)"
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>
)}
{!phone && <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>
{phone && (
<>
<div aria-hidden="true" className="h-[calc(5rem+env(safe-area-inset-bottom,0px))]" />
<nav aria-label="Main" className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)]">
{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={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 text-[11px] ${active === n.id ? 'text-[var(--r-text)]' : 'text-[var(--r-sub)]'}`}>
<TabIcon id={n.id} />
<span aria-hidden="true" className={active === n.id ? 'font-medium' : ''}>{n.short}</span>
</button>
))}
<AccountMenu tab active={['settings', 'admin'].includes(active)} />
</nav>
</>
)}
</div>
);
}