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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user