Build and Push Docker Image / build-and-push (push) Failing after 19s
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>
276 lines
12 KiB
React
276 lines
12 KiB
React
// 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>
|
||
);
|
||
}
|