Build and Push Docker Image / build-and-push (push) Successful in 5m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
224 lines
15 KiB
React
224 lines
15 KiB
React
// 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, useRef, useState } from 'react';
|
||
import { useApp } from '../context/AppContext.js';
|
||
import PageShell from '../components/study/PageShell.jsx';
|
||
import { useToast } from '../components/Toasts.jsx';
|
||
import { STATUSES, filterPrayers, daysToAnswer, labelFor, live, parsePrayerImport, parseVerses, pinned as pinnedList, prayersToJson, 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, pinPos }) {
|
||
const [mode, setMode] = useState(null); // null | 'edit' | 'answer' | 'partial'
|
||
const [text, setText] = useState('');
|
||
const [note, setNote] = useState('');
|
||
const prayedToday = prayer.prayedOn.includes(today);
|
||
const isPinned = !!pinPos;
|
||
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 || isPinned || prayer.status) && (
|
||
<p className="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
|
||
{isPinned && <span className="text-[var(--r-accent)]">Pinned</span>}
|
||
{prayer.status && <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 normal-case tracking-normal">{STATUSES[prayer.status]}</span>}
|
||
{prayer.forWhom && <span>For {prayer.forWhom}</span>}
|
||
</p>
|
||
)}
|
||
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
|
||
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
|
||
</div>
|
||
)}
|
||
{prayer.status === 'partial' && prayer.answerNote && mode !== 'partial' && (
|
||
<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)]">Partly answered</p>
|
||
<p className="mt-0.5 whitespace-pre-wrap">{prayer.answerNote}</p>
|
||
</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>
|
||
)}
|
||
{mode === 'partial' && (
|
||
<div className="mt-3">
|
||
<textarea aria-label="What has been answered so far?" autoFocus value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={1000} placeholder="What has been answered so far? (optional)" className={`${field} w-full`} />
|
||
<div className="mt-2 flex gap-3 text-xs">
|
||
<button type="button" onClick={() => { actions.status(prayer.id, 'partial', note); 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 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 flex-wrap gap-2">
|
||
{!prayer.answeredAt && isPinned && (
|
||
<>
|
||
<button type="button" onClick={() => actions.move(prayer.id, -1)} disabled={pinPos.index === 0} aria-label="Move up" title="Move up" className={`${small} disabled:opacity-40`}>↑</button>
|
||
<button type="button" onClick={() => actions.move(prayer.id, 1)} disabled={pinPos.index === pinPos.count - 1} aria-label="Move down" title="Move down" className={`${small} disabled:opacity-40`}>↓</button>
|
||
</>
|
||
)}
|
||
{!prayer.answeredAt && <button type="button" onClick={() => actions.pin(prayer.id, !isPinned)} aria-pressed={isPinned} className={`${small} ${isPinned ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>{isPinned ? 'Unpin' : 'Pin'}</button>}
|
||
{!prayer.answeredAt && (
|
||
<>
|
||
<button type="button" onClick={() => actions.status(prayer.id, prayer.status === 'waiting' ? '' : 'waiting')} aria-pressed={prayer.status === 'waiting'} className={`${small} ${prayer.status === 'waiting' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Waiting</button>
|
||
<button type="button" onClick={() => { setNote(prayer.status === 'partial' ? prayer.answerNote : ''); setMode('partial'); }} aria-pressed={prayer.status === 'partial'} className={`${small} ${prayer.status === 'partial' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Partly answered</button>
|
||
</>
|
||
)}
|
||
{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, pinPrayer, movePrayer, setPrayerStatus, importPrayers, 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();
|
||
const fileRef = useRef(null);
|
||
|
||
// "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, pin: pinPrayer, move: movePrayer, status: setPrayerStatus,
|
||
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 pinOrder = useMemo(() => pinnedList(prayers).map((p) => p.id), [prayers]);
|
||
const download = (json = false) => {
|
||
const url = URL.createObjectURL(new Blob([json ? prayersToJson(prayers) : prayersToMarkdown(prayers)], { type: json ? 'application/json' : 'text/markdown;charset=utf-8' }));
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = json ? 'prayer-journal.json' : 'prayer-journal.md';
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
};
|
||
|
||
const onImportFile = async (e) => {
|
||
const file = e.target.files?.[0];
|
||
e.target.value = '';
|
||
if (!file) return;
|
||
let result;
|
||
try { result = parsePrayerImport(await file.text(), prayers); } catch { result = { prayers: [], skipped: 0, error: 'Couldn’t read that file.' }; }
|
||
if (result.error) { toast(result.error); return; }
|
||
if (result.prayers.length) importPrayers(result.prayers);
|
||
toast(`Imported ${result.prayers.length} prayer${result.prayers.length === 1 ? '' : 's'}${result.skipped ? ` (${result.skipped} already here or skipped)` : ''}`);
|
||
};
|
||
|
||
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`} />
|
||
<button type="button" onClick={() => fileRef.current?.click()} className={small}>Import</button>
|
||
<input ref={fileRef} type="file" accept=".json,.md,.txt,text/markdown,application/json,text/plain" onChange={onImportFile} className="hidden" aria-label="Import prayers file" />
|
||
{live(prayers).length > 0 && (
|
||
<>
|
||
<button type="button" onClick={() => download(false)} className={small}>Download</button>
|
||
<button type="button" onClick={() => download(true)} className={small} title="Keeps every detail, including the days you prayed">Download JSON</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} pinPos={pinOrder.includes(p.id) ? { index: pinOrder.indexOf(p.id), count: pinOrder.length } : null} />)}
|
||
</ul>
|
||
)}
|
||
</main>
|
||
</PageShell>
|
||
);
|
||
}
|