Reading plans: integrated into the Reader, redesigned, with per-plan translation
Build and Push Docker Image / build-and-push (push) Failing after 19s

Reader: plan strip with a dot per chapter, plan sheet (day picker, chapter
ticks, translation, streak, other plans), end-of-day card that steps through
the day and rolls into the next, and chapter-level auto-marking.
Each plan remembers its own translation (BSB, ESV, NLT, CSB, NASB, NET) and the
Reader follows it while a plan is open. Plans home redesigned in the reader's
themes with a Continue card, in-progress plans, a filterable catalog, and
sheets for details and the custom-plan builder.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 09:47:51 -04:00
1 parent c11b6a60bf
commit b1bc1c8ea4
17 files changed
+1205 -863

No files matched your search

+270
View File
@@ -0,0 +1,270 @@
// Form for creating or editing a custom reading plan (title, days, cover photo,
// books and chapter ranges). Themed with the reader's --r-* colour variables.
import { useRef, useState } from 'react';
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
import {
bookChapterCount, ALL_BOOKS, OT_BOOKS, NT_BOOKS, GOSPELS, PENTATEUCH, WISDOM_BOOKS,
} from '../../utils/readingPlans.js';
// Downscales a picked image file to a reasonable cover-photo size and returns
// a data URL — no server upload endpoint needed, it's stored right on the plan.
export function resizeImageToDataUrl(file, maxWidth = 960, quality = 0.82) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Could not read file.'));
reader.onload = () => {
const img = new Image();
img.onerror = () => reject(new Error('Could not decode image.'));
img.onload = () => {
const scale = Math.min(1, maxWidth / img.width);
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
const BOOK_PRESETS = [
['All', ALL_BOOKS],
['None', []],
['Old Testament', OT_BOOKS],
['New Testament', NT_BOOKS],
['Gospels', GOSPELS],
['Pentateuch', PENTATEUCH],
['Psalms', ['PSA']],
['Proverbs', ['PRO']],
['Wisdom', WISDOM_BOOKS],
];
export default function CustomPlanBuilder({ initialValues, onSubmit, onCancel }) {
const [title, setTitle] = useState(initialValues?.title ?? '');
const [description, setDescription] = useState(initialValues?.description ?? '');
const [days, setDays] = useState(initialValues?.days ?? 30);
const [books, setBooks] = useState(initialValues?.books ?? []);
const [chapterRanges, setChapterRanges] = useState(initialValues?.chapterRanges ?? {});
const [image, setImage] = useState(initialValues?.image ?? null);
const [imageError, setImageError] = useState('');
const fileInputRef = useRef(null);
const isEditing = !!initialValues;
const toggleBook = (abbrev) => {
const isOn = books.includes(abbrev);
setBooks((prev) => (isOn ? prev.filter((b) => b !== abbrev) : [...prev, abbrev]));
if (isOn) {
setChapterRanges((prev) => {
if (!(abbrev in prev)) return prev;
const next = { ...prev };
delete next[abbrev];
return next;
});
}
};
const setChapterRange = (abbrev, key, value) => {
const total = CHAPTER_COUNTS[abbrev] ?? 1;
const clamped = Math.min(total, Math.max(1, Math.round(value) || 1));
setChapterRanges((prev) => ({
...prev,
[abbrev]: { start: prev[abbrev]?.start ?? 1, end: prev[abbrev]?.end ?? total, [key]: clamped },
}));
};
const applyPreset = (list) => {
setBooks(list);
setChapterRanges({});
};
const pickImage = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
try {
setImageError('');
setImage(await resizeImageToDataUrl(file));
} catch {
setImageError("Couldn't use that image.");
}
};
const totalChapters = books.reduce((sum, b) => sum + bookChapterCount(b, chapterRanges), 0);
const canCreate = title.trim().length > 0 && totalChapters > 0 && days >= 1;
return (
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<h3 className="mb-3 text-sm font-semibold text-[var(--r-text)]">{isEditing ? 'Edit reading plan' : 'New reading plan'}</h3>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm text-[var(--r-text)]">
Title
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Advent Readings"
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
</label>
<label className="text-sm text-[var(--r-text)]">
Days
<input
type="number"
min="1"
max="365"
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
</label>
</div>
<label className="mt-3 block text-sm text-[var(--r-text)]">
Description <span className="text-[var(--r-sub)]">(optional)</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
</label>
<div className="mt-3">
<span className="text-sm text-[var(--r-text)]">Cover photo <span className="text-[var(--r-sub)]">(optional)</span></span>
<div className="mt-1 flex items-center gap-3">
{image ? (
<img src={image} alt="" className="h-16 w-28 rounded-lg object-cover" />
) : (
<div className="flex h-16 w-28 items-center justify-center rounded-lg bg-[var(--r-bg)] text-2xl">🖼️</div>
)}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-1.5 text-sm font-semibold text-[var(--r-text)] hover:bg-black/5"
>
{image ? 'Change photo' : 'Choose photo'}
</button>
{image && (
<button type="button" onClick={() => setImage(null)} className="text-sm text-[var(--r-sub)] hover:text-rose-500">
Remove
</button>
)}
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
</div>
{imageError && <p className="mt-1 text-xs text-rose-500">{imageError}</p>}
</div>
<div className="mt-4">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-[var(--r-text)]">Books</span>
{BOOK_PRESETS.map(([label, list]) => (
<button
key={label}
type="button"
onClick={() => applyPreset(list)}
className="rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-2.5 py-1 text-xs font-semibold text-[var(--r-text)] hover:bg-black/5"
>
{label}
</button>
))}
</div>
<p className="mb-2 text-xs text-[var(--r-sub)]">
Check a book to include the whole thing, or narrow it to a chapter range with the fields that appear.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="max-h-56 overflow-y-auto rounded-xl border border-[var(--r-line)] p-2">
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub)]">Old Testament</p>
{bookOptions.filter((b) => OT_BOOKS.includes(b.abbrev)).map((b) => (
<BookRow
key={b.abbrev}
book={b}
checked={books.includes(b.abbrev)}
range={chapterRanges[b.abbrev]}
onToggle={() => toggleBook(b.abbrev)}
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
/>
))}
</div>
<div className="max-h-56 overflow-y-auto rounded-xl border border-[var(--r-line)] p-2">
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub)]">New Testament</p>
{bookOptions.filter((b) => NT_BOOKS.includes(b.abbrev)).map((b) => (
<BookRow
key={b.abbrev}
book={b}
checked={books.includes(b.abbrev)}
range={chapterRanges[b.abbrev]}
onToggle={() => toggleBook(b.abbrev)}
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
/>
))}
</div>
</div>
<p className="mt-2 text-xs text-[var(--r-sub)]">
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / days).toFixed(1)} ch/day`}
</p>
{isEditing && (
books.join() !== initialValues.books.join()
|| Number(days) !== initialValues.days
|| JSON.stringify(chapterRanges) !== JSON.stringify(initialValues.chapterRanges ?? {})
) && (
<p className="mt-2 text-xs text-amber-600">
Changing the books, chapters, or day count restarts progress for any run of this plan already in your list.
</p>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
disabled={!canCreate}
onClick={() => onSubmit({ title, description, days, books, chapterRanges, image })}
className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-semibold text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{isEditing ? 'Save changes' : 'Create plan'}
</button>
<button type="button" onClick={onCancel} className="text-sm text-[var(--r-sub)] hover:text-[var(--r-text)]">Cancel</button>
</div>
</div>
);
}
// One book row in the custom-plan builder: a checkbox, and — once checked — a
// "ch. X–Y" chapter-range pair that defaults to the whole book.
function BookRow({ book, checked, range, onToggle, onRangeChange }) {
const total = CHAPTER_COUNTS[book.abbrev] ?? 1;
return (
<label className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-black/5">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="h-4 w-4 shrink-0 rounded border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] accent-[var(--r-accent)]"
/>
<span className="flex-1">{book.name}</span>
{checked && total > 1 && (
<span className="flex shrink-0 items-center gap-1 text-xs text-[var(--r-sub)]" onClick={(e) => e.preventDefault()}>
ch.
<input
type="number"
min={1}
max={total}
value={range?.start ?? 1}
onChange={(e) => onRangeChange('start', e.target.value)}
className="w-11 rounded border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-1 py-0.5 text-center"
/>
–
<input
type="number"
min={1}
max={total}
value={range?.end ?? total}
onChange={(e) => onRangeChange('end', e.target.value)}
className="w-11 rounded border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-1 py-0.5 text-center"
/>
<span className="text-[var(--r-sub)]">/{total}</span>
</span>
)}
</label>
);
}
+113
View File
@@ -0,0 +1,113 @@
// Detail for one plan. Two modes: an in-progress plan (days to tick or jump into,
// its translation, catch-up / restart / remove) and a catalog plan (preview of the
// first days, pick a translation, start it).
import { useRef, useState } from 'react';
import Sheet from '../reader/Sheet.jsx';
import TranslationSelect from './TranslationSelect.jsx';
import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx';
import { formatDayReadings, getPlanSchedule, daysBehindPace } from '../../utils/readingPlans.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
function ActiveBody({ plan, progress, onClose, actions }) {
const schedule = getPlanSchedule(plan.id);
const behind = daysBehindPace(plan, progress);
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
return (
<>
<div className="mb-3 flex items-center justify-between text-sm">
<span>{progress.completedDays.length} of {plan.days} days · {pct}%</span>
<span className="flex items-center gap-2">
<span className="text-xs text-[var(--r-sub)]">Read in</span>
<TranslationSelect value={progress.translation ?? actions.defaultTranslation} onChange={(t) => actions.setTranslation(progress.id, t)} />
</span>
</div>
<div className="mb-3 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)]" style={{ width: `${pct}%` }} /></div>
{behind >= 2 && (
<div className="mb-3 flex items-center justify-between rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
<span className="text-[var(--r-sub)]">{behind} days behind pace</span>
<button type="button" onClick={() => actions.recalibrate(progress.id)} className="font-medium text-[var(--r-accent)] hover:underline">Catch me up</button>
</div>
)}
<div className="max-h-[38vh] overflow-y-auto rounded-xl border border-[var(--r-line)]">
{schedule.map((refs, i) => {
const done = progress.completedDays.includes(i);
return (
<div key={i} className="flex items-center gap-3 border-b border-[var(--r-line)] px-3 py-2 last:border-0">
<input type="checkbox" checked={done} onChange={() => actions.toggleDay(progress.id, i, plan.days)} className="h-4 w-4 shrink-0 accent-[var(--r-accent)]" aria-label={`Day ${i + 1} read`} />
<span className={`flex-1 text-sm ${done ? 'text-[var(--r-sub)] line-through' : ''}`}>Day {i + 1} · {formatDayReadings(refs)}</span>
<button type="button" onClick={() => { onClose(); actions.read(progress.id, i); }} className="shrink-0 text-xs font-medium text-[var(--r-accent)] hover:underline">Read</button>
</div>
);
})}
</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>
</div>
</>
);
}
function CatalogBody({ plan, active, completedCount, onClose, actions }) {
const [translation, setTranslation] = useState(actions.defaultTranslation);
const [imageError, setImageError] = useState('');
const fileRef = useRef(null);
const schedule = getPlanSchedule(plan.id);
const chapters = schedule.reduce((n, d) => n + d.length, 0);
const pickImage = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
try { setImageError(''); actions.updateCover(plan.id, await resizeImageToDataUrl(file)); } catch { setImageError('Couldn’t use that image.'); }
};
return (
<>
{plan.image && <img src={plan.image} alt="" onError={(e) => { e.currentTarget.style.display = 'none'; }} className="mb-3 h-32 w-full rounded-xl object-cover" style={{ objectPosition: plan.imageFocus ?? 'center' }} />}
<p className="text-sm text-[var(--r-sub)]">{plan.description || 'A custom reading plan.'}</p>
<p className="mt-2 text-xs text-[var(--r-sub)]">{plan.days} days · {chapters} chapters · about {(chapters / plan.days).toFixed(1)} a day{completedCount > 0 ? ` · completed ${completedCount}×` : ''}</p>
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">First days</p>
<div className="rounded-xl border border-[var(--r-line)]">
{schedule.slice(0, 5).map((refs, i) => (
<div key={i} className="border-b border-[var(--r-line)] px-3 py-1.5 text-sm last:border-0">Day {i + 1} · {formatDayReadings(refs)}</div>
))}
</div>
{!active && (
<div className="mt-4 flex items-center justify-between text-sm">
<span>Read in</span>
<TranslationSelect value={translation} onChange={setTranslation} />
</div>
)}
<div className="mt-3">
{active
? <button type="button" className={primary} onClick={() => { onClose(); actions.read(active.id, actions.nextDay(plan, active)); }}>Continue reading</button>
: <button type="button" className={primary} onClick={() => { actions.start(plan.id, translation); onClose(); }}>{completedCount > 0 ? 'Start again' : 'Start plan'}</button>}
</div>
{plan.custom && (
<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>
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
{imageError && <p className="w-full text-red-600">{imageError}</p>}
</div>
)}
</>
);
}
export default function PlanDetailSheet({ plan, progress, active, completedCount, onClose, actions }) {
if (!plan) return null;
return (
<Sheet open onClose={onClose} title={<span>{plan.icon ? `${plan.icon} ` : ''}{plan.title}</span>}>
{progress
? <ActiveBody plan={plan} progress={progress} onClose={onClose} actions={actions} />
: <CatalogBody plan={plan} active={active} completedCount={completedCount} onClose={onClose} actions={actions} />}
</Sheet>
);
}
@@ -0,0 +1,11 @@
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
// Every Bible the app can read in — used for a plan's own translation.
export default function TranslationSelect({ value, onChange, className = '' }) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)}
className={`rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)] ${className}`}>
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
</select>
);
}
+2 -1
View File
@@ -7,7 +7,7 @@ import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT');
const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition';
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, onGo }) {
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo }) {
const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing
const close = () => { setPickedBook(null); onClose(); };
const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null;
@@ -33,6 +33,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
</button>
))}
</div>
{translationNote && <p className="-mt-1 mb-3 text-[11px] text-[var(--r-sub)]">{translationNote}</p>}
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Old Testament</p>
<div className="mb-4 grid grid-cols-3 gap-1.5 sm:grid-cols-4">{bookOptions.slice(0, NT_START).map(bookButton)}</div>
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">New Testament</p>
+32
View File
@@ -0,0 +1,32 @@
// Card at the end of the chapter while reading a plan: step to the next chapter of
// today's reading, or finish the day and roll into the next one.
export default function PlanFooter({ info, label, onNext, onNextDay, onFinish }) {
if (!info || info.position === 0) return null;
const last = info.position === info.total;
const nextRef = !last ? info.refs[info.position] : null;
const btn = 'rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)]';
return (
<div className="mx-auto mt-10 max-w-md rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 text-center font-sans">
<p className="text-xs text-[var(--r-sub)]">{info.plan.title} · Day {info.dayIndex + 1}</p>
{!last && (
<>
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">{info.total - info.position} more chapter{info.total - info.position === 1 ? '' : 's'} today</p>
<button type="button" onClick={onNext} className={btn}>Next: {label(nextRef)}</button>
</>
)}
{last && info.nextDayRefs?.length > 0 && (
<>
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">That’s Day {info.dayIndex + 1}</p>
<button type="button" onClick={onNextDay} className={btn}>Mark read · Day {info.dayIndex + 2}: {label(info.nextDayRefs[0])}</button>
<div><button type="button" onClick={onFinish} className="mt-2 text-xs text-[var(--r-sub)] underline">Mark read and stop for today</button></div>
</>
)}
{last && !info.nextDayRefs?.length && (
<>
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">Last day of the plan</p>
<button type="button" onClick={onFinish} className={btn}>{info.done ? 'Plan complete' : 'Finish plan'}</button>
</>
)}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
// The plan's control room, one tap from the reader: pick the day, tick chapters,
// choose the Bible this plan is read in, and jump to another active plan.
import Sheet from './Sheet.jsx';
import { ChevronLeft, ChevronRight, CheckIcon } from './icons.jsx';
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
import { formatDayReadings } from '../../utils/readingPlans.js';
export default function PlanSheet({
open, onClose, info, translation, currentKey, streak, otherPlans, studyBusy,
onDay, onToggleChapter, onGoChapter, onTranslation, onToggleDay, onSwitchPlan, onListen, onStudy, onLeave,
}) {
if (!info) return null;
const label = (r) => formatDayReadings([r]);
return (
<Sheet open={open} onClose={onClose} title={info.plan.title}>
<div className="mb-1 flex items-center justify-between">
<button type="button" onClick={() => onDay(-1)} disabled={info.dayIndex <= 0} aria-label="Previous day"
className="rounded-lg p-1.5 text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-30"><ChevronLeft /></button>
<span className="text-sm font-medium">Day {info.dayIndex + 1} of {info.plan.days}{info.done ? ' · read' : ''}</span>
<button type="button" onClick={() => onDay(1)} disabled={info.dayIndex >= info.plan.days - 1} aria-label="Next day"
className="rounded-lg p-1.5 text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-30"><ChevronRight /></button>
</div>
<p className="mb-1 mt-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Reading</p>
{info.refs.map((r) => {
const key = `${r.book}.${r.chapter}`;
const read = info.readSet.has(key);
return (
<div key={key} className="flex items-center gap-3 border-b border-[var(--r-line)] py-2">
<button type="button" onClick={() => onToggleChapter(key)} aria-label={read ? 'Mark unread' : 'Mark read'}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-[1.5px] ${read ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-sub)] text-transparent'}`}>
<span className="scale-75"><CheckIcon /></span>
</button>
<button type="button" onClick={() => onGoChapter(r)} className={`flex-1 text-left text-sm ${key === currentKey ? 'font-medium' : ''}`}>{label(r)}</button>
{key === currentKey && <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]">reading</span>}
</div>
);
})}
<button type="button" onClick={onToggleDay}
className="mt-3 w-full rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">
{info.done ? 'Mark this day unread' : 'Mark the whole day read'}
</button>
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">This plan</p>
<div className="flex items-center justify-between py-1.5 text-sm">
<span>Read in</span>
<select value={translation} onChange={(e) => onTranslation(e.target.value)}
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)]">
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
</select>
</div>
<div className="flex items-center justify-between py-1.5 text-sm">
<span>Streak</span><span className="text-[var(--r-sub)]">{streak.current} day{streak.current === 1 ? '' : 's'}</span>
</div>
<div className="mt-2 flex gap-2">
<button type="button" onClick={onListen} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">Listen</button>
<button type="button" onClick={onStudy} disabled={studyBusy} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5 disabled:opacity-50">{studyBusy ? 'Building…' : 'Study this day'}</button>
</div>
{otherPlans.length > 0 && (
<>
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Other plans</p>
{otherPlans.map((o) => (
<button key={o.progress.id} type="button" onClick={() => onSwitchPlan(o)}
className="flex w-full items-center justify-between rounded-lg px-1 py-2 text-left text-sm hover:bg-black/5">
<span className="truncate">{o.plan.title}</span>
<span className="ml-3 shrink-0 text-xs text-[var(--r-sub)]">Day {o.dayIndex + 1}</span>
</button>
))}
</>
)}
<button type="button" onClick={onLeave} className="mt-4 w-full text-center text-xs text-[var(--r-sub)] underline">Leave plan (keep reading on my own)</button>
</Sheet>
);
}
+34 -13
View File
@@ -1,18 +1,39 @@
// Thin strip under the top bar while the open chapter belongs to an active
// reading plan. Shows which day you're on and lets you mark it read.
export default function PlanStrip({ matches, notice, onToggle }) {
if (!matches.length && !notice) return null;
// Thin strip under the top bar while reading a plan: which plan and day, one dot
// per chapter of today's reading (filled = read, ringed = the chapter on screen),
// and the translation. Tapping it opens the plan sheet. When you've wandered off
// today's chapters (e.g. following a cross-reference) it offers a way back.
export default function PlanStrip({ info, translation, inPlan, notice, onOpen, onJoin, onDot, onBackToDay }) {
if (!info && !notice) return null;
return (
<div className="mx-auto flex max-w-2xl flex-col gap-1 px-4 pt-2 font-sans">
<div className="mx-auto flex max-w-2xl flex-col gap-1 px-3 pt-2 font-sans">
{notice && <p className="rounded-lg bg-[var(--r-bar)] px-3 py-1.5 text-xs text-[var(--r-accent)]">{notice}</p>}
{matches.map((m) => (
<button key={m.progressId} type="button" onClick={() => onToggle(m)}
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--r-bar)] px-3 py-1.5 text-left text-xs text-[var(--r-sub)] hover:bg-black/5"
title={m.done ? 'Tap to unmark this day' : 'Tap to mark this day read'}>
<span className="truncate"><span className="text-[var(--r-accent)]">{m.done ? '✓' : '●'}</span> {m.plan.title} · Day {m.dayIndex + 1} of {m.plan.days}</span>
<span className="shrink-0">{m.total > 1 ? `${m.position} of ${m.total}` : m.done ? 'Read' : 'Mark read'}</span>
</button>
))}
{info && (
<div className="rounded-xl bg-[var(--r-bar)] px-3 py-2 text-xs text-[var(--r-sub)]">
<button type="button" onClick={inPlan ? onOpen : onJoin} className="flex w-full items-center justify-between gap-3 text-left">
<span className="truncate font-medium text-[var(--r-text)]">{info.plan.title} · Day {info.dayIndex + 1}</span>
<span className="shrink-0">{inPlan ? translation : 'Open plan'}</span>
</button>
{inPlan && info.position === 0 && (
<button type="button" onClick={onBackToDay} className="mt-1.5 rounded-full border border-[var(--r-line)] px-3 py-1 text-[11px] text-[var(--r-accent)] hover:bg-black/5">
Back to today’s reading
</button>
)}
{inPlan && info.position > 0 && (
<div className="mt-1.5 flex items-center gap-1.5">
{info.refs.map((r, i) => {
const key = `${r.book}.${r.chapter}`;
const read = info.readSet.has(key);
const current = i + 1 === info.position;
return (
<button key={key} type="button" onClick={() => onDot(r)} aria-label={`Go to chapter ${r.chapter}`}
className={`h-2.5 w-2.5 rounded-full border-[1.5px] ${read ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'} ${current ? 'ring-2 ring-[var(--r-line)]' : ''}`} />
);
})}
<span className="ml-auto">{info.done ? 'Day read' : `${info.position} of ${info.total}`}</span>
</div>
)}
</div>
)}
</div>
);
}
+3 -1
View File
@@ -5,7 +5,7 @@ import { formatCrossRef } from '../../utils/wordStudy.js';
export default function ReaderText({
title, translation, notice, verses, loading, error, layout, fontSize, wide,
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
crossRefs, showCrossRefs, onSelectionEnd,
crossRefs, showCrossRefs, onSelectionEnd, footer,
}) {
const handleTap = (n) => {
const sel = window.getSelection();
@@ -73,6 +73,8 @@ export default function ReaderText({
</div>
)}
{!loading && !error && footer}
{notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>}
</article>
);