Version history, notes on highlights and bookmarks, "you've studied this", prayer journal
Build and Push Docker Image / build-and-push (push) Successful in 5m13s
Build and Push Docker Image / build-and-push (push) Successful in 5m13s
- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept) - Notes on highlights and bookmarks: synced, merged without loss, in exports and backups - Reader verse panel lists your studies and chunks that cover the verse, and opens them - Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups and the account export; daily prayer reminder through ntfy - TODO.md lists the ideas not built yet Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54cb1c217d
commit
207bfcb919
45 files changed
+1669
-75
No files matched your search
@@ -3,6 +3,7 @@ import { useApp } from '../context/AppContext.js';
|
||||
import { useShareLink } from '../hooks/useShareLink.js';
|
||||
import HandoutExport from '../components/HandoutExport.jsx';
|
||||
import SessionSchedule from '../components/SessionSchedule.jsx';
|
||||
import VersionHistory from '../components/VersionHistory.jsx';
|
||||
import CoTeachers from '../components/CoTeachers.jsx';
|
||||
import LinkRules from '../components/LinkRules.jsx';
|
||||
import StudioShell from '../components/study/StudioShell.jsx';
|
||||
@@ -21,7 +22,7 @@ function Row({ title, note, button, onClick }) {
|
||||
}
|
||||
|
||||
export default function ExportPage() {
|
||||
const { project, allChunks, setCurrentPage, sharedStudies, deleteProject } = useApp();
|
||||
const { project, allChunks, setCurrentPage, sharedStudies, deleteProject, restoreEarlierVersion } = useApp();
|
||||
const share = useShareLink(project?.id);
|
||||
const filled = allChunks.filter((c) => (c.finalScript ?? '').trim()).length;
|
||||
|
||||
@@ -66,6 +67,8 @@ export default function ExportPage() {
|
||||
|
||||
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
|
||||
|
||||
<VersionHistory project={project} onRestore={restoreEarlierVersion} />
|
||||
|
||||
<section className={card} aria-label="Pronunciation">
|
||||
<Row title="Pronunciation guide" note="Every Greek and Hebrew word in this study, with transliterations." button="Open guide" onClick={() => setCurrentPage('pronunciation')} />
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// The prayer journal: what you are praying about (and for whom), the verses tied to each request, the days you
|
||||
// prayed, and the answers when they come.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import PageShell from '../components/study/PageShell.jsx';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
import { filterPrayers, daysToAnswer, labelFor, live, parseVerses, prayersToMarkdown } from '../utils/prayers.js';
|
||||
import { localDateString } from '../utils/readingPlans.js';
|
||||
|
||||
const field = '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 small = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1 text-xs hover:bg-black/5';
|
||||
const tabBtn = (on) => `rounded-full border px-3 py-1 text-xs font-medium ${on ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||
|
||||
function VerseChips({ verses, onOpen }) {
|
||||
if (!verses?.length) return null;
|
||||
return (
|
||||
<span className="mt-1 flex flex-wrap gap-1.5">
|
||||
{verses.map((v, i) => (
|
||||
<button key={`${v.book}${v.chapter}${v.verse}${i}`} type="button" onClick={() => onOpen(v.book, v.chapter, v.verse ?? 1)}
|
||||
className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-accent)] hover:bg-black/5" title="Read it in the Bible">{labelFor(v)}</button>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
||||
const [mode, setMode] = useState(null); // null | 'edit' | 'answer'
|
||||
const [text, setText] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const prayedToday = prayer.prayedOn.includes(today);
|
||||
const days = daysToAnswer(prayer);
|
||||
return (
|
||||
<li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
|
||||
{mode === 'edit' ? (
|
||||
<div>
|
||||
<textarea aria-label="Edit prayer" value={text} onChange={(e) => setText(e.target.value)} rows={3} maxLength={2000} className={`${field} w-full`} />
|
||||
<div className="mt-2 flex gap-3 text-xs">
|
||||
<button type="button" onClick={() => { if (text.trim()) actions.edit(prayer.id, { text: text.trim() }); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Save</button>
|
||||
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{prayer.forWhom && <p className="text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">For {prayer.forWhom}</p>}
|
||||
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
|
||||
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
|
||||
</div>
|
||||
)}
|
||||
{prayer.answeredAt && (
|
||||
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
|
||||
<p className="text-xs font-medium text-[var(--r-accent)]">Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}</p>
|
||||
{prayer.answerNote && <p className="mt-0.5 whitespace-pre-wrap">{prayer.answerNote}</p>}
|
||||
</div>
|
||||
)}
|
||||
{mode === 'answer' && (
|
||||
<div className="mt-3">
|
||||
<textarea aria-label="How was it answered?" autoFocus value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={1000} placeholder="How was it answered? (optional)" className={`${field} w-full`} />
|
||||
<div className="mt-2 flex gap-3 text-xs">
|
||||
<button type="button" onClick={() => { actions.answer(prayer.id, note); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Mark answered</button>
|
||||
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{!prayer.answeredAt && (
|
||||
<button type="button" onClick={() => actions.toggle(prayer.id)} aria-pressed={prayedToday} className={`${small} ${prayedToday ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>
|
||||
{prayedToday ? '✓ Prayed today' : 'I prayed'}
|
||||
</button>
|
||||
)}
|
||||
{prayer.prayedOn.length > 0 && <span className="text-xs text-[var(--r-sub)]">Prayed on {prayer.prayedOn.length} day{prayer.prayedOn.length === 1 ? '' : 's'}</span>}
|
||||
<span className="ml-auto flex gap-2">
|
||||
{prayer.answeredAt
|
||||
? <button type="button" onClick={() => actions.reopen(prayer.id)} className={small}>Pray again</button>
|
||||
: <button type="button" onClick={() => { setNote(''); setMode('answer'); }} className={small}>Answered</button>}
|
||||
<button type="button" onClick={() => { setText(prayer.text); setMode('edit'); }} className={small}>Edit</button>
|
||||
<button type="button" onClick={() => actions.remove(prayer.id)} className={`${small} text-[var(--r-danger,#b42318)]`}>Delete</button>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PrayerPage() {
|
||||
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState('open');
|
||||
const [query, setQuery] = useState('');
|
||||
const [form, setForm] = useState({ text: '', forWhom: '', verses: '' });
|
||||
const today = localDateString();
|
||||
|
||||
// "Pray about this verse" from the Reader arrives with the verse filled in
|
||||
useEffect(() => {
|
||||
if (prayerDraft) { setForm((f) => ({ ...f, verses: prayerDraft.label })); clearPrayerDraft(); }
|
||||
}, [prayerDraft, clearPrayerDraft]);
|
||||
|
||||
const parsed = useMemo(() => parseVerses(form.verses), [form.verses]);
|
||||
const shown = filterPrayers(prayers, { tab, query });
|
||||
const counts = { open: filterPrayers(prayers, { tab: 'open' }).length, answered: filterPrayers(prayers, { tab: 'answered' }).length };
|
||||
|
||||
const add = (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.text.trim()) return;
|
||||
addPrayer({ text: form.text, forWhom: form.forWhom, verses: parsed.verses });
|
||||
setForm({ text: '', forWhom: '', verses: '' });
|
||||
setTab('open');
|
||||
};
|
||||
const actions = {
|
||||
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer,
|
||||
answer: (id, note) => { answerPrayer(id, note); toast('Marked answered. Thank God for it.'); },
|
||||
remove: (id) => { const undo = deletePrayer(id); toast('Prayer deleted', { action: { label: 'Undo', onClick: undo } }); },
|
||||
};
|
||||
const openVerse = (book, chapter, verse) => { jumpToReaderVerse(book, chapter, verse); openBibleReader(); };
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(new Blob([prayersToMarkdown(prayers)], { type: 'text/markdown;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'prayer-journal.md';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl">
|
||||
<main className="mx-auto max-w-3xl space-y-5 px-4 py-6">
|
||||
<form onSubmit={add} className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4" aria-label="Add a prayer">
|
||||
<label className="block text-sm font-medium">What are you praying about?
|
||||
<textarea value={form.text} onChange={(e) => setForm((f) => ({ ...f, text: e.target.value }))} rows={3} maxLength={2000} className={`${field} mt-1 w-full`} placeholder="A request, a thanks, someone to keep in prayer…" />
|
||||
</label>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<label className="block text-xs text-[var(--r-sub)]">For (optional)
|
||||
<input type="text" value={form.forWhom} onChange={(e) => setForm((f) => ({ ...f, forWhom: e.target.value }))} maxLength={80} placeholder="e.g. Sam, my church" className={`${field} mt-1 w-full`} />
|
||||
</label>
|
||||
<label className="block text-xs text-[var(--r-sub)]">Verses (optional)
|
||||
<input type="text" value={form.verses} onChange={(e) => setForm((f) => ({ ...f, verses: e.target.value }))} placeholder="e.g. Philippians 4:6, Psalm 23" className={`${field} mt-1 w-full`} />
|
||||
</label>
|
||||
</div>
|
||||
{(parsed.verses.length > 0 || parsed.unknown.length > 0) && (
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">
|
||||
{parsed.verses.length > 0 && <>Linked: {parsed.verses.map(labelFor).join(' · ')}. </>}
|
||||
{parsed.unknown.length > 0 && <span className="text-[var(--r-danger,#b42318)]">Couldn’t read: {parsed.unknown.join(', ')}.</span>}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" disabled={!form.text.trim()} className="mt-3 rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">Add to my journal</button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div role="tablist" aria-label="Which prayers" className="flex gap-1.5">
|
||||
<button type="button" role="tab" aria-selected={tab === 'open'} onClick={() => setTab('open')} className={tabBtn(tab === 'open')}>Praying ({counts.open})</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'answered'} onClick={() => setTab('answered')} className={tabBtn(tab === 'answered')}>Answered ({counts.answered})</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')} className={tabBtn(tab === 'all')}>All</button>
|
||||
</div>
|
||||
<input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search prayers…" aria-label="Search prayers" className={`${field} ml-auto w-48 py-1.5`} />
|
||||
{live(prayers).length > 0 && <button type="button" onClick={download} className={small}>Download</button>}
|
||||
</div>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-[var(--r-line)] p-8 text-center text-sm text-[var(--r-sub)]">
|
||||
{live(prayers).length === 0 ? 'Nothing here yet. Write down something you’re praying about, and come back to mark how God answers.'
|
||||
: query ? 'No prayers match that search.' : tab === 'answered' ? 'No answered prayers yet.' : 'Nothing open right now.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3" aria-label="Prayers">
|
||||
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} />)}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
import { AppContext } from '../context/AppContext.js';
|
||||
import { ToastProvider } from '../components/Toasts.jsx';
|
||||
import PrayerPage from './PrayerPage.jsx';
|
||||
import { markAnswered, newPrayer } from '../utils/prayers.js';
|
||||
|
||||
vi.mock('../components/study/PageShell.jsx', () => ({ default: ({ children }) => <div>{children}</div> }));
|
||||
|
||||
const T0 = Date.UTC(2026, 9, 1);
|
||||
function setup(initial = [], extra = {}) {
|
||||
const ctx = {
|
||||
prayers: initial, addPrayer: vi.fn(), editPrayer: vi.fn(), togglePrayed: vi.fn(), answerPrayer: vi.fn(), reopenPrayer: vi.fn(), deletePrayer: vi.fn(() => vi.fn()),
|
||||
prayerDraft: null, clearPrayerDraft: vi.fn(), jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), ...extra,
|
||||
};
|
||||
render(<AppContext.Provider value={ctx}><ToastProvider><PrayerPage /></ToastProvider></AppContext.Provider>);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test('a prayer is added with who it is for and the verses it is tied to', async () => {
|
||||
const user = userEvent.setup();
|
||||
const ctx = setup();
|
||||
const form = screen.getByRole('form', { name: /add a prayer/i });
|
||||
await user.type(within(form).getByLabelText(/what are you praying about/i), 'Peace at home');
|
||||
await user.type(within(form).getByLabelText(/for \(optional\)/i), 'Family');
|
||||
await user.type(within(form).getByLabelText(/verses/i), 'Philippians 4:6, nonsense words');
|
||||
expect(within(form).getByText(/Linked: Philippians 4:6/)).toBeInTheDocument();
|
||||
expect(within(form).getByText(/Couldn’t read: nonsense words/)).toBeInTheDocument();
|
||||
await user.click(within(form).getByRole('button', { name: /add to my journal/i }));
|
||||
expect(ctx.addPrayer).toHaveBeenCalledWith({ text: 'Peace at home', forWhom: 'Family', verses: [expect.objectContaining({ book: 'PHP', chapter: 4, verse: 6 })] });
|
||||
});
|
||||
|
||||
test('praying and answered lists are separate, with counts; a verse chip opens the Bible', async () => {
|
||||
const open = { ...newPrayer({ text: 'Wisdom at work', verses: [{ book: 'JAS', chapter: 1, verse: 5, label: 'James 1:5' }] }, T0) };
|
||||
const done = markAnswered(newPrayer({ text: 'A new job' }, T0), 'Hired!', T0 + 3 * 86_400_000);
|
||||
const user = userEvent.setup();
|
||||
const ctx = setup([open, done]);
|
||||
expect(screen.getByRole('tab', { name: 'Praying (1)' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Wisdom at work')).toBeInTheDocument();
|
||||
expect(screen.queryByText('A new job')).toBeNull();
|
||||
await user.click(screen.getByRole('button', { name: 'James 1:5' }));
|
||||
expect(ctx.jumpToReaderVerse).toHaveBeenCalledWith('JAS', 1, 5);
|
||||
expect(ctx.openBibleReader).toHaveBeenCalled();
|
||||
await user.click(screen.getByRole('tab', { name: 'Answered (1)' }));
|
||||
expect(screen.getByText('A new job')).toBeInTheDocument();
|
||||
expect(screen.getByText(/after 3 days/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Hired!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a prayer can be marked answered with a note, prayed for today, and deleted with undo', async () => {
|
||||
const p = newPrayer({ text: 'Healing' }, T0);
|
||||
const user = userEvent.setup();
|
||||
const ctx = setup([p]);
|
||||
await user.click(screen.getByRole('button', { name: 'I prayed' }));
|
||||
expect(ctx.togglePrayed).toHaveBeenCalledWith(p.id);
|
||||
await user.click(screen.getByRole('button', { name: 'Answered' }));
|
||||
await user.type(screen.getByLabelText('How was it answered?'), 'Better each day');
|
||||
await user.click(screen.getByRole('button', { name: 'Mark answered' }));
|
||||
expect(ctx.answerPrayer).toHaveBeenCalledWith(p.id, 'Better each day');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
expect(ctx.deletePrayer).toHaveBeenCalledWith(p.id);
|
||||
expect(await screen.findByRole('button', { name: 'Undo' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a verse sent from the Reader fills in the verse box; an empty journal explains itself', () => {
|
||||
const ctx = setup([], { prayerDraft: { label: 'John 3:16' } });
|
||||
expect(screen.getByLabelText(/verses/i)).toHaveValue('John 3:16');
|
||||
expect(ctx.clearPrayerDraft).toHaveBeenCalled();
|
||||
expect(screen.getByText(/Nothing here yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('search narrows the list', async () => {
|
||||
const a = newPrayer({ text: 'Rain for the farm' }, T0);
|
||||
const b = newPrayer({ text: 'Peace at home' }, T0 + 5);
|
||||
setup([a, b]);
|
||||
await userEvent.setup().type(screen.getByLabelText('Search prayers'), 'rain');
|
||||
expect(screen.getByText('Rain for the farm')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Peace at home')).toBeNull();
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
// The reading side of the app: a distraction-free page with slim chrome. Every
|
||||
// tool (navigation, search, settings, audio, verse study) lives in a sheet.
|
||||
import { buildVerseIndex, studiesForVerse } from '../utils/studyVerseIndex.js';
|
||||
import { loadProjectById } from '../utils/projectStorage.js';
|
||||
import { placeHighlights, isWholeVerse } from '../utils/highlightSpans.js';
|
||||
import { useState, useEffect, useRef, useMemo, useDeferredValue, useCallback } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
@@ -78,7 +80,8 @@ export default function ReaderPage() {
|
||||
handleSeekAudioTo,
|
||||
readerInkByPage,
|
||||
updateReaderPageInk,
|
||||
readerTextHighlights,
|
||||
readerTextHighlights, readerBookmarkNotes, setBookmarkNote, setHighlightNote,
|
||||
projectIndex, openStudyChunk, startPrayerFor,
|
||||
addTextHighlight,
|
||||
removeTextHighlight,
|
||||
restoreTextHighlight,
|
||||
@@ -428,6 +431,10 @@ export default function ReaderPage() {
|
||||
|
||||
const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null;
|
||||
const verseHighlights = selectedVerse ? (readerTextHighlights || []).filter((h) => h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === selectedVerse.number) : [];
|
||||
// Your studies that cover the selected verse (looked up only while a verse is selected, and again when your studies change)
|
||||
const studyIndexKey = (projectIndex ?? []).map((e) => `${e.id}:${e.lastEdited}`).join('|');
|
||||
const verseIndex = useMemo(() => (selectedVerse ? buildVerseIndex(projectIndex ?? [], loadProjectById) : []), [selectedVerse != null, studyIndexKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const studiesHere = selectedVerse ? studiesForVerse(verseIndex, readerBookAbbrev, readerChapter, selectedVerse.number) : [];
|
||||
const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null;
|
||||
const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`;
|
||||
const audio = {
|
||||
@@ -568,6 +575,8 @@ export default function ReaderPage() {
|
||||
crossRefsLoading={readerCrossRefsLoading}
|
||||
onLoadCrossRefs={() => { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
|
||||
bookmarkColor={readerBookmarks[selectedKey]}
|
||||
studiesHere={studiesHere} onOpenStudy={openStudyChunk} onPray={startPrayerFor}
|
||||
bookmarkNote={readerBookmarkNotes?.[selectedKey] ?? ''} onSetBookmarkNote={(text) => setBookmarkNote(selectedKey, text)} onSetHighlightNote={setHighlightNote}
|
||||
onToggleBookmark={() => (readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey))}
|
||||
onCycleColor={() => cycleBookmarkColor(selectedKey)}
|
||||
onJump={jump}
|
||||
@@ -640,6 +649,7 @@ export default function ReaderPage() {
|
||||
onJump={jump}
|
||||
onRemoveBookmark={(e) => removeBookmark(e.key, e.color)}
|
||||
onRemoveHighlight={(h) => removeHighlight(h.id)}
|
||||
bookmarkNotes={readerBookmarkNotes ?? {}} onSetHighlightNote={setHighlightNote} onSetBookmarkNote={setBookmarkNote}
|
||||
/>
|
||||
<AudioSheet
|
||||
open={sheet === 'audio'} onClose={closeSheet}
|
||||
|
||||
@@ -87,6 +87,8 @@ export default function SettingsPage() {
|
||||
reminderTime: authUser?.reminderTime ?? '07:00',
|
||||
votdEnabled: authUser?.votdEnabled ?? false,
|
||||
votdTime: authUser?.votdTime ?? '06:30',
|
||||
prayerEnabled: authUser?.prayerEnabled ?? false,
|
||||
prayerTime: authUser?.prayerTime ?? '20:00',
|
||||
});
|
||||
const [reminderBusy, setReminderBusy] = useState(false);
|
||||
const [reminderError, setReminderError] = useState('');
|
||||
@@ -428,6 +430,18 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--r-line)] p-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
|
||||
<input type="checkbox" checked={reminderForm.prayerEnabled} onChange={(e) => setReminderForm((f) => ({ ...f, prayerEnabled: e.target.checked }))} className="h-4 w-4 accent-[var(--r-accent)]" />
|
||||
Daily prayer reminder
|
||||
</label>
|
||||
<label className="mt-2 block max-w-xs text-sm font-medium text-[var(--r-text)]">
|
||||
Remind me at
|
||||
<input type="time" value={reminderForm.prayerTime} onChange={(e) => setReminderForm((f) => ({ ...f, prayerTime: e.target.value }))}
|
||||
className="mt-1 block 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)]" />
|
||||
</label>
|
||||
<span className="mt-1 block text-xs text-[var(--r-sub)]">A notification to the same topic listing a few of the requests in your prayer journal. Nothing is sent when there are no open requests.</span>
|
||||
</div>
|
||||
{authUser?.votdAvailable && (
|
||||
<div className="rounded-xl border border-[var(--r-line)] p-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
|
||||
|
||||
Reference in new issue
Block a user