Study - New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports); the chunk stage is now "Chunks" - Home cards: passages shown as ranges, tags capped with "+N more" Reader - Side-by-side translations and a Compare tab per verse - Typeface, line spacing, verse-number settings; text and column scale with the screen - Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix - Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help Flow and safety - Toasts with Undo replace most confirm()/alert() - Back/Forward move between pages - Server refuses saves over a newer copy (409); keep-mine / use-other banner; opening a study no longer re-stamps it - Latest chapter request wins when flipping quickly Offline and data - Save books, plan days or a whole BSB/NET for offline - Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late - Settings: download/restore a full backup Accessibility - Sheets trap focus and close on Escape, skip link, focus ring, reduced motion, theme contrast raised to 4.5:1 (with a test) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
136 lines
8.2 KiB
React
136 lines
8.2 KiB
React
// 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, nextUnreadDayIndex } from '../../utils/readingPlans.js';
|
||
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
|
||
import { DownloadProgress } from '../OfflineSave.jsx';
|
||
import { canSaveOffline } from '../../utils/offline.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;
|
||
const offline = useOfflineSave();
|
||
const translation = progress.translation ?? actions.defaultTranslation;
|
||
// The next week of readings, so a trip or a bad connection doesn't interrupt the plan
|
||
const upcoming = (() => {
|
||
const from = nextUnreadDayIndex(plan, progress.completedDays);
|
||
return from == null ? [] : schedule.slice(from, from + 7).flat();
|
||
})();
|
||
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>
|
||
{upcoming.length > 0 && canSaveOffline(translation) && (
|
||
<div className="mt-3">
|
||
{offline.job
|
||
? <DownloadProgress job={offline.job} onCancel={offline.cancel} />
|
||
: (
|
||
<button type="button" onClick={() => offline.saveRefs('the next 7 days', translation, upcoming)}
|
||
className="w-full rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs text-[var(--r-sub)] hover:bg-black/5">
|
||
Save the next 7 days for offline ({translation})
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="mt-4 flex items-center justify-between text-xs">
|
||
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
|
||
<button type="button" onClick={() => { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] 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-[var(--r-danger,#b42318)]">Remove cover</button>}
|
||
<button type="button" onClick={() => { actions.delete(plan.id); onClose(); }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
|
||
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||
{imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{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>
|
||
);
|
||
}
|