Add reading plans: catalog, progress tracking, and custom plans
Replaces the old single-book reading goal with a full Reading Plans page: 9 built-in plans (Whole Bible, OT/NT, topical) with cover photos and color-themed cards, day-by-day checklists synced to the server per user, inline "mark today read" in the Bible reader, and user-created plans with their own book picker and an optional uploaded cover photo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f1026dc194
commit
600b3d3ab9
21 files changed
+1166
-159
No files matched your search
@@ -0,0 +1,557 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import AuthStatus from '../components/AuthStatus.jsx';
|
||||
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.jsx';
|
||||
import {
|
||||
READING_PLANS,
|
||||
getPlan,
|
||||
getPlanSchedule,
|
||||
formatDayReadings,
|
||||
firstRefOfDay,
|
||||
nextUnreadDayIndex,
|
||||
themeForCategory,
|
||||
ALL_BOOKS,
|
||||
OT_BOOKS,
|
||||
NT_BOOKS,
|
||||
GOSPELS,
|
||||
PENTATEUCH,
|
||||
WISDOM_BOOKS,
|
||||
} from '../utils/readingPlans.js';
|
||||
|
||||
export default function ReadingPlansPage() {
|
||||
const {
|
||||
goHome,
|
||||
planProgress,
|
||||
customPlans,
|
||||
startPlan,
|
||||
toggleDay,
|
||||
restartPlan,
|
||||
removePlanProgress,
|
||||
addCustomPlan,
|
||||
removeCustomPlan,
|
||||
updateCustomPlan,
|
||||
readReadingPlanDay,
|
||||
} = useApp();
|
||||
const [showBuilder, setShowBuilder] = useState(false);
|
||||
|
||||
const activeProgress = planProgress.filter((p) => !p.completedAt);
|
||||
const completedProgress = planProgress
|
||||
.filter((p) => p.completedAt)
|
||||
.sort((a, b) => b.completedAt - a.completedAt);
|
||||
|
||||
const categoryOrder = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical'];
|
||||
const builtinCategories = categoryOrder.filter((c) => READING_PLANS.some((p) => p.category === c));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">Reading Plans</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<AuthStatus />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||
{activeProgress.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900">Continue Reading</h2>
|
||||
<div className="space-y-4">
|
||||
{activeProgress.map((progress) => (
|
||||
<ActivePlanCard
|
||||
key={progress.id}
|
||||
progress={progress}
|
||||
onRead={readReadingPlanDay}
|
||||
onToggleDay={toggleDay}
|
||||
onRestart={restartPlan}
|
||||
onRemove={removePlanProgress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900">My Plans</h2>
|
||||
{!showBuilder && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBuilder(true)}
|
||||
className="rounded-xl bg-rose-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-rose-500"
|
||||
>
|
||||
+ New Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showBuilder && (
|
||||
<div className="mb-4">
|
||||
<CustomPlanBuilder
|
||||
onCreate={(input) => { addCustomPlan(input); setShowBuilder(false); }}
|
||||
onCancel={() => setShowBuilder(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{customPlans.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{customPlans.map((plan) => (
|
||||
<PlanCatalogCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
progressForPlan={planProgress.filter((p) => p.planId === plan.id)}
|
||||
onStart={startPlan}
|
||||
onDelete={(id) => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) removeCustomPlan(id); }}
|
||||
onImageChange={(id, image) => updateCustomPlan(id, { image })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : !showBuilder && (
|
||||
<p className="text-sm text-slate-500">You haven't created any plans yet — try "+ New Plan".</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{builtinCategories.map((category) => (
|
||||
<section key={category}>
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900">{category}</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{READING_PLANS.filter((p) => p.category === category).map((plan) => (
|
||||
<PlanCatalogCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
progressForPlan={planProgress.filter((p) => p.planId === plan.id)}
|
||||
onStart={startPlan}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{completedProgress.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900">Completed</h2>
|
||||
<div className="space-y-2">
|
||||
{completedProgress.map((progress) => {
|
||||
const plan = getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
return (
|
||||
<div
|
||||
key={progress.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-3 shadow-panel"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{plan.icon ?? '✓'} {plan.title}</p>
|
||||
<p className="text-xs text-slate-500">Completed {formatRelativeDate(progress.completedAt)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => restartPlan(progress.id)}
|
||||
className="font-semibold text-slate-700 hover:underline"
|
||||
>
|
||||
Start again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm('Remove this from your history?')) removePlanProgress(progress.id); }}
|
||||
className="text-rose-500 hover:text-rose-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCover({ plan }) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const theme = themeForCategory(plan.category);
|
||||
const showImage = !!plan.image && !imgFailed;
|
||||
return (
|
||||
<div className={`relative flex h-32 items-center justify-center overflow-hidden bg-gradient-to-br ${theme.gradient}`}>
|
||||
{showImage && (
|
||||
<img
|
||||
src={plan.image}
|
||||
alt=""
|
||||
onError={() => setImgFailed(true)}
|
||||
style={{ objectPosition: plan.imageFocus ?? 'center' }}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{!showImage && <span className="text-4xl drop-shadow">{plan.icon ?? '📖'}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const plan = getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
|
||||
const theme = themeForCategory(plan.category);
|
||||
const schedule = getPlanSchedule(progress.planId);
|
||||
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
|
||||
const nextDay = nextUnreadDayIndex(plan, progress.completedDays);
|
||||
const nextRefs = nextDay != null ? schedule[nextDay] : null;
|
||||
const nextFirstRef = nextRefs ? firstRefOfDay(nextRefs) : null;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl border border-emerald-200 bg-emerald-50 shadow-panel">
|
||||
<div className={`h-1.5 w-full bg-gradient-to-r ${theme.gradient}`} />
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className={`text-xs font-semibold uppercase tracking-widest ${theme.text}`}>{plan.category}</p>
|
||||
<h3 className="mt-1 text-base font-semibold text-slate-900">{plan.icon ?? '📖'} {plan.title}</h3>
|
||||
<p className="mt-0.5 text-sm text-slate-600">
|
||||
{progress.completedDays.length} / {plan.days} days
|
||||
{nextRefs && <> · Next: Day {nextDay + 1} — {formatDayReadings(nextRefs)}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{nextFirstRef && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRead(nextFirstRef.book, nextFirstRef.chapter)}
|
||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
|
||||
>
|
||||
{expanded ? 'Hide days' : 'View days'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-emerald-200">
|
||||
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 max-h-72 overflow-y-auto rounded-2xl border border-emerald-100 bg-white p-2">
|
||||
{schedule.map((dayRefs, i) => {
|
||||
const done = progress.completedDays.includes(i);
|
||||
const ref = firstRefOfDay(dayRefs);
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-slate-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
onChange={() => onToggleDay(progress.id, i, plan.days)}
|
||||
className="h-4 w-4 shrink-0 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className={done ? 'flex-1 text-slate-400 line-through' : 'flex-1 text-slate-700'}>
|
||||
Day {i + 1} — {formatDayReadings(dayRefs)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRead(ref.book, ref.chapter)}
|
||||
className="shrink-0 text-xs font-semibold text-sky-600 hover:underline"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-4 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm('Restart this plan from day 1?')) onRestart(progress.id); }}
|
||||
className="font-semibold text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm('Remove this plan?')) onRemove(progress.id); }}
|
||||
className="font-semibold text-rose-500 hover:text-rose-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCatalogCard({ plan, progressForPlan, onStart, onDelete, onImageChange }) {
|
||||
const active = progressForPlan.find((p) => !p.completedAt);
|
||||
const completedCount = progressForPlan.filter((p) => p.completedAt).length;
|
||||
const theme = themeForCategory(plan.category);
|
||||
const fileInputRef = useRef(null);
|
||||
const [imageError, setImageError] = useState('');
|
||||
|
||||
const pickImage = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ''; // lets the same file be re-picked later
|
||||
if (!file) return;
|
||||
try {
|
||||
setImageError('');
|
||||
onImageChange(plan.id, await resizeImageToDataUrl(file));
|
||||
} catch {
|
||||
setImageError("Couldn't use that image.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-panel transition hover:shadow-lg">
|
||||
<PlanCover plan={plan} />
|
||||
<div className="flex flex-col gap-2 p-5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-900">{plan.title}</h3>
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(plan.id)}
|
||||
title="Delete plan"
|
||||
className="shrink-0 text-slate-300 transition hover:text-rose-500"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="flex-1 text-xs text-slate-500">{plan.description || 'A custom reading plan.'}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${theme.chip}`}>{plan.days} days</span>
|
||||
{completedCount > 0 && <span className="text-xs font-semibold text-emerald-600">✓ Completed {completedCount}×</span>}
|
||||
</div>
|
||||
{onImageChange && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} className="font-semibold text-slate-500 hover:text-slate-700">
|
||||
{plan.image ? 'Change cover' : '+ Add cover photo'}
|
||||
</button>
|
||||
{plan.image && (
|
||||
<button type="button" onClick={() => onImageChange(plan.id, null)} className="text-slate-400 hover:text-rose-500">
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||
</div>
|
||||
)}
|
||||
{imageError && <p className="text-xs text-rose-500">{imageError}</p>}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!active}
|
||||
onClick={() => onStart(plan.id)}
|
||||
className="mt-1 rounded-xl bg-slate-900 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{active ? 'In progress' : completedCount > 0 ? 'Start again' : 'Start plan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
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],
|
||||
];
|
||||
|
||||
function CustomPlanBuilder({ onCreate, onCancel }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [days, setDays] = useState(30);
|
||||
const [books, setBooks] = useState([]);
|
||||
const [image, setImage] = useState(null);
|
||||
const [imageError, setImageError] = useState('');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const toggleBook = (abbrev) => {
|
||||
setBooks((prev) => (prev.includes(abbrev) ? prev.filter((b) => b !== abbrev) : [...prev, abbrev]));
|
||||
};
|
||||
|
||||
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 + (CHAPTER_COUNTS[b] ?? 0), 0);
|
||||
const canCreate = title.trim().length > 0 && books.length > 0 && days >= 1;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-rose-200 bg-white p-5 shadow-panel">
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-900">New reading plan</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm text-slate-600">
|
||||
Title
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Advent Readings"
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-slate-600">
|
||||
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-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="mt-3 block text-sm text-slate-600">
|
||||
Description <span className="text-slate-400">(optional)</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-slate-600">Cover photo <span className="text-slate-400">(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-slate-100 text-2xl">🖼️</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
{image ? 'Change photo' : 'Choose photo'}
|
||||
</button>
|
||||
{image && (
|
||||
<button type="button" onClick={() => setImage(null)} className="text-sm text-slate-400 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-slate-700">Books</span>
|
||||
{BOOK_PRESETS.map(([label, list]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => setBooks(list)}
|
||||
className="rounded-full border border-slate-300 px-2.5 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="max-h-48 overflow-y-auto rounded-xl border border-slate-200 p-2">
|
||||
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-slate-400">Old Testament</p>
|
||||
{bookOptions.filter((b) => OT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||
<label key={b.abbrev} className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-slate-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={books.includes(b.abbrev)}
|
||||
onChange={() => toggleBook(b.abbrev)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-rose-600 focus:ring-rose-500"
|
||||
/>
|
||||
{b.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto rounded-xl border border-slate-200 p-2">
|
||||
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-slate-400">New Testament</p>
|
||||
{bookOptions.filter((b) => NT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||
<label key={b.abbrev} className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-slate-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={books.includes(b.abbrev)}
|
||||
onChange={() => toggleBook(b.abbrev)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-rose-600 focus:ring-rose-500"
|
||||
/>
|
||||
{b.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
|
||||
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / days).toFixed(1)} ch/day`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canCreate}
|
||||
onClick={() => onCreate({ title, description, days, books, image })}
|
||||
className="rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Create plan
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user