Rework app bar, notes layout, Settings/Admin/login restyle
Build and Push Docker Image / build-and-push (push) Failing after 20s
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:
1 parent
31abee0695
commit
ecb1c701fa
22 files changed
+351
-289
No files matched your search
+74
-5
@@ -76,6 +76,12 @@ async function renderApp() {
|
||||
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() {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
@@ -412,6 +418,7 @@ describe('Authentication', () => {
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await signInWithForm(u);
|
||||
await openAccountMenu(u);
|
||||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||
expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true);
|
||||
await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true));
|
||||
@@ -424,6 +431,7 @@ describe('Authentication', () => {
|
||||
await signInWithForm(u);
|
||||
await u.type(await screen.findByPlaceholderText('123456'), '123456');
|
||||
await u.click(screen.getByRole('button', { name: /verify/i }));
|
||||
await openAccountMenu(u);
|
||||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||
expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true);
|
||||
});
|
||||
@@ -432,8 +440,9 @@ describe('Authentication', () => {
|
||||
const fetchMock = stubApi({ session: user });
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await openAccountMenu(u);
|
||||
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(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true);
|
||||
});
|
||||
@@ -442,7 +451,8 @@ describe('Authentication', () => {
|
||||
stubApi({ session: user });
|
||||
const u = userEvent.setup();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -479,7 +489,8 @@ describe('Admin page', () => {
|
||||
stubAdminApi(admin);
|
||||
const u = userEvent.setup();
|
||||
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();
|
||||
await u.click(screen.getByRole('button', { name: /projects \(1\)/i }));
|
||||
expect(await screen.findByText('Romans Study')).toBeInTheDocument();
|
||||
@@ -490,7 +501,8 @@ describe('Admin page', () => {
|
||||
window.confirm.mockReturnValue(true);
|
||||
const u = userEvent.setup();
|
||||
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');
|
||||
await u.click(within(row).getByRole('button', { name: /delete/i }));
|
||||
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 () => {
|
||||
stubAdminApi({ ...admin, isAdmin: false });
|
||||
render(<App />);
|
||||
await openAccountMenu();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ function ActiveBody({ plan, progress, onClose, actions }) {
|
||||
</div>
|
||||
<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('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>
|
||||
</>
|
||||
);
|
||||
@@ -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">
|
||||
<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>
|
||||
{plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-red-600">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>
|
||||
{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-[var(--r-danger,#b42318)] hover:underline">Delete</button>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
{e.bookName} {e.chapter}:{e.verse}
|
||||
</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>
|
||||
))}
|
||||
</Sheet>
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function ReaderText({
|
||||
</header>
|
||||
|
||||
{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 && (
|
||||
<div
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function SearchSheet({
|
||||
{scope === 'bible' && isLocal && (
|
||||
<>
|
||||
{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
|
||||
? note('Type a word to search all 66 books.')
|
||||
: bibleMatches.length === 0
|
||||
@@ -87,7 +87,7 @@ export default function SearchSheet({
|
||||
{scope === 'bible' && !isLocal && (
|
||||
<>
|
||||
{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.total === 0 && remote.page > 0 && note(`No verses match “${query}”.`)}
|
||||
{remote.results.length > 0 && (
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
export const READER_THEMES = {
|
||||
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: {
|
||||
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: {
|
||||
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' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ function RefChip({ label, onRemove, loadVerseText }) {
|
||||
<div className="rounded-xl border border-[var(--r-line)]">
|
||||
<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={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>
|
||||
{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>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function NotesPane({ chunk, updateChunk, allTags }) {
|
||||
</button>
|
||||
{isOpen && (
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
@@ -77,7 +77,7 @@ export default function NotesPane({ chunk, updateChunk, allTags }) {
|
||||
{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">
|
||||
{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>
|
||||
))}
|
||||
<input value={tagInput} onChange={(e) => setTagInput(e.target.value)} list="study-tag-suggestions"
|
||||
|
||||
@@ -1,29 +1,91 @@
|
||||
// Themed page frame for pages that aren't part of a study project (Home, Import):
|
||||
// slim top bar with a title, optional back button, and actions on the right.
|
||||
// Themed frame for pages outside a study project (Home, Import, Settings, Admin):
|
||||
// a calm app bar (brand, three destinations, account menu) and a page heading row.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
||||
import { ChevronLeft } 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 }) {
|
||||
const { readerTheme, setReaderTheme } = useApp();
|
||||
const cycle = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
|
||||
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
|
||||
function AccountMenu() {
|
||||
const { authUser, readerTheme, setReaderTheme, setCurrentPage, logout } = useApp();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const onDown = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
|
||||
}, [open]);
|
||||
|
||||
const go = (page) => { setOpen(false); setCurrentPage(page); };
|
||||
const item = 'block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5';
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-xs font-medium uppercase hover:bg-black/5">
|
||||
{authUser?.email?.[0] ?? '·'}
|
||||
</button>
|
||||
{open && (
|
||||
<div role="menu" className="absolute right-0 top-full z-40 mt-2 w-60 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-2 shadow-xl">
|
||||
{authUser && <p className="truncate px-3 pb-1 pt-2 text-xs text-[var(--r-sub)]">{authUser.email}</p>}
|
||||
<div className="px-3 pb-2 pt-1">
|
||||
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Theme</p>
|
||||
<div className="flex overflow-hidden rounded-lg border border-[var(--r-line)]">
|
||||
{Object.entries(READER_THEMES).map(([id, t]) => (
|
||||
<button key={id} type="button" onClick={() => setReaderTheme(id)} aria-pressed={readerTheme === id}
|
||||
className={`flex-1 px-2 py-1.5 text-xs ${readerTheme === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{authUser && (
|
||||
<div className="border-t border-[var(--r-line)] pt-1">
|
||||
<button type="button" role="menuitem" onClick={() => go('settings')} className={item}>Settings</button>
|
||||
{authUser.isAdmin && <button type="button" role="menuitem" onClick={() => go('admin')} className={item}>Admin</button>}
|
||||
<button type="button" role="menuitem" onClick={() => { setOpen(false); logout(); }} className={item}>Log out</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
|
||||
const { readerTheme, goHome, openBibleReader, openReadingPlans } = useApp();
|
||||
const go = { studies: goHome, reader: openBibleReader, plans: openReadingPlans };
|
||||
return (
|
||||
<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)]">
|
||||
<div className={`mx-auto flex min-h-[3rem] ${maxWidth} flex-wrap items-center gap-2 px-3 py-1.5`}>
|
||||
{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>
|
||||
)}
|
||||
<h1 className="text-base font-medium">{title}</h1>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
||||
{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>
|
||||
<header className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-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 && (
|
||||
<button type="button" onClick={onBack} aria-label="Back" className="-ml-2 flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
|
||||
)}
|
||||
<h1 className="text-2xl font-medium tracking-tight">{title}</h1>
|
||||
{actions && <div className="ml-auto flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -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 }) {
|
||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
||||
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">
|
||||
{RESOURCE_TABS.map((t) => (
|
||||
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
|
||||
: syncStatus === 'syncing' ? <span>Syncing…</span>
|
||||
: syncStatus === 'synced' ? <span>Saved</span>
|
||||
: 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}
|
||||
<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>
|
||||
|
||||
@@ -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, '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>}
|
||||
<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 className="grid grid-cols-2 gap-2">
|
||||
{field(word.strongNumber?.startsWith('H') ? 'Hebrew word' : 'Greek word', 'lexeme')}
|
||||
|
||||
+39
-58
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 {
|
||||
adminListUsers,
|
||||
@@ -68,46 +68,27 @@ export default function AdminPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<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">
|
||||
<PageShell title="Admin" onBack={goHome} maxWidth="max-w-5xl">
|
||||
<main className="mx-auto max-w-5xl space-y-5 px-4 py-6">
|
||||
<div className="flex gap-2">
|
||||
<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})
|
||||
</button>
|
||||
<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})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adminLoading && <p className="text-sm text-slate-500">Loading…</p>}
|
||||
{adminError && <p className="text-sm text-rose-600">{adminError}</p>}
|
||||
{adminLoading && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
|
||||
{adminError && <p className="text-sm text-[var(--r-danger,#b42318)]">{adminError}</p>}
|
||||
|
||||
{!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">
|
||||
<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>
|
||||
<th className="px-4 py-3">Email</th>
|
||||
<th className="px-4 py-3">Joined</th>
|
||||
@@ -118,22 +99,22 @@ export default function AdminPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{adminUsers.map((u) => (
|
||||
<tr key={u.id} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-medium text-slate-800">
|
||||
{u.email}{u.id === authUser.id && <span className="ml-2 text-xs font-normal text-slate-400">(you)</span>}
|
||||
<tr key={u.id} className="border-t border-[var(--r-line)]">
|
||||
<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-[var(--r-sub)]">(you)</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{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-slate-500">{u.projectCount}</td>
|
||||
<td className="px-4 py-3 text-[var(--r-sub)]">{new Date(u.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 text-[var(--r-sub)]">{u.totpEnabled ? '✓' : '—'}</td>
|
||||
<td className="px-4 py-3 text-[var(--r-sub)]">{u.projectCount}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{u.id !== authUser.id && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<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
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -148,10 +129,10 @@ export default function AdminPage() {
|
||||
)}
|
||||
|
||||
{!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">
|
||||
<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>
|
||||
<th className="px-4 py-3">Title</th>
|
||||
<th className="px-4 py-3">Owner</th>
|
||||
@@ -163,22 +144,22 @@ export default function AdminPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{adminProjects.map((p) => (
|
||||
<tr key={p.id} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-medium text-slate-800">{p.title}</td>
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{p.ownerEmail ?? <span className="italic text-slate-400">orphaned</span>}
|
||||
<tr key={p.id} className="border-t border-[var(--r-line)]">
|
||||
<td className="px-4 py-3 font-medium text-[var(--r-text)]">{p.title}</td>
|
||||
<td className="px-4 py-3 text-[var(--r-sub)]">
|
||||
{p.ownerEmail ?? <span className="italic text-[var(--r-sub)]">orphaned</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{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-slate-500">{p.shareToken ? '🔗' : '—'}</td>
|
||||
<td className="px-4 py-3 text-[var(--r-sub)]">{p.chapterSummary}</td>
|
||||
<td className="px-4 py-3 text-[var(--r-sub)]">{new Date(p.lastEdited).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 text-[var(--r-sub)]">{p.shareToken ? '🔗' : '—'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<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
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -194,11 +175,11 @@ export default function AdminPage() {
|
||||
|
||||
{adminViewProject && (
|
||||
<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="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-slate-700">{adminViewProject.title}</p>
|
||||
<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-[var(--r-line)] px-4 py-3">
|
||||
<p className="text-sm font-medium text-[var(--r-text)]">{adminViewProject.title}</p>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -214,22 +195,22 @@ export default function AdminPage() {
|
||||
|
||||
{adminResetResult && (
|
||||
<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">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Password reset</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Relay this to <span className="font-medium text-slate-700">{adminResetResult.email}</span> yourself
|
||||
<div className="w-full max-w-sm rounded-2xl bg-[var(--r-bar)] p-6 shadow-2xl">
|
||||
<h3 className="text-sm font-medium text-[var(--r-text)]">Password reset</h3>
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">
|
||||
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.
|
||||
</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}
|
||||
</p>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
+38
-36
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { themeVars } from '../components/reader/readerThemes.js';
|
||||
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
||||
|
||||
export default function AuthPage() {
|
||||
const { signIn } = useApp();
|
||||
const { signIn, readerTheme } = useApp();
|
||||
const shell = { style: themeVars(readerTheme) };
|
||||
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
|
||||
const [authForm, setAuthForm] = useState({ email: '', password: '' });
|
||||
const [authError, setAuthError] = useState('');
|
||||
@@ -49,11 +51,11 @@ export default function AuthPage() {
|
||||
|
||||
if (authMfaPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
|
||||
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-semibold text-slate-900">Two-factor verification</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
<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-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
|
||||
<p className="mt-2 text-sm text-[var(--r-sub)]">
|
||||
{authMfaUseBackup
|
||||
? 'Enter one of your saved backup codes.'
|
||||
: 'Enter the 6-digit code from your authenticator app.'}
|
||||
@@ -67,13 +69,13 @@ export default function AuthPage() {
|
||||
placeholder={authMfaUseBackup ? 'xxxxxxxxxx' : '123456'}
|
||||
value={authMfaCode}
|
||||
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
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
@@ -85,7 +87,7 @@ export default function AuthPage() {
|
||||
setAuthMfaCode('');
|
||||
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"}
|
||||
</button>
|
||||
@@ -97,7 +99,7 @@ export default function AuthPage() {
|
||||
setAuthMfaCode('');
|
||||
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
|
||||
</button>
|
||||
@@ -107,41 +109,41 @@ export default function AuthPage() {
|
||||
}
|
||||
|
||||
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">
|
||||
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
|
||||
<div className="hidden lg:block">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold leading-tight text-white">
|
||||
Deep Bible study,<br />organized chunk by chunk.
|
||||
<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-[var(--r-text)]">
|
||||
Deep Bible study,<br />one chunk at a time.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-sm text-slate-300">
|
||||
Split any passage into chunks and work through Observation, Interpretation, and
|
||||
Application notes alongside Greek & Hebrew word studies, cross-references, and commentary.
|
||||
<p className="mt-4 max-w-sm text-[var(--r-sub)]">
|
||||
Read Scripture in six translations, follow a reading plan into the text, and study a passage one chunk
|
||||
at a time with commentary, word studies, and cross-references beside it.
|
||||
</p>
|
||||
<ul className="mt-8 space-y-4 text-sm text-slate-300">
|
||||
<li className="flex items-center gap-3"><span className="text-lg">📖</span> Chunk-by-chunk OIA notes on any passage</li>
|
||||
<li className="flex items-center gap-3"><span className="text-lg">🔤</span> Greek & Hebrew word studies with pronunciation</li>
|
||||
<li className="flex items-center gap-3"><span className="text-lg">🔗</span> Cross-references and commentary, one click away</li>
|
||||
<li className="flex items-center gap-3"><span className="text-lg">🎙</span> Turn your notes into a podcast-ready script</li>
|
||||
<li className="flex items-center gap-3"><span className="text-lg">🔒</span> Your studies stay private to your account, with optional 2FA</li>
|
||||
<ul className="mt-8 space-y-3 text-sm text-[var(--r-sub)]">
|
||||
<li>Read in BSB, ESV, NLT, CSB, NASB, or NET, with audio and search</li>
|
||||
<li>Reading plans that open straight into the Reader</li>
|
||||
<li>Observe, Interpret, Apply notes with Greek & Hebrew word studies</li>
|
||||
<li>Turn your notes into a script, then export or share</li>
|
||||
<li>Your studies stay private to your account, with optional 2FA</li>
|
||||
</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
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Sign in / register form */}
|
||||
<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">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400 lg:hidden">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-semibold text-slate-900">
|
||||
<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-[var(--r-sub)] lg:hidden">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">
|
||||
{authMode === 'login' ? 'Sign in' : 'Create an account'}
|
||||
</h1>
|
||||
<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
|
||||
<input
|
||||
type="email"
|
||||
@@ -149,10 +151,10 @@ export default function AuthPage() {
|
||||
autoComplete="email"
|
||||
value={authForm.email}
|
||||
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 className="block text-sm font-medium text-slate-700">
|
||||
<label className="block text-sm font-medium text-[var(--r-text)]">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
@@ -161,14 +163,14 @@ export default function AuthPage() {
|
||||
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
|
||||
value={authForm.password}
|
||||
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>
|
||||
{authError && <p className="text-sm text-rose-600">{authError}</p>}
|
||||
{authError && <p className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
@@ -179,7 +181,7 @@ export default function AuthPage() {
|
||||
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
|
||||
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'}
|
||||
</button>
|
||||
|
||||
@@ -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)]" />
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<button type="button" onClick={share.enable} disabled={share.shareBusy} className={btn}>{share.shareBusy ? 'Enabling…' : 'Enable sharing'}</button>
|
||||
|
||||
@@ -29,7 +29,6 @@ export default function HomePage() {
|
||||
audioNarrator, setAudioNarrator,
|
||||
audioState,
|
||||
audioProgress,
|
||||
openBibleReader,
|
||||
openImportProject,
|
||||
openNewProject,
|
||||
pullLatestFromServer,
|
||||
@@ -110,10 +109,9 @@ export default function HomePage() {
|
||||
return (
|
||||
<PageShell
|
||||
title="My Studies"
|
||||
active="studies"
|
||||
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={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' && (
|
||||
<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>
|
||||
<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>
|
||||
<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-[var(--r-danger,#b42318)] hover:bg-red-50">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -227,7 +225,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
<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={() => 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>
|
||||
))}
|
||||
|
||||
@@ -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)]';
|
||||
|
||||
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">
|
||||
<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)]">
|
||||
@@ -187,7 +187,7 @@ export default function ImportPage() {
|
||||
</label>
|
||||
|
||||
{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 && (
|
||||
<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 text-right text-[var(--r-sub)]">
|
||||
{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>}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -113,11 +113,11 @@ export default function ReadingPlansPage() {
|
||||
<main className="mx-auto max-w-3xl px-4 pt-5 font-sans">
|
||||
{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">
|
||||
<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.
|
||||
{readingPlanSyncError && <span className="block text-xs">{readingPlanSyncError}</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>
|
||||
)}
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function ReadingPlansPage() {
|
||||
</div>
|
||||
<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={() => { 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>
|
||||
);
|
||||
|
||||
+72
-92
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import AuthStatus from '../components/AuthStatus.jsx';
|
||||
import PageShell from '../components/study/PageShell.jsx';
|
||||
import {
|
||||
startMfaSetup,
|
||||
confirmMfaSetup,
|
||||
@@ -160,52 +160,32 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<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">Account Settings</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-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>
|
||||
<PageShell title="Account Settings" onBack={goHome} maxWidth="max-w-2xl">
|
||||
<main className="mx-auto max-w-2xl space-y-5 px-4 py-6">
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-2">
|
||||
<h2 className="text-base font-medium text-[var(--r-text)]">Account</h2>
|
||||
<p className="text-sm text-[var(--r-sub)]">
|
||||
Signed in as <span className="font-medium text-[var(--r-text)]">{authUser.email}</span>
|
||||
</p>
|
||||
</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>
|
||||
<h2 className="text-lg font-semibold text-slate-900">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>
|
||||
<h2 className="text-base font-medium text-[var(--r-text)]">Change password</h2>
|
||||
<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>
|
||||
<form onSubmit={submitChangePassword} className="space-y-3">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Current password
|
||||
<label className="block text-sm">Current password
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={changePasswordForm.current}
|
||||
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 className="block text-sm font-medium text-slate-700">
|
||||
<label className="block text-sm font-medium text-[var(--r-text)]">
|
||||
New password
|
||||
<input
|
||||
type="password"
|
||||
@@ -214,10 +194,10 @@ export default function SettingsPage() {
|
||||
autoComplete="new-password"
|
||||
value={changePasswordForm.next}
|
||||
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 className="block text-sm font-medium text-slate-700">
|
||||
<label className="block text-sm font-medium text-[var(--r-text)]">
|
||||
Confirm new password
|
||||
<input
|
||||
type="password"
|
||||
@@ -226,26 +206,26 @@ export default function SettingsPage() {
|
||||
autoComplete="new-password"
|
||||
value={changePasswordForm.confirm}
|
||||
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>
|
||||
{changePasswordError && <p className="text-sm text-rose-600">{changePasswordError}</p>}
|
||||
{changePasswordSaved && <p className="text-sm text-emerald-600">Password changed.</p>}
|
||||
{changePasswordError && <p className="text-sm text-[var(--r-danger,#b42318)]">{changePasswordError}</p>}
|
||||
{changePasswordSaved && <p className="text-sm text-[var(--r-accent)]">Password changed.</p>}
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Podcast / show name</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Only needed if you use "🎙 Prepare for Podcast" on the study page — it fills in your show's
|
||||
<h2 className="text-base font-medium text-[var(--r-text)]">Podcast / show name</h2>
|
||||
<p className="text-sm text-[var(--r-sub)]">
|
||||
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
|
||||
study; that button still works, it just won't name a specific show.
|
||||
</p>
|
||||
@@ -256,23 +236,23 @@ export default function SettingsPage() {
|
||||
value={podcastNameInput}
|
||||
onChange={(e) => setPodcastNameInput(e.target.value)}
|
||||
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
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
</form>
|
||||
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
|
||||
{podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
|
||||
</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>
|
||||
<h2 className="text-lg font-semibold text-slate-900">My reading journal</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
<h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2>
|
||||
<p className="text-sm text-[var(--r-sub)]">
|
||||
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
|
||||
what's saved here.
|
||||
@@ -283,7 +263,7 @@ export default function SettingsPage() {
|
||||
type="button"
|
||||
onClick={downloadJournalDocx}
|
||||
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)'}
|
||||
</button>
|
||||
@@ -291,83 +271,83 @@ export default function SettingsPage() {
|
||||
type="button"
|
||||
onClick={printJournal}
|
||||
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'}
|
||||
</button>
|
||||
</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 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>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Reading reminders</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
<h2 className="text-base font-medium text-[var(--r-text)]">Reading reminders</h2>
|
||||
<p className="text-sm text-[var(--r-sub)]">
|
||||
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{' '}
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={reminderForm.reminderEnabled}
|
||||
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
|
||||
</label>
|
||||
<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
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. nate-bible-reading-8f2"
|
||||
value={reminderForm.ntfyTopic}
|
||||
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 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
|
||||
<input
|
||||
type="time"
|
||||
value={reminderForm.reminderTime}
|
||||
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>
|
||||
</div>
|
||||
<label className="block max-w-xs text-sm font-medium text-slate-700">
|
||||
ntfy server <span className="text-slate-400">(optional — defaults to ntfy.sh)</span>
|
||||
<label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
|
||||
ntfy server <span className="text-[var(--r-sub)]">(optional — defaults to ntfy.sh)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://ntfy.sh"
|
||||
value={reminderForm.ntfyUrl}
|
||||
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>
|
||||
{reminderError && <p className="text-sm text-rose-600">{reminderError}</p>}
|
||||
{reminderSaved && <p className="text-sm text-emerald-600">Saved.</p>}
|
||||
{reminderError && <p className="text-sm text-[var(--r-danger,#b42318)]">{reminderError}</p>}
|
||||
{reminderSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
<h2 className="text-base font-medium text-[var(--r-text)]">Two-factor authentication</h2>
|
||||
<p className="text-sm text-[var(--r-sub)]">
|
||||
{authUser.totpEnabled
|
||||
? "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.'}
|
||||
@@ -375,39 +355,39 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{mfaBackupCodes ? (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 space-y-3">
|
||||
<p className="text-sm font-semibold text-amber-800">
|
||||
<div className="rounded-2xl border border-[var(--r-accent)] bg-[var(--r-bg)] p-4 space-y-3">
|
||||
<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.
|
||||
</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) => (
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
) : authUser.totpEnabled ? (
|
||||
<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
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={mfaDisablePassword}
|
||||
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>
|
||||
{mfaDisableError && <p className="text-sm text-rose-600">{mfaDisableError}</p>}
|
||||
{mfaDisableError && <p className="text-sm text-[var(--r-danger,#b42318)]">{mfaDisableError}</p>}
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -415,13 +395,13 @@ export default function SettingsPage() {
|
||||
) : mfaSetup ? (
|
||||
<form onSubmit={confirmSetup} className="space-y-4">
|
||||
<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" />
|
||||
<div className="space-y-1 text-sm text-slate-600">
|
||||
<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-[var(--r-sub)]">
|
||||
<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>
|
||||
<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
|
||||
<input
|
||||
type="text"
|
||||
@@ -430,22 +410,22 @@ export default function SettingsPage() {
|
||||
placeholder="123456"
|
||||
value={mfaSetupCode}
|
||||
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>
|
||||
{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">
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
@@ -457,15 +437,15 @@ export default function SettingsPage() {
|
||||
type="button"
|
||||
onClick={startSetup}
|
||||
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'}
|
||||
</button>
|
||||
{mfaSetupError && <p className="text-sm text-rose-600">{mfaSetupError}</p>}
|
||||
{mfaSetupError && <p className="text-sm text-[var(--r-danger,#b42318)]">{mfaSetupError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, wi
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,7 +180,7 @@ export default function SetupPage() {
|
||||
<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 === 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>
|
||||
))}
|
||||
|
||||
+18
-9
@@ -78,7 +78,7 @@ export default function StudyPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<OutlineRail project={project} selectedId={selectedChunk?.id} onSelect={select} onEditOutline={() => setCurrentPage('setup')} />
|
||||
</aside>
|
||||
@@ -107,21 +107,30 @@ export default function StudyPage() {
|
||||
interlinear={resources.interlinearData} interlinearLoading={resources.interlinearLoading}
|
||||
addedStrongs={addedStrongs} onPickWord={pickWord}
|
||||
/>
|
||||
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="p-8 text-center text-sm text-[var(--r-sub)]">Add a chunk in the Outline stage to start studying.</p>
|
||||
)}
|
||||
</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 && (
|
||||
<ResourcePane
|
||||
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
|
||||
updateChunk={updateChunk} notify={notify}
|
||||
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
|
||||
openWordId={openWordId} setOpenWordId={setOpenWordId}
|
||||
/>
|
||||
<>
|
||||
<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
|
||||
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
|
||||
updateChunk={updateChunk} notify={notify}
|
||||
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
|
||||
openWordId={openWordId} setOpenWordId={setOpenWordId}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user