Rework app bar, notes layout, Settings/Admin/login restyle
Build and Push Docker Image / build-and-push (push) Failing after 20s

App bar: brand, Studies / Read Bible / Reading Plans, and one account menu
(theme, Settings, Admin, Log out). Study stage: notes get their own box above
the resources in the right column. Settings, Admin and the login page use the
reader themes; add a theme-aware danger colour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:27:02 -04:00
1 parent 31abee0695
commit ecb1c701fa
22 files changed
+344 -282

No files matched your search

+74 -5
View File
@@ -76,6 +76,12 @@ async function renderApp() {
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
} }
// The account menu (avatar button in the app bar) holds the email, Settings, Admin and Log out.
async function openAccountMenu(user = userEvent.setup()) {
await user.click(await screen.findByRole('button', { name: /account menu/i }));
return user;
}
async function loadChapter() { async function loadChapter() {
const user = userEvent.setup(); const user = userEvent.setup();
await renderApp(); await renderApp();
@@ -412,6 +418,7 @@ describe('Authentication', () => {
const u = userEvent.setup(); const u = userEvent.setup();
render(<App />); render(<App />);
await signInWithForm(u); await signInWithForm(u);
await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument(); expect(await screen.findByText(user.email)).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true); expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true);
await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true)); await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true));
@@ -424,6 +431,7 @@ describe('Authentication', () => {
await signInWithForm(u); await signInWithForm(u);
await u.type(await screen.findByPlaceholderText('123456'), '123456'); await u.type(await screen.findByPlaceholderText('123456'), '123456');
await u.click(screen.getByRole('button', { name: /verify/i })); await u.click(screen.getByRole('button', { name: /verify/i }));
await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument(); expect(await screen.findByText(user.email)).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true); expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true);
}); });
@@ -432,8 +440,9 @@ describe('Authentication', () => {
const fetchMock = stubApi({ session: user }); const fetchMock = stubApi({ session: user });
const u = userEvent.setup(); const u = userEvent.setup();
render(<App />); render(<App />);
await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument(); expect(await screen.findByText(user.email)).toBeInTheDocument();
await u.click(screen.getByRole('button', { name: /log out/i })); await u.click(screen.getByRole('menuitem', { name: /log out/i }));
expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true); expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true);
}); });
@@ -442,7 +451,8 @@ describe('Authentication', () => {
stubApi({ session: user }); stubApi({ session: user });
const u = userEvent.setup(); const u = userEvent.setup();
render(<App />); render(<App />);
await u.click(await screen.findByRole('button', { name: /settings/i })); await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument(); expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
}); });
}); });
@@ -479,7 +489,8 @@ describe('Admin page', () => {
stubAdminApi(admin); stubAdminApi(admin);
const u = userEvent.setup(); const u = userEvent.setup();
render(<App />); render(<App />);
await u.click(await screen.findByRole('button', { name: /admin/i })); await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
expect(await screen.findByText('student@example.com')).toBeInTheDocument(); expect(await screen.findByText('student@example.com')).toBeInTheDocument();
await u.click(screen.getByRole('button', { name: /projects \(1\)/i })); await u.click(screen.getByRole('button', { name: /projects \(1\)/i }));
expect(await screen.findByText('Romans Study')).toBeInTheDocument(); expect(await screen.findByText('Romans Study')).toBeInTheDocument();
@@ -490,7 +501,8 @@ describe('Admin page', () => {
window.confirm.mockReturnValue(true); window.confirm.mockReturnValue(true);
const u = userEvent.setup(); const u = userEvent.setup();
render(<App />); render(<App />);
await u.click(await screen.findByRole('button', { name: /admin/i })); await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
const row = (await screen.findByText('student@example.com')).closest('tr'); const row = (await screen.findByText('student@example.com')).closest('tr');
await u.click(within(row).getByRole('button', { name: /delete/i })); await u.click(within(row).getByRole('button', { name: /delete/i }));
await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument());
@@ -500,8 +512,10 @@ describe('Admin page', () => {
test('non-admins get no Admin button', async () => { test('non-admins get no Admin button', async () => {
stubAdminApi({ ...admin, isAdmin: false }); stubAdminApi({ ...admin, isAdmin: false });
render(<App />); render(<App />);
await openAccountMenu();
expect(await screen.findByText(admin.email)).toBeInTheDocument(); expect(await screen.findByText(admin.email)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /admin/i })).not.toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /settings/i })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: /admin/i })).not.toBeInTheDocument();
}); });
}); });
@@ -881,3 +895,58 @@ describe('Study workspace', () => {
expect(screen.getByText(/\d+ words · about/)).toBeInTheDocument(); expect(screen.getByText(/\d+ words · about/)).toBeInTheDocument();
}); });
}); });
// ---------------------------------------------------------------------------
// App bar and study layout
// ---------------------------------------------------------------------------
describe('App bar', () => {
test('shows the three destinations, and the account menu switches the theme', async () => {
const user = userEvent.setup();
await renderApp();
expect(screen.getByRole('button', { name: /^studies$/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('button', { name: /read bible/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reading plans/i })).toBeInTheDocument();
await openAccountMenu(user);
await user.click(screen.getByRole('button', { name: /^dark$/i }));
expect(localStorage.getItem('reader-theme')).toBe('dark');
// Local-only mode has no signed-in user, so there is no Settings/Log out to show
expect(screen.queryByRole('menuitem', { name: /log out/i })).not.toBeInTheDocument();
});
test('the account menu closes on Escape', async () => {
const user = userEvent.setup();
await renderApp();
await openAccountMenu(user);
expect(screen.getByRole('button', { name: /^paper$/i })).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('button', { name: /^paper$/i })).not.toBeInTheDocument();
});
test('the brand button and Studies return Home from another page', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /import session list/i }));
await screen.findByRole('heading', { name: /import session list/i });
await user.click(screen.getByRole('button', { name: /bible study home/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
});
describe('Study layout', () => {
test('notes sit in their own box above the resources, not under the passage', async () => {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
const notes = screen.getByRole('region', { name: 'Notes' });
const resources = screen.getByRole('region', { name: 'Resources' });
expect(within(notes).getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
expect(within(resources).getByRole('tab', { name: /commentary/i })).toBeInTheDocument();
// notes come first in the right column
expect(notes.compareDocumentPosition(resources) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// and the passage column no longer holds them
expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument();
});
});
-39
View File
@@ -1,39 +0,0 @@
// Signed-in email plus Admin / Settings / Log out buttons for page headers.
import { useApp } from '../context/AppContext.js';
// `themed` swaps the dark-header look for the reader themes' colours.
export default function AuthStatus({ themed = false }) {
const { authUser, setCurrentPage, logout } = useApp();
if (!authUser) return null;
const btn = themed
? 'rounded-lg px-2.5 py-1.5 text-xs text-[var(--r-sub)] transition hover:bg-black/5'
: 'rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15';
return (
<div className={`flex items-center gap-2 text-sm ${themed ? 'text-[var(--r-sub)]' : 'text-slate-300'}`}>
<span className="hidden sm:inline">{authUser.email}</span>
{authUser.isAdmin && (
<button
type="button"
onClick={() => setCurrentPage('admin')}
className={btn}
>
🛡 Admin
</button>
)}
<button
type="button"
onClick={() => setCurrentPage('settings')}
className={btn}
>
⚙ Settings
</button>
<button
type="button"
onClick={logout}
className={btn}
>
Log out
</button>
</div>
);
}
+4 -4
View File
@@ -44,7 +44,7 @@ function ActiveBody({ plan, progress, onClose, actions }) {
</div> </div>
<div className="mt-4 flex items-center justify-between text-xs"> <div className="mt-4 flex items-center justify-between text-xs">
<button type="button" onClick={() => { if (window.confirm('Restart this plan from day 1?')) { actions.restart(progress.id); onClose(); } }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button> <button type="button" onClick={() => { if (window.confirm('Restart this plan from day 1?')) { actions.restart(progress.id); onClose(); } }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
<button type="button" onClick={() => { if (window.confirm('Remove this plan?')) { actions.remove(progress.id); onClose(); } }} className="text-red-600 hover:underline">Remove</button> <button type="button" onClick={() => { if (window.confirm('Remove this plan?')) { actions.remove(progress.id); onClose(); } }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</div> </div>
</> </>
); );
@@ -91,10 +91,10 @@ function CatalogBody({ plan, active, completedCount, onClose, actions }) {
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[var(--r-line)] pt-3 text-xs"> <div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[var(--r-line)] pt-3 text-xs">
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button> <button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
<button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button> <button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button>
{plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-red-600">Remove cover</button>} {plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover</button>}
<button type="button" onClick={() => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) { actions.delete(plan.id); onClose(); } }} className="ml-auto text-red-600 hover:underline">Delete</button> <button type="button" onClick={() => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) { actions.delete(plan.id); onClose(); } }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" /> <input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
{imageError && <p className="w-full text-red-600">{imageError}</p>} {imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{imageError}</p>}
</div> </div>
)} )}
</> </>
+1 -1
View File
@@ -12,7 +12,7 @@ export default function BookmarksSheet({ open, onClose, entries, onJump, onRemov
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm"> <button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse} {e.bookName} {e.chapter}:{e.verse}
</button> </button>
<button type="button" onClick={() => onRemove(e.key)} aria-label="Remove bookmark" className="rounded p-1 text-[var(--r-sub)] hover:text-red-600"><CloseIcon /></button> <button type="button" onClick={() => onRemove(e.key)} aria-label="Remove bookmark" className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div> </div>
))} ))}
</Sheet> </Sheet>
+1 -1
View File
@@ -48,7 +48,7 @@ export default function ReaderText({
</header> </header>
{loading && <p className="text-center font-sans text-sm text-[var(--r-sub)]">Loading…</p>} {loading && <p className="text-center font-sans text-sm text-[var(--r-sub)]">Loading…</p>}
{error && <p className="text-center font-sans text-sm text-red-600">{error}</p>} {error && <p className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{!loading && !error && ( {!loading && !error && (
<div <div
+2 -2
View File
@@ -68,7 +68,7 @@ export default function SearchSheet({
{scope === 'bible' && isLocal && ( {scope === 'bible' && isLocal && (
<> <>
{indexStatus === 'loading' && note('Loading the full Bible for search. This happens once per visit (about 7 MB).')} {indexStatus === 'loading' && note('Loading the full Bible for search. This happens once per visit (about 7 MB).')}
{indexStatus === 'error' && <p className="text-sm text-red-600">Couldn’t load the full Bible. <button type="button" onClick={onLoadIndex} className="underline">Try again</button></p>} {indexStatus === 'error' && <p className="text-sm text-[var(--r-danger,#b42318)]">Couldn’t load the full Bible. <button type="button" onClick={onLoadIndex} className="underline">Try again</button></p>}
{indexStatus === 'ready' && (!deferredBibleQuery {indexStatus === 'ready' && (!deferredBibleQuery
? note('Type a word to search all 66 books.') ? note('Type a word to search all 66 books.')
: bibleMatches.length === 0 : bibleMatches.length === 0
@@ -87,7 +87,7 @@ export default function SearchSheet({
{scope === 'bible' && !isLocal && ( {scope === 'bible' && !isLocal && (
<> <>
{remote.tooShort && note('Type at least two letters. Use quotes for an exact phrase, like “good shepherd”.')} {remote.tooShort && note('Type at least two letters. Use quotes for an exact phrase, like “good shepherd”.')}
{!remote.tooShort && remote.error && <p className="text-sm text-red-600">{remote.error}</p>} {!remote.tooShort && remote.error && <p className="text-sm text-[var(--r-danger,#b42318)]">{remote.error}</p>}
{!remote.tooShort && !remote.error && remote.loading && remote.results.length === 0 && note('Searching…')} {!remote.tooShort && !remote.error && remote.loading && remote.results.length === 0 && note('Searching…')}
{!remote.tooShort && !remote.error && !remote.loading && remote.total === 0 && remote.page > 0 && note(`No verses match “${query}”.`)} {!remote.tooShort && !remote.error && !remote.loading && remote.total === 0 && remote.page > 0 && note(`No verses match “${query}”.`)}
{remote.results.length > 0 && ( {remote.results.length > 0 && (
+3 -3
View File
@@ -3,15 +3,15 @@
export const READER_THEMES = { export const READER_THEMES = {
paper: { paper: {
label: 'Paper', label: 'Paper',
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#7d7466', '--r-accent': '#b4532a', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff' }, vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#7d7466', '--r-accent': '#b4532a', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
}, },
sepia: { sepia: {
label: 'Sepia', label: 'Sepia',
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#7d6b48', '--r-accent': '#9a4a1e', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff' }, vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#7d6b48', '--r-accent': '#9a4a1e', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
}, },
dark: { dark: {
label: 'Dark', label: 'Dark',
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#9a9385', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19' }, vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#9a9385', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
}, },
}; };
+1 -1
View File
@@ -13,7 +13,7 @@ function RefChip({ label, onRemove, loadVerseText }) {
<div className="rounded-xl border border-[var(--r-line)]"> <div className="rounded-xl border border-[var(--r-line)]">
<div className="flex items-center justify-between gap-2 px-3 py-1.5"> <div className="flex items-center justify-between gap-2 px-3 py-1.5">
<button type="button" onClick={toggle} aria-expanded={open} className="flex-1 text-left text-sm">{label}</button> <button type="button" onClick={toggle} aria-expanded={open} className="flex-1 text-left text-sm">{label}</button>
<button type="button" onClick={onRemove} aria-label={`Remove ${label}`} className="text-[var(--r-sub)] hover:text-red-600">×</button> <button type="button" onClick={onRemove} aria-label={`Remove ${label}`} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
</div> </div>
{open && <p className="border-t border-[var(--r-line)] px-3 py-2 font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{text ?? 'Loading…'}</p>} {open && <p className="border-t border-[var(--r-line)] px-3 py-2 font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{text ?? 'Loading…'}</p>}
</div> </div>
+2 -2
View File
@@ -50,7 +50,7 @@ export default function NotesPane({ chunk, updateChunk, allTags }) {
</button> </button>
{isOpen && ( {isOpen && (
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<textarea aria-label={label} value={chunk[field] ?? ''} rows={8} placeholder={placeholder} <textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={placeholder}
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} /> onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
</div> </div>
)} )}
@@ -77,7 +77,7 @@ export default function NotesPane({ chunk, updateChunk, allTags }) {
{tags.map((tag) => ( {tags.map((tag) => (
<span key={tag} className="inline-flex items-center gap-1 rounded-full bg-[var(--r-bar)] px-2.5 py-1 text-xs"> <span key={tag} className="inline-flex items-center gap-1 rounded-full bg-[var(--r-bar)] px-2.5 py-1 text-xs">
{tag} {tag}
<button type="button" aria-label={`Remove tag ${tag}`} onClick={() => updateChunk(chunk.id, { tags: tags.filter((t) => t !== tag) })} className="text-[var(--r-sub)] hover:text-red-600">×</button> <button type="button" aria-label={`Remove tag ${tag}`} onClick={() => updateChunk(chunk.id, { tags: tags.filter((t) => t !== tag) })} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
</span> </span>
))} ))}
<input value={tagInput} onChange={(e) => setTagInput(e.target.value)} list="study-tag-suggestions" <input value={tagInput} onChange={(e) => setTagInput(e.target.value)} list="study-tag-suggestions"
+79 -17
View File
@@ -1,29 +1,91 @@
// Themed page frame for pages that aren't part of a study project (Home, Import): // Themed frame for pages outside a study project (Home, Import, Settings, Admin):
// slim top bar with a title, optional back button, and actions on the right. // 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 { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js'; import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx'; import { ChevronLeft } from '../reader/icons.jsx';
import AuthStatus from '../AuthStatus.jsx';
const THEME_ORDER = ['paper', 'sepia', 'dark']; const NAV = [
{ id: 'studies', label: 'Studies' },
{ id: 'reader', label: 'Read Bible' },
{ id: 'plans', label: 'Reading Plans' },
];
export default function PageShell({ title, onBack, actions, maxWidth = 'max-w-6xl', children }) { // Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
const { readerTheme, setReaderTheme } = useApp(); function AccountMenu() {
const cycle = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]); 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, openBibleReader, openReadingPlans } = useApp();
const go = { studies: goHome, reader: openBibleReader, plans: openReadingPlans };
return ( return (
<div className="min-h-screen bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}> <div className="min-h-screen bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]"> <header className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className={`mx-auto flex min-h-[3rem] ${maxWidth} flex-wrap items-center gap-2 px-3 py-1.5`}> <div className={`mx-auto flex h-12 ${maxWidth} items-center gap-4 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}
className={`rounded-full px-3 py-1.5 text-sm ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{n.label}</button>
))}
</nav>
<div className="ml-auto"><AccountMenu /></div>
</div>
</header>
<div className={`mx-auto flex ${maxWidth} flex-wrap items-center gap-3 px-4 pt-6`}>
{onBack && ( {onBack && (
<button type="button" onClick={onBack} aria-label="Back" className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button> <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-base font-medium">{title}</h1> <h1 className="text-2xl font-medium tracking-tight">{title}</h1>
<div className="ml-auto flex flex-wrap items-center gap-1.5"> {actions && <div className="ml-auto flex flex-wrap items-center gap-2">{actions}</div>}
{actions}
<button type="button" onClick={cycle} className="rounded-lg px-2 py-1.5 text-xs text-[var(--r-sub)] hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
<AuthStatus themed />
</div>
</div>
</div> </div>
{children} {children}
</div> </div>
+1 -1
View File
@@ -16,7 +16,7 @@ export const RESOURCE_TABS = [
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) { export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length }; const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
return ( return (
<div className="flex h-full min-h-0 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5"> <div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
{RESOURCE_TABS.map((t) => ( {RESOURCE_TABS.map((t) => (
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)} <button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}
+1 -1
View File
@@ -48,7 +48,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
: syncStatus === 'syncing' ? <span>Syncing…</span> : syncStatus === 'syncing' ? <span>Syncing…</span>
: syncStatus === 'synced' ? <span>Saved</span> : syncStatus === 'synced' ? <span>Saved</span>
: syncStatus === 'error' ? ( : syncStatus === 'error' ? (
<span className="text-red-600">Sync failed <button type="button" onClick={retrySync} className="underline">Retry</button></span> <span className="text-[var(--r-danger,#b42318)]">Sync failed <button type="button" onClick={retrySync} className="underline">Retry</button></span>
) : null} ) : null}
<button type="button" onClick={cycleTheme} className="rounded-lg px-2 py-1 hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button> <button type="button" onClick={cycleTheme} className="rounded-lg px-2 py-1 hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
</div> </div>
+1 -1
View File
@@ -34,7 +34,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}</button> <button type="button" onClick={() => lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}</button>
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button> <button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button>
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak</button>} {word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak</button>}
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-red-700`}>Delete</button> <button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-[var(--r-danger,#b42318)]`}>Delete</button>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{field(word.strongNumber?.startsWith('H') ? 'Hebrew word' : 'Greek word', 'lexeme')} {field(word.strongNumber?.startsWith('H') ? 'Hebrew word' : 'Greek word', 'lexeme')}
+39 -58
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import AuthStatus from '../components/AuthStatus.jsx'; import PageShell from '../components/study/PageShell.jsx';
import { buildExportHtml } from '../utils/bibleUtils.jsx'; import { buildExportHtml } from '../utils/bibleUtils.jsx';
import { import {
adminListUsers, adminListUsers,
@@ -68,46 +68,27 @@ export default function AdminPage() {
}; };
return ( return (
<div className="min-h-screen bg-slate-50 text-slate-900"> <PageShell title="Admin" onBack={goHome} maxWidth="max-w-5xl">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm"> <main className="mx-auto max-w-5xl space-y-5 px-4 py-6">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">🛡 Admin</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={goHome}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
← Back
</button>
<AuthStatus />
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
<div className="flex gap-2"> <div className="flex gap-2">
<button type="button" onClick={() => setAdminTab('users')} <button type="button" onClick={() => setAdminTab('users')}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'users' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}> className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'users' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
Users ({adminUsers.length}) Users ({adminUsers.length})
</button> </button>
<button type="button" onClick={() => setAdminTab('projects')} <button type="button" onClick={() => setAdminTab('projects')}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'projects' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}> className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'projects' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
Projects ({adminProjects.length}) Projects ({adminProjects.length})
</button> </button>
</div> </div>
{adminLoading && <p className="text-sm text-slate-500">Loading…</p>} {adminLoading && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
{adminError && <p className="text-sm text-rose-600">{adminError}</p>} {adminError && <p className="text-sm text-[var(--r-danger,#b42318)]">{adminError}</p>}
{!adminLoading && !adminError && adminTab === 'users' && ( {!adminLoading && !adminError && adminTab === 'users' && (
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel"> <div className="overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)]">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-sm"> <table className="w-full min-w-[560px] text-sm">
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"> <thead className="bg-[var(--r-bg)] text-left text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
<tr> <tr>
<th className="px-4 py-3">Email</th> <th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Joined</th> <th className="px-4 py-3">Joined</th>
@@ -118,22 +99,22 @@ export default function AdminPage() {
</thead> </thead>
<tbody> <tbody>
{adminUsers.map((u) => ( {adminUsers.map((u) => (
<tr key={u.id} className="border-t border-slate-100"> <tr key={u.id} className="border-t border-[var(--r-line)]">
<td className="px-4 py-3 font-medium text-slate-800"> <td className="px-4 py-3 font-medium text-[var(--r-text)]">
{u.email}{u.id === authUser.id && <span className="ml-2 text-xs font-normal text-slate-400">(you)</span>} {u.email}{u.id === authUser.id && <span className="ml-2 text-xs font-normal text-[var(--r-sub)]">(you)</span>}
</td> </td>
<td className="px-4 py-3 text-slate-500">{new Date(u.createdAt).toLocaleDateString()}</td> <td className="px-4 py-3 text-[var(--r-sub)]">{new Date(u.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3 text-slate-500">{u.totpEnabled ? '✓' : '—'}</td> <td className="px-4 py-3 text-[var(--r-sub)]">{u.totpEnabled ? '✓' : '—'}</td>
<td className="px-4 py-3 text-slate-500">{u.projectCount}</td> <td className="px-4 py-3 text-[var(--r-sub)]">{u.projectCount}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
{u.id !== authUser.id && ( {u.id !== authUser.id && (
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={() => handleResetPasswordAdmin(u.id, u.email)} <button type="button" onClick={() => handleResetPasswordAdmin(u.id, u.email)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50"> className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
Reset Password Reset Password
</button> </button>
<button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)} <button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)}
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50"> className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs text-[var(--r-danger,#b42318)] hover:bg-black/5">
Delete Delete
</button> </button>
</div> </div>
@@ -148,10 +129,10 @@ export default function AdminPage() {
)} )}
{!adminLoading && !adminError && adminTab === 'projects' && ( {!adminLoading && !adminError && adminTab === 'projects' && (
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel"> <div className="overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)]">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm"> <table className="w-full min-w-[640px] text-sm">
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"> <thead className="bg-[var(--r-bg)] text-left text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
<tr> <tr>
<th className="px-4 py-3">Title</th> <th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Owner</th> <th className="px-4 py-3">Owner</th>
@@ -163,22 +144,22 @@ export default function AdminPage() {
</thead> </thead>
<tbody> <tbody>
{adminProjects.map((p) => ( {adminProjects.map((p) => (
<tr key={p.id} className="border-t border-slate-100"> <tr key={p.id} className="border-t border-[var(--r-line)]">
<td className="px-4 py-3 font-medium text-slate-800">{p.title}</td> <td className="px-4 py-3 font-medium text-[var(--r-text)]">{p.title}</td>
<td className="px-4 py-3 text-slate-500"> <td className="px-4 py-3 text-[var(--r-sub)]">
{p.ownerEmail ?? <span className="italic text-slate-400">orphaned</span>} {p.ownerEmail ?? <span className="italic text-[var(--r-sub)]">orphaned</span>}
</td> </td>
<td className="px-4 py-3 text-slate-500">{p.chapterSummary}</td> <td className="px-4 py-3 text-[var(--r-sub)]">{p.chapterSummary}</td>
<td className="px-4 py-3 text-slate-500">{new Date(p.lastEdited).toLocaleDateString()}</td> <td className="px-4 py-3 text-[var(--r-sub)]">{new Date(p.lastEdited).toLocaleDateString()}</td>
<td className="px-4 py-3 text-slate-500">{p.shareToken ? '🔗' : '—'}</td> <td className="px-4 py-3 text-[var(--r-sub)]">{p.shareToken ? '🔗' : '—'}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={() => handleViewProjectAdmin(p.id)} <button type="button" onClick={() => handleViewProjectAdmin(p.id)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50"> className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
View View
</button> </button>
<button type="button" onClick={() => handleDeleteProjectAdmin(p.id, p.title)} <button type="button" onClick={() => handleDeleteProjectAdmin(p.id, p.title)}
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50"> className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs text-[var(--r-danger,#b42318)] hover:bg-black/5">
Delete Delete
</button> </button>
</div> </div>
@@ -194,11 +175,11 @@ export default function AdminPage() {
{adminViewProject && ( {adminViewProject && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setAdminViewProject(null)}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setAdminViewProject(null)}>
<div className="h-full w-full max-w-4xl overflow-hidden rounded-2xl bg-white shadow-2xl" onClick={(e) => e.stopPropagation()}> <div className="h-full w-full max-w-4xl overflow-hidden rounded-2xl bg-[var(--r-bar)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3"> <div className="flex items-center justify-between border-b border-[var(--r-line)] px-4 py-3">
<p className="text-sm font-semibold text-slate-700">{adminViewProject.title}</p> <p className="text-sm font-medium text-[var(--r-text)]">{adminViewProject.title}</p>
<button type="button" onClick={() => setAdminViewProject(null)} <button type="button" onClick={() => setAdminViewProject(null)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs text-slate-600 hover:bg-slate-50"> className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
Close Close
</button> </button>
</div> </div>
@@ -214,22 +195,22 @@ export default function AdminPage() {
{adminResetResult && ( {adminResetResult && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl"> <div className="w-full max-w-sm rounded-2xl bg-[var(--r-bar)] p-6 shadow-2xl">
<h3 className="text-sm font-semibold text-slate-900">Password reset</h3> <h3 className="text-sm font-medium text-[var(--r-text)]">Password reset</h3>
<p className="mt-1 text-xs text-slate-500"> <p className="mt-1 text-xs text-[var(--r-sub)]">
Relay this to <span className="font-medium text-slate-700">{adminResetResult.email}</span> yourself Relay this to <span className="font-medium text-[var(--r-text)]">{adminResetResult.email}</span> yourself
(text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it. (text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it.
</p> </p>
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-center font-mono text-lg tracking-wider text-amber-800"> <p className="mt-4 rounded-xl border border-[var(--r-accent)] bg-[var(--r-bg)] px-3 py-2 text-center font-mono text-lg tracking-wider text-[var(--r-accent)]">
{adminResetResult.temporaryPassword} {adminResetResult.temporaryPassword}
</p> </p>
<button type="button" onClick={() => setAdminResetResult(null)} <button type="button" onClick={() => setAdminResetResult(null)}
className="mt-4 w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400"> className="mt-4 w-full rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90">
Done Done
</button> </button>
</div> </div>
</div> </div>
)} )}
</div> </PageShell>
); );
} }
+38 -36
View File
@@ -1,9 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { themeVars } from '../components/reader/readerThemes.js';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js'; import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
export default function AuthPage() { export default function AuthPage() {
const { signIn } = useApp(); const { signIn, readerTheme } = useApp();
const shell = { style: themeVars(readerTheme) };
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
const [authForm, setAuthForm] = useState({ email: '', password: '' }); const [authForm, setAuthForm] = useState({ email: '', password: '' });
const [authError, setAuthError] = useState(''); const [authError, setAuthError] = useState('');
@@ -49,11 +51,11 @@ export default function AuthPage() {
if (authMfaPending) { if (authMfaPending) {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4"> <div {...shell} className="flex min-h-screen items-center justify-center bg-[var(--r-bg)] px-4 text-[var(--r-text)]">
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel"> <div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p> <p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
<h1 className="mt-2 text-xl font-semibold text-slate-900">Two-factor verification</h1> <h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-[var(--r-sub)]">
{authMfaUseBackup {authMfaUseBackup
? 'Enter one of your saved backup codes.' ? 'Enter one of your saved backup codes.'
: 'Enter the 6-digit code from your authenticator app.'} : 'Enter the 6-digit code from your authenticator app.'}
@@ -67,13 +69,13 @@ export default function AuthPage() {
placeholder={authMfaUseBackup ? 'xxxxxxxxxx' : '123456'} placeholder={authMfaUseBackup ? 'xxxxxxxxxx' : '123456'}
value={authMfaCode} value={authMfaCode}
onChange={(e) => setAuthMfaCode(e.target.value)} onChange={(e) => setAuthMfaCode(e.target.value)}
className="block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-center text-lg tracking-widest focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
{authError && <p className="text-sm text-rose-600">{authError}</p>} {authError && <p className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
<button <button
type="submit" type="submit"
disabled={authBusy} disabled={authBusy}
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300" className="w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
> >
{authBusy ? 'Verifying…' : 'Verify'} {authBusy ? 'Verifying…' : 'Verify'}
</button> </button>
@@ -85,7 +87,7 @@ export default function AuthPage() {
setAuthMfaCode(''); setAuthMfaCode('');
setAuthError(''); setAuthError('');
}} }}
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700" className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
> >
{authMfaUseBackup ? 'Use your authenticator app instead' : "Lost your device? Use a backup code"} {authMfaUseBackup ? 'Use your authenticator app instead' : "Lost your device? Use a backup code"}
</button> </button>
@@ -97,7 +99,7 @@ export default function AuthPage() {
setAuthMfaCode(''); setAuthMfaCode('');
setAuthError(''); setAuthError('');
}} }}
className="mt-2 w-full text-center text-sm text-slate-400 underline hover:text-slate-600" className="mt-2 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-sub)]"
> >
Cancel Cancel
</button> </button>
@@ -107,41 +109,41 @@ export default function AuthPage() {
} }
return ( return (
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4 py-12"> <div {...shell} className="flex min-h-screen items-center justify-center bg-[var(--r-bg)] px-4 py-12 text-[var(--r-text)]">
<div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2"> <div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2">
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */} {/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
<div className="hidden lg:block"> <div className="hidden lg:block">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p> <p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
<h1 className="mt-4 text-3xl font-semibold leading-tight text-white"> <h1 className="mt-4 text-3xl font-semibold leading-tight text-[var(--r-text)]">
Deep Bible study,<br />organized chunk by chunk. Deep Bible study,<br />one chunk at a time.
</h1> </h1>
<p className="mt-4 max-w-sm text-slate-300"> <p className="mt-4 max-w-sm text-[var(--r-sub)]">
Split any passage into chunks and work through Observation, Interpretation, and Read Scripture in six translations, follow a reading plan into the text, and study a passage one chunk
Application notes alongside Greek &amp; Hebrew word studies, cross-references, and commentary. at a time with commentary, word studies, and cross-references beside it.
</p> </p>
<ul className="mt-8 space-y-4 text-sm text-slate-300"> <ul className="mt-8 space-y-3 text-sm text-[var(--r-sub)]">
<li className="flex items-center gap-3"><span className="text-lg">📖</span> Chunk-by-chunk OIA notes on any passage</li> <li>Read in BSB, ESV, NLT, CSB, NASB, or NET, with audio and search</li>
<li className="flex items-center gap-3"><span className="text-lg">🔤</span> Greek &amp; Hebrew word studies with pronunciation</li> <li>Reading plans that open straight into the Reader</li>
<li className="flex items-center gap-3"><span className="text-lg">🔗</span> Cross-references and commentary, one click away</li> <li>Observe, Interpret, Apply notes with Greek &amp; Hebrew word studies</li>
<li className="flex items-center gap-3"><span className="text-lg">🎙</span> Turn your notes into a podcast-ready script</li> <li>Turn your notes into a script, then export or share</li>
<li className="flex items-center gap-3"><span className="text-lg">🔒</span> Your studies stay private to your account, with optional 2FA</li> <li>Your studies stay private to your account, with optional 2FA</li>
</ul> </ul>
<blockquote className="mt-8 border-l-2 border-slate-700 pl-4 text-sm italic text-slate-400"> <blockquote className="mt-8 border-l-2 border-[var(--r-line)] pl-4 text-sm italic text-[var(--r-sub)]">
"Make every effort to present yourself approved to God, an unashamed workman who accurately "Make every effort to present yourself approved to God, an unashamed workman who accurately
handles the word of truth." handles the word of truth."
<footer className="mt-1 not-italic text-slate-500">— 2 Timothy 2:15 (BSB)</footer> <footer className="mt-1 not-italic text-[var(--r-sub)]">— 2 Timothy 2:15 (BSB)</footer>
</blockquote> </blockquote>
</div> </div>
{/* Sign in / register form */} {/* Sign in / register form */}
<div className="flex justify-center lg:justify-start"> <div className="flex justify-center lg:justify-start">
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel"> <div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400 lg:hidden">Bible Study Project</p> <p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)] lg:hidden">Bible Study Project</p>
<h1 className="mt-2 text-xl font-semibold text-slate-900"> <h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">
{authMode === 'login' ? 'Sign in' : 'Create an account'} {authMode === 'login' ? 'Sign in' : 'Create an account'}
</h1> </h1>
<form onSubmit={submitAuth} className="mt-6 space-y-4"> <form onSubmit={submitAuth} className="mt-6 space-y-4">
<label className="block text-sm font-medium text-slate-700"> <label className="block text-sm">
Email Email
<input <input
type="email" type="email"
@@ -149,10 +151,10 @@ export default function AuthPage() {
autoComplete="email" autoComplete="email"
value={authForm.email} value={authForm.email}
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))} onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
</label> </label>
<label className="block text-sm font-medium text-slate-700"> <label className="block text-sm font-medium text-[var(--r-text)]">
Password Password
<input <input
type="password" type="password"
@@ -161,14 +163,14 @@ export default function AuthPage() {
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'} autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
value={authForm.password} value={authForm.password}
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))} onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
</label> </label>
{authError && <p className="text-sm text-rose-600">{authError}</p>} {authError && <p className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
<button <button
type="submit" type="submit"
disabled={authBusy} disabled={authBusy}
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300" className="w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
> >
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'} {authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
</button> </button>
@@ -179,7 +181,7 @@ export default function AuthPage() {
setAuthMode((m) => (m === 'login' ? 'register' : 'login')); setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
setAuthError(''); setAuthError('');
}} }}
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700" className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
> >
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'} {authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
</button> </button>
+1 -1
View File
@@ -47,7 +47,7 @@ export default function ExportPage() {
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" /> className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
<button type="button" onClick={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button> <button type="button" onClick={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button>
</div> </div>
<button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-red-700 hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button> <button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button>
</div> </div>
) : ( ) : (
<button type="button" onClick={share.enable} disabled={share.shareBusy} className={btn}>{share.shareBusy ? 'Enabling…' : 'Enable sharing'}</button> <button type="button" onClick={share.enable} disabled={share.shareBusy} className={btn}>{share.shareBusy ? 'Enabling…' : 'Enable sharing'}</button>
+4 -6
View File
@@ -29,7 +29,6 @@ export default function HomePage() {
audioNarrator, setAudioNarrator, audioNarrator, setAudioNarrator,
audioState, audioState,
audioProgress, audioProgress,
openBibleReader,
openImportProject, openImportProject,
openNewProject, openNewProject,
pullLatestFromServer, pullLatestFromServer,
@@ -110,10 +109,9 @@ export default function HomePage() {
return ( return (
<PageShell <PageShell
title="My Studies" title="My Studies"
active="studies"
actions={( actions={(
<> <>
<button type="button" onClick={openBibleReader} className={linkBtn}>Read Bible</button>
<button type="button" onClick={openReadingPlans} className={linkBtn}>Reading Plans</button>
<button type="button" onClick={openImportProject} className={linkBtn} title="Set up a whole series from a session list (.docx)">Import Session List</button> <button type="button" onClick={openImportProject} className={linkBtn} title="Set up a whole series from a session list (.docx)">Import Session List</button>
<button type="button" onClick={openNewProject} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button> <button type="button" onClick={openNewProject} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
</> </>
@@ -137,8 +135,8 @@ export default function HomePage() {
)} )}
{readingPlanSyncStatus === 'error' && ( {readingPlanSyncStatus === 'error' && (
<div className={`${notice} flex flex-wrap items-center justify-between gap-3`}> <div className={`${notice} flex flex-wrap items-center justify-between gap-3`}>
<span className="text-red-700">Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.</span> <span className="text-[var(--r-danger,#b42318)]">Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.</span>
<button type="button" onClick={retryReadingPlanSync} className="shrink-0 rounded-lg border border-red-300 px-3 py-1 text-xs text-red-700 hover:bg-red-50">Retry</button> <button type="button" onClick={retryReadingPlanSync} className="shrink-0 rounded-lg border border-red-300 px-3 py-1 text-xs text-[var(--r-danger,#b42318)] hover:bg-red-50">Retry</button>
</div> </div>
)} )}
@@ -227,7 +225,7 @@ export default function HomePage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button> <button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-red-700 hover:bg-black/5">Delete</button> <button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">Delete</button>
</div> </div>
</div> </div>
))} ))}
+3 -3
View File
@@ -150,7 +150,7 @@ export default function ImportPage() {
const field = 'mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; const field = 'mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
return ( return (
<PageShell title="Import Session List" onBack={() => setCurrentPage('home')} maxWidth="max-w-4xl"> <PageShell title="Import Session List" active="studies" onBack={() => setCurrentPage('home')} maxWidth="max-w-3xl">
<main className="mx-auto max-w-3xl space-y-6 px-4 py-8"> <main className="mx-auto max-w-3xl space-y-6 px-4 py-8">
<section className="space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6"> <section className="space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<div className="space-y-2 rounded-xl bg-[var(--r-bg)] p-4 text-sm text-[var(--r-sub)]"> <div className="space-y-2 rounded-xl bg-[var(--r-bg)] p-4 text-sm text-[var(--r-sub)]">
@@ -187,7 +187,7 @@ export default function ImportPage() {
</label> </label>
{importBusy && <p className="text-sm text-[var(--r-sub)]">Working…</p>} {importBusy && <p className="text-sm text-[var(--r-sub)]">Working…</p>}
{importError && <p className="text-sm text-red-600">{importError}</p>} {importError && <p className="text-sm text-[var(--r-danger,#b42318)]">{importError}</p>}
{importPreview && !importBusy && ( {importPreview && !importBusy && (
<div className="space-y-3"> <div className="space-y-3">
@@ -205,7 +205,7 @@ export default function ImportPage() {
<td className="px-3 py-1.5">{spec.title}</td> <td className="px-3 py-1.5">{spec.title}</td>
<td className="px-3 py-1.5 text-right text-[var(--r-sub)]"> <td className="px-3 py-1.5 text-right text-[var(--r-sub)]">
{spec.parsed === 'invalid' {spec.parsed === 'invalid'
? <span className="text-red-600">unrecognized — skipped</span> ? <span className="text-[var(--r-danger,#b42318)]">unrecognized — skipped</span>
: spec.parsed ? spec.passage : <span>marker (no passage)</span>} : spec.parsed ? spec.passage : <span>marker (no passage)</span>}
</td> </td>
</tr> </tr>
+3 -3
View File
@@ -113,11 +113,11 @@ export default function ReadingPlansPage() {
<main className="mx-auto max-w-3xl px-4 pt-5 font-sans"> <main className="mx-auto max-w-3xl px-4 pt-5 font-sans">
{readingPlanSyncStatus === 'error' && ( {readingPlanSyncStatus === 'error' && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-300 px-4 py-3 text-sm"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-300 px-4 py-3 text-sm">
<span className="text-red-700"> <span className="text-[var(--r-danger,#b42318)]">
Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed. Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.
{readingPlanSyncError && <span className="block text-xs">{readingPlanSyncError}</span>} {readingPlanSyncError && <span className="block text-xs">{readingPlanSyncError}</span>}
</span> </span>
<button type="button" onClick={retryReadingPlanSync} className="shrink-0 rounded-lg border border-red-300 px-3 py-1.5 font-medium text-red-700 hover:bg-red-50">Retry</button> <button type="button" onClick={retryReadingPlanSync} className="shrink-0 rounded-lg border border-red-300 px-3 py-1.5 font-medium text-[var(--r-danger,#b42318)] hover:bg-red-50">Retry</button>
</div> </div>
)} )}
@@ -217,7 +217,7 @@ export default function ReadingPlansPage() {
</div> </div>
<div className="flex shrink-0 items-center gap-3 text-xs"> <div className="flex shrink-0 items-center gap-3 text-xs">
<button type="button" onClick={() => restartPlan(p.id)} className="font-medium hover:underline">Start again</button> <button type="button" onClick={() => restartPlan(p.id)} className="font-medium hover:underline">Start again</button>
<button type="button" onClick={() => { if (window.confirm('Remove this from your history?')) removePlanProgress(p.id); }} className="text-red-600 hover:underline">Remove</button> <button type="button" onClick={() => { if (window.confirm('Remove this from your history?')) removePlanProgress(p.id); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</div> </div>
</div> </div>
); );
+72 -92
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import AuthStatus from '../components/AuthStatus.jsx'; import PageShell from '../components/study/PageShell.jsx';
import { import {
startMfaSetup, startMfaSetup,
confirmMfaSetup, confirmMfaSetup,
@@ -160,52 +160,32 @@ export default function SettingsPage() {
}; };
return ( return (
<div className="min-h-screen bg-slate-50 text-slate-900"> <PageShell title="Account Settings" onBack={goHome} maxWidth="max-w-2xl">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm"> <main className="mx-auto max-w-2xl space-y-5 px-4 py-6">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8"> <section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-2">
<div> <h2 className="text-base font-medium text-[var(--r-text)]">Account</h2>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p> <p className="text-sm text-[var(--r-sub)]">
<h1 className="mt-2 text-2xl font-semibold">Account Settings</h1> Signed in as <span className="font-medium text-[var(--r-text)]">{authUser.email}</span>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={goHome}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
← Back
</button>
<AuthStatus />
</div>
</div>
</header>
<main className="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-2">
<h2 className="text-lg font-semibold text-slate-900">Account</h2>
<p className="text-sm text-slate-500">
Signed in as <span className="font-medium text-slate-700">{authUser.email}</span>
</p> </p>
</section> </section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4"> <section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900">Change password</h2> <h2 className="text-base font-medium text-[var(--r-text)]">Change password</h2>
<p className="text-sm text-slate-500">Changing your password signs you out of every other device — this one stays signed in.</p> <p className="text-sm text-[var(--r-sub)]">Changing your password signs you out of every other device — this one stays signed in.</p>
</div> </div>
<form onSubmit={submitChangePassword} className="space-y-3"> <form onSubmit={submitChangePassword} className="space-y-3">
<label className="block text-sm font-medium text-slate-700"> <label className="block text-sm">Current password
Current password
<input <input
type="password" type="password"
required required
autoComplete="current-password" autoComplete="current-password"
value={changePasswordForm.current} value={changePasswordForm.current}
onChange={(e) => setChangePasswordForm((f) => ({ ...f, current: e.target.value }))} onChange={(e) => setChangePasswordForm((f) => ({ ...f, current: e.target.value }))}
className="mt-1 block w-full max-w-xs rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="mt-1 block w-full max-w-xs rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
</label> </label>
<label className="block text-sm font-medium text-slate-700"> <label className="block text-sm font-medium text-[var(--r-text)]">
New password New password
<input <input
type="password" type="password"
@@ -214,10 +194,10 @@ export default function SettingsPage() {
autoComplete="new-password" autoComplete="new-password"
value={changePasswordForm.next} value={changePasswordForm.next}
onChange={(e) => setChangePasswordForm((f) => ({ ...f, next: e.target.value }))} onChange={(e) => setChangePasswordForm((f) => ({ ...f, next: e.target.value }))}
className="mt-1 block w-full max-w-xs rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="mt-1 block w-full max-w-xs rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
</label> </label>
<label className="block text-sm font-medium text-slate-700"> <label className="block text-sm font-medium text-[var(--r-text)]">
Confirm new password Confirm new password
<input <input
type="password" type="password"
@@ -226,26 +206,26 @@ export default function SettingsPage() {
autoComplete="new-password" autoComplete="new-password"
value={changePasswordForm.confirm} value={changePasswordForm.confirm}
onChange={(e) => setChangePasswordForm((f) => ({ ...f, confirm: e.target.value }))} onChange={(e) => setChangePasswordForm((f) => ({ ...f, confirm: e.target.value }))}
className="mt-1 block w-full max-w-xs rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="mt-1 block w-full max-w-xs rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
</label> </label>
{changePasswordError && <p className="text-sm text-rose-600">{changePasswordError}</p>} {changePasswordError && <p className="text-sm text-[var(--r-danger,#b42318)]">{changePasswordError}</p>}
{changePasswordSaved && <p className="text-sm text-emerald-600">Password changed.</p>} {changePasswordSaved && <p className="text-sm text-[var(--r-accent)]">Password changed.</p>}
<button <button
type="submit" type="submit"
disabled={changePasswordBusy} disabled={changePasswordBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300" className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
> >
{changePasswordBusy ? 'Saving…' : 'Change password'} {changePasswordBusy ? 'Saving…' : 'Change password'}
</button> </button>
</form> </form>
</section> </section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4"> <section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900">Podcast / show name</h2> <h2 className="text-base font-medium text-[var(--r-text)]">Podcast / show name</h2>
<p className="text-sm text-slate-500"> <p className="text-sm text-[var(--r-sub)]">
Only needed if you use "🎙 Prepare for Podcast" on the study page — it fills in your show's Only needed if you use "Podcast → Claude" on a study's Script stage — it fills in your show's
name when asking Claude to write an episode script. Leave blank if you're just doing personal name when asking Claude to write an episode script. Leave blank if you're just doing personal
study; that button still works, it just won't name a specific show. study; that button still works, it just won't name a specific show.
</p> </p>
@@ -256,23 +236,23 @@ export default function SettingsPage() {
value={podcastNameInput} value={podcastNameInput}
onChange={(e) => setPodcastNameInput(e.target.value)} onChange={(e) => setPodcastNameInput(e.target.value)}
placeholder="e.g. Verse by Verse with Nate: A Journey Through Scripture" placeholder="e.g. Verse by Verse with Nate: A Journey Through Scripture"
className="w-full flex-1 rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="w-full flex-1 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
<button <button
type="submit" type="submit"
disabled={podcastNameSaving} disabled={podcastNameSaving}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300" className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
> >
{podcastNameSaving ? 'Saving…' : 'Save'} {podcastNameSaving ? 'Saving…' : 'Save'}
</button> </button>
</form> </form>
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>} {podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
</section> </section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4"> <section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900">My reading journal</h2> <h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2>
<p className="text-sm text-slate-500"> <p className="text-sm text-[var(--r-sub)]">
A document of your bookmarked verses, highlighted passages, and completed reading plans. A document of your bookmarked verses, highlighted passages, and completed reading plans.
Bookmarks and highlights only live on this device (they're not synced), so this reflects Bookmarks and highlights only live on this device (they're not synced), so this reflects
what's saved here. what's saved here.
@@ -283,7 +263,7 @@ export default function SettingsPage() {
type="button" type="button"
onClick={downloadJournalDocx} onClick={downloadJournalDocx}
disabled={journalBusy !== ''} disabled={journalBusy !== ''}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300" className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
> >
{journalBusy === 'docx' ? 'Building…' : 'Download as Word (.docx)'} {journalBusy === 'docx' ? 'Building…' : 'Download as Word (.docx)'}
</button> </button>
@@ -291,83 +271,83 @@ export default function SettingsPage() {
type="button" type="button"
onClick={printJournal} onClick={printJournal}
disabled={journalBusy !== ''} disabled={journalBusy !== ''}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50" className="rounded-full border border-[var(--r-line)] px-5 py-2 text-sm font-medium transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50"
> >
{journalBusy === 'pdf' ? 'Building…' : 'Print / Save as PDF'} {journalBusy === 'pdf' ? 'Building…' : 'Print / Save as PDF'}
</button> </button>
</div> </div>
{journalError && <p className="text-sm text-rose-600">{journalError}</p>} {journalError && <p className="text-sm text-[var(--r-danger,#b42318)]">{journalError}</p>}
</section> </section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4"> <section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900">Reading reminders</h2> <h2 className="text-base font-medium text-[var(--r-text)]">Reading reminders</h2>
<p className="text-sm text-slate-500"> <p className="text-sm text-[var(--r-sub)]">
Get a daily nudge via{' '} Get a daily nudge via{' '}
<a href="https://ntfy.sh" target="_blank" rel="noreferrer" className="text-sky-600 underline">ntfy.sh</a> <a href="https://ntfy.sh" target="_blank" rel="noreferrer" className="text-[var(--r-accent)] underline">ntfy.sh</a>
{' '}— a free push-notification service. Install the ntfy app (or open{' '} {' '}— a free push-notification service. Install the ntfy app (or open{' '}
<span className="font-mono text-xs">{(reminderForm.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '')}/{reminderForm.ntfyTopic || '<topic>'}</span> <span className="font-mono text-xs">{(reminderForm.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '')}/{reminderForm.ntfyTopic || '<topic>'}</span>
{' '}in a browser) and subscribe to the topic below to receive it. {' '}in a browser) and subscribe to the topic below to receive it.
</p> </p>
</div> </div>
<form onSubmit={submitReminderSettings} className="space-y-3"> <form onSubmit={submitReminderSettings} className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700"> <label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
<input <input
type="checkbox" type="checkbox"
checked={reminderForm.reminderEnabled} checked={reminderForm.reminderEnabled}
onChange={(e) => setReminderForm((f) => ({ ...f, reminderEnabled: e.target.checked }))} onChange={(e) => setReminderForm((f) => ({ ...f, reminderEnabled: e.target.checked }))}
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500" className="h-4 w-4 accent-[var(--r-accent)]"
/> />
Enable daily reading reminder Enable daily reading reminder
</label> </label>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<label className="block max-w-xs text-sm font-medium text-slate-700"> <label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
Topic Topic
<input <input
type="text" type="text"
placeholder="e.g. nate-bible-reading-8f2" placeholder="e.g. nate-bible-reading-8f2"
value={reminderForm.ntfyTopic} value={reminderForm.ntfyTopic}
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyTopic: e.target.value }))} onChange={(e) => setReminderForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
<span className="mt-1 block text-xs text-slate-400">Pick something unique — anyone who knows it can subscribe.</span> <span className="mt-1 block text-xs text-[var(--r-sub)]">Pick something unique — anyone who knows it can subscribe.</span>
</label> </label>
<label className="block max-w-xs text-sm font-medium text-slate-700"> <label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
Reminder time Reminder time
<input <input
type="time" type="time"
value={reminderForm.reminderTime} value={reminderForm.reminderTime}
onChange={(e) => setReminderForm((f) => ({ ...f, reminderTime: e.target.value }))} onChange={(e) => setReminderForm((f) => ({ ...f, reminderTime: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
</label> </label>
</div> </div>
<label className="block max-w-xs text-sm font-medium text-slate-700"> <label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
ntfy server <span className="text-slate-400">(optional — defaults to ntfy.sh)</span> ntfy server <span className="text-[var(--r-sub)]">(optional — defaults to ntfy.sh)</span>
<input <input
type="text" type="text"
placeholder="https://ntfy.sh" placeholder="https://ntfy.sh"
value={reminderForm.ntfyUrl} value={reminderForm.ntfyUrl}
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyUrl: e.target.value }))} onChange={(e) => setReminderForm((f) => ({ ...f, ntfyUrl: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
</label> </label>
{reminderError && <p className="text-sm text-rose-600">{reminderError}</p>} {reminderError && <p className="text-sm text-[var(--r-danger,#b42318)]">{reminderError}</p>}
{reminderSaved && <p className="text-sm text-emerald-600">Saved.</p>} {reminderSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
<button <button
type="submit" type="submit"
disabled={reminderBusy} disabled={reminderBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300" className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
> >
{reminderBusy ? 'Saving…' : 'Save'} {reminderBusy ? 'Saving…' : 'Save'}
</button> </button>
</form> </form>
</section> </section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4"> <section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2> <h2 className="text-base font-medium text-[var(--r-text)]">Two-factor authentication</h2>
<p className="text-sm text-slate-500"> <p className="text-sm text-[var(--r-sub)]">
{authUser.totpEnabled {authUser.totpEnabled
? "Enabled — you'll need a code from your authenticator app to sign in." ? "Enabled — you'll need a code from your authenticator app to sign in."
: 'Add a 6-digit code from an authenticator app (Google Authenticator, Authy, 1Password, etc.) as a second step at sign-in.'} : 'Add a 6-digit code from an authenticator app (Google Authenticator, Authy, 1Password, etc.) as a second step at sign-in.'}
@@ -375,39 +355,39 @@ export default function SettingsPage() {
</div> </div>
{mfaBackupCodes ? ( {mfaBackupCodes ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 space-y-3"> <div className="rounded-2xl border border-[var(--r-accent)] bg-[var(--r-bg)] p-4 space-y-3">
<p className="text-sm font-semibold text-amber-800"> <p className="text-sm font-medium text-[var(--r-accent)]">
Save these backup codes now — each works once if you ever lose your device. They won't be shown again. Save these backup codes now — each works once if you ever lose your device. They won't be shown again.
</p> </p>
<div className="grid grid-cols-2 gap-2 font-mono text-sm text-slate-800"> <div className="grid grid-cols-2 gap-2 font-mono text-sm text-[var(--r-text)]">
{mfaBackupCodes.map((code) => ( {mfaBackupCodes.map((code) => (
<div key={code} className="rounded-lg border border-amber-200 bg-white px-3 py-1.5">{code}</div> <div key={code} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-3 py-1.5">{code}</div>
))} ))}
</div> </div>
<button <button
type="button" type="button"
onClick={() => setMfaBackupCodes(null)} onClick={() => setMfaBackupCodes(null)}
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400" className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90"
> >
I've saved these codes I've saved these codes
</button> </button>
</div> </div>
) : authUser.totpEnabled ? ( ) : authUser.totpEnabled ? (
<form onSubmit={submitDisable} className="space-y-3"> <form onSubmit={submitDisable} className="space-y-3">
<label className="block max-w-xs text-sm font-medium text-slate-700"> <label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
Enter your password to disable 2FA Enter your password to disable 2FA
<input <input
type="password" type="password"
required required
value={mfaDisablePassword} value={mfaDisablePassword}
onChange={(e) => setMfaDisablePassword(e.target.value)} onChange={(e) => setMfaDisablePassword(e.target.value)}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
</label> </label>
{mfaDisableError && <p className="text-sm text-rose-600">{mfaDisableError}</p>} {mfaDisableError && <p className="text-sm text-[var(--r-danger,#b42318)]">{mfaDisableError}</p>}
<button <button
type="submit" type="submit"
className="rounded-md bg-rose-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-rose-400" className="rounded-full bg-red-700 px-5 py-2 text-sm font-medium text-white transition hover:opacity-90"
> >
Disable 2FA Disable 2FA
</button> </button>
@@ -415,13 +395,13 @@ export default function SettingsPage() {
) : mfaSetup ? ( ) : mfaSetup ? (
<form onSubmit={confirmSetup} className="space-y-4"> <form onSubmit={confirmSetup} className="space-y-4">
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start"> <div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
<img src={mfaSetup.qrCodeDataUrl} alt="2FA QR code" className="h-40 w-40 rounded-xl border border-slate-200" /> <img src={mfaSetup.qrCodeDataUrl} alt="2FA QR code" className="h-40 w-40 rounded-xl border border-[var(--r-line)]" />
<div className="space-y-1 text-sm text-slate-600"> <div className="space-y-1 text-sm text-[var(--r-sub)]">
<p>Scan this with your authenticator app, or enter the code manually:</p> <p>Scan this with your authenticator app, or enter the code manually:</p>
<p className="break-all rounded-lg bg-slate-100 px-2 py-1 font-mono text-xs">{mfaSetup.secret}</p> <p className="break-all rounded-lg bg-[var(--r-bg)] px-2 py-1 font-mono text-xs">{mfaSetup.secret}</p>
</div> </div>
</div> </div>
<label className="block max-w-xs text-sm font-medium text-slate-700"> <label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
Enter the 6-digit code it shows Enter the 6-digit code it shows
<input <input
type="text" type="text"
@@ -430,22 +410,22 @@ export default function SettingsPage() {
placeholder="123456" placeholder="123456"
value={mfaSetupCode} value={mfaSetupCode}
onChange={(e) => setMfaSetupCode(e.target.value)} onChange={(e) => setMfaSetupCode(e.target.value)}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-center text-lg tracking-widest focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/> />
</label> </label>
{mfaSetupError && <p className="text-sm text-rose-600">{mfaSetupError}</p>} {mfaSetupError && <p className="text-sm text-[var(--r-danger,#b42318)]">{mfaSetupError}</p>}
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
type="submit" type="submit"
disabled={mfaSetupBusy} disabled={mfaSetupBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300" className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
> >
{mfaSetupBusy ? 'Verifying…' : 'Confirm & enable'} {mfaSetupBusy ? 'Verifying…' : 'Confirm & enable'}
</button> </button>
<button <button
type="button" type="button"
onClick={cancelSetup} onClick={cancelSetup}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50" className="rounded-full border border-[var(--r-line)] px-5 py-2 text-sm font-medium transition hover:bg-black/5"
> >
Cancel Cancel
</button> </button>
@@ -457,15 +437,15 @@ export default function SettingsPage() {
type="button" type="button"
onClick={startSetup} onClick={startSetup}
disabled={mfaSetupBusy} disabled={mfaSetupBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300" className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
> >
{mfaSetupBusy ? 'Starting…' : 'Enable 2FA'} {mfaSetupBusy ? 'Starting…' : 'Enable 2FA'}
</button> </button>
{mfaSetupError && <p className="text-sm text-rose-600">{mfaSetupError}</p>} {mfaSetupError && <p className="text-sm text-[var(--r-danger,#b42318)]">{mfaSetupError}</p>}
</div> </div>
)} )}
</section> </section>
</main> </main>
</div> </PageShell>
); );
} }
+2 -2
View File
@@ -32,7 +32,7 @@ function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, wi
)} )}
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-xs text-[var(--r-sub)]">{error ? <span className="text-red-600">{error}</span> : 'Text: Berean Standard Bible (BSB).'}</p> <p className="text-xs text-[var(--r-sub)]">{error ? <span className="text-[var(--r-danger,#b42318)]">{error}</span> : 'Text: Berean Standard Bible (BSB).'}</p>
<button type="button" onClick={onLoad} disabled={loading} className={primaryBtn}>{loading ? 'Loading…' : 'Load chapter'}</button> <button type="button" onClick={onLoad} disabled={loading} className={primaryBtn}>{loading ? 'Loading…' : 'Load chapter'}</button>
</div> </div>
</div> </div>
@@ -180,7 +180,7 @@ export default function SetupPage() {
<div className="mt-2 flex items-center gap-1.5"> <div className="mt-2 flex items-center gap-1.5">
<button type="button" onClick={() => b.moveChunk(chunk.id, -1)} disabled={index === 0} aria-label="Move chunk up" className={smallBtn}>↑</button> <button type="button" onClick={() => b.moveChunk(chunk.id, -1)} disabled={index === 0} aria-label="Move chunk up" className={smallBtn}>↑</button>
<button type="button" onClick={() => b.moveChunk(chunk.id, 1)} disabled={index === activeChapter.chunks.length - 1} aria-label="Move chunk down" className={smallBtn}>↓</button> <button type="button" onClick={() => b.moveChunk(chunk.id, 1)} disabled={index === activeChapter.chunks.length - 1} aria-label="Move chunk down" className={smallBtn}>↓</button>
<button type="button" onClick={() => b.deleteChunk(chunk.id)} className={`${smallBtn} ml-auto text-red-700`}>× Delete</button> <button type="button" onClick={() => b.deleteChunk(chunk.id)} className={`${smallBtn} ml-auto text-[var(--r-danger,#b42318)]`}>× Delete</button>
</div> </div>
</div> </div>
))} ))}
+12 -3
View File
@@ -78,7 +78,7 @@ export default function StudyPage() {
return ( return (
<StudioShell stage="study" fixedHeight> <StudioShell stage="study" fixedHeight>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto lg:grid lg:grid-cols-[16rem_minmax(0,1fr)_27rem] lg:overflow-hidden xl:grid-cols-[17rem_minmax(0,1fr)_29rem]"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto lg:grid lg:grid-cols-[15rem_minmax(0,1fr)_28rem] lg:overflow-hidden xl:grid-cols-[16rem_minmax(0,1fr)_32rem]">
<aside className="hidden min-h-0 border-r border-[var(--r-line)] bg-[var(--r-bar)] lg:block"> <aside className="hidden min-h-0 border-r border-[var(--r-line)] bg-[var(--r-bar)] lg:block">
<OutlineRail project={project} selectedId={selectedChunk?.id} onSelect={select} onEditOutline={() => setCurrentPage('setup')} /> <OutlineRail project={project} selectedId={selectedChunk?.id} onSelect={select} onEditOutline={() => setCurrentPage('setup')} />
</aside> </aside>
@@ -107,21 +107,30 @@ export default function StudyPage() {
interlinear={resources.interlinearData} interlinearLoading={resources.interlinearLoading} interlinear={resources.interlinearData} interlinearLoading={resources.interlinearLoading}
addedStrongs={addedStrongs} onPickWord={pickWord} addedStrongs={addedStrongs} onPickWord={pickWord}
/> />
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} />
</div> </div>
) : ( ) : (
<p className="p-8 text-center text-sm text-[var(--r-sub)]">Add a chunk in the Outline stage to start studying.</p> <p className="p-8 text-center text-sm text-[var(--r-sub)]">Add a chunk in the Outline stage to start studying.</p>
)} )}
</main> </main>
<aside className="min-h-0 border-t border-[var(--r-line)] bg-[var(--r-bar)] lg:border-l lg:border-t-0"> <aside className="flex min-h-0 flex-col gap-3 border-t border-[var(--r-line)] bg-[var(--r-bar)] p-3 lg:border-l lg:border-t-0">
{selectedChunk && chapter && ( {selectedChunk && chapter && (
<>
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
<h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} />
</div>
</section>
<section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]">
<ResourcePane <ResourcePane
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project} tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
updateChunk={updateChunk} notify={notify} updateChunk={updateChunk} notify={notify}
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs} resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
openWordId={openWordId} setOpenWordId={setOpenWordId} openWordId={openWordId} setOpenWordId={setOpenWordId}
/> />
</section>
</>
)} )}
</aside> </aside>
</div> </div>