Files
study-bible-project/src/components/plans/CustomPlanBuilder.jsx
T
nmemmertandClaude Sonnet 5.5 297f2c7e84
Build and Push Docker Image / build-and-push (push) Failing after 19s
Fix plan days with no chapters, add a way out of ink mode, harden ntfy reminders
Plans can no longer have more days than chapters (existing ones heal), and
opening a plan never strands the Reader on an empty day. The Reader shows
Done drawing while inking (Escape also exits). Reminders use the user's own
timezone, send within a 3 hour grace window, and Settings has a Send test
notification button.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:34:46 -04:00

276 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, clampPlanDays, 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 / Math.min(days, totalChapters)).toFixed(1)} ch/day`}
</p>
{totalChapters > 0 && Number(days) > totalChapters && (
<p className="mt-1 text-xs text-[var(--r-accent)]">
That’s more days than chapters, so the plan will be {totalChapters} days, one chapter a 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: clampPlanDays(books, chapterRanges, 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>
);
}