Files
study-bible-project/src/components/iq/IqDailyCard.jsx
T
nmemmertandClaude Sonnet 5.5 aabd4267b8
Build and Push Docker Image / build-and-push (push) Failing after 21s
Verse of the day with picture backgrounds, shareable image, and study covers
- Verse of the day: card over a picture (a different one each day, or chosen),
  12 drawn scenes plus photos from public/verse-backgrounds, and "Share as
  image" (also on the Reader's verse panel) that saves or shares a PNG with the
  same background, a theme look, or a photo from the device
- Study cards get a cover banner named for the book, coloured by kind of book
- Outline "Build from my notes": abbreviations like "A.D. 63." no longer end a
  sentence

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 09:21:44 -04:00

118 lines
8.5 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.
// Home card: today's verse and Spurgeon's Morning & Evening reading, both from IQ Bible.
import { useEffect, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
import VerseShareImage, { BACKGROUND_KEY } from '../VerseShareImage.jsx';
import { SCENES, loadPhotoList, resolveBackground, sceneDataUrl } from '../../utils/verseBackgrounds.js';
import { bookOptions } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
export default function IqDailyCard() {
const on = useIqAvailable();
if (!on) return null;
return <Card />;
}
function Card() {
const { jumpToReaderVerse, openBibleReader, readerTranslation, readerTheme } = useApp();
// Today's verse is chosen by IQ Bible (the same for everyone); its words come from the Bible you pick.
const votd = useIq('bibles/eng_kjv/verse-of-the-day');
const [chosen, setChosen] = useState(() => { try { return localStorage.getItem('votd-translation') || ''; } catch { return ''; } });
const translation = READER_TRANSLATIONS.some((t) => t.id === chosen) ? chosen : (readerTranslation || 'BSB');
const pick = (id) => { setChosen(id); try { localStorage.setItem('votd-translation', id); } catch { /* storage unavailable */ } };
const ref = votd.data?.data;
const [text, setText] = useState({ key: '', value: '', error: false });
const [sharing, setSharing] = useState(false);
// The picture behind the verse: a different one each day by default, or the one you chose.
const [bgChoice, setBgChoice] = useState(() => { try { return localStorage.getItem(BACKGROUND_KEY) || 'auto'; } catch { return 'auto'; } });
const [photos, setPhotos] = useState([]);
useEffect(() => { let live = true; loadPhotoList().then((list) => { if (live) setPhotos(list); }); return () => { live = false; }; }, []);
const background = resolveBackground(bgChoice, photos);
const backgroundUrl = background?.kind === 'photo' ? background.url : background ? sceneDataUrl(background.id) : '';
const pickBackground = (id) => { setBgChoice(id); try { localStorage.setItem(BACKGROUND_KEY, id); } catch { /* storage unavailable */ } };
const key = ref ? `${translation}/${ref.book_usfm}/${ref.chapter_number}/${ref.verse_number}` : '';
useEffect(() => {
if (!ref) return undefined;
const controller = new AbortController();
fetchChapter(translation, ref.book_usfm, ref.chapter_number, { signal: controller.signal })
.then(({ verses }) => {
const found = verses.find((v) => v.number === ref.verse_number)?.text;
setText({ key, value: found ?? '', error: !found });
})
.catch(() => { if (!controller.signal.aborted) setText({ key, value: '', error: true }); });
return () => controller.abort();
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
const dev = useIq('devotionals/today');
const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening'));
const verse = votd.data?.data;
const reading = dev.data?.data?.[part];
const loaded = text.key === key;
const shown = loaded && text.value ? text.value : loaded && text.error ? ref?.text ?? '' : '…';
const shownTranslation = loaded && text.error ? 'KJV' : translation;
const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
if (!verse && !reading) return null;
return (
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
{verse && (
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day</p>
<span className="flex items-center gap-1.5">
<select aria-label="Verse of the day background" value={bgChoice} onChange={(e) => pickBackground(e.target.value)}
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 text-xs">
<option value="auto">Picture of the day</option>
<option value="plain">Plain</option>
<optgroup label="Scenes">{SCENES.map((sc) => <option key={sc.id} value={sc.id}>{sc.name}</option>)}</optgroup>
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
</select>
<select aria-label="Verse of the day translation" value={translation} onChange={(e) => pick(e.target.value)}
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 text-xs">
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)}
</select>
</span>
</div>
{/* the translation's text; the KJV from IQ Bible is the fallback if that Bible can't be loaded right now */}
{backgroundUrl ? (
<figure className="relative mt-2 overflow-hidden rounded-xl bg-cover bg-center" style={{ backgroundImage: `url(${backgroundUrl})` }}>
<div className="bg-gradient-to-b from-black/35 via-black/45 to-black/60 px-6 py-8 text-center text-white" style={{ textShadow: '0 1px 8px rgba(0,0,0,0.55)' }}>
<blockquote className="font-serif text-[18px] leading-relaxed sm:text-[20px]">{shown}</blockquote>
<figcaption className="mt-3 text-sm font-semibold tracking-wide text-[#ffe0b0]">{name(verse.book_usfm)} {verse.chapter_number}:{verse.verse_number} <span className="font-normal text-white/80">· {shownTranslation}</span></figcaption>
</div>
</figure>
) : (
<blockquote className="mt-2 rounded-xl border border-[var(--r-line)] border-l-4 border-l-[var(--r-accent)] bg-[var(--r-bg)] px-4 py-3 font-serif text-[16px] leading-relaxed">{shown}</blockquote>
)}
{loaded && text.error && translation !== 'KJV' && <p className="mt-1 text-[11px] text-[var(--r-sub)]">Couldn’t load {translation} just now, so this is the KJV.</p>}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
<button type="button" onClick={() => { jumpToReaderVerse(verse.book_usfm, verse.chapter_number, verse.verse_number); openBibleReader(); }}
className="text-xs font-medium text-[var(--r-accent)] hover:underline">{name(verse.book_usfm)} {verse.chapter_number}:{verse.verse_number} — read in context</button>
<button type="button" onClick={() => setSharing((o) => !o)} aria-expanded={sharing} disabled={!loaded || !shown || shown === '…'}
className="text-xs font-medium text-[var(--r-accent)] hover:underline disabled:opacity-50">Share as image</button>
</div>
{sharing && loaded && shown && (
<VerseShareImage text={shown} reference={`${name(verse.book_usfm)} ${verse.chapter_number}:${verse.verse_number}`} translation={shownTranslation}
notice={loaded && !text.error ? READER_TRANSLATIONS.find((t) => t.id === translation)?.notice ?? '' : ''} defaultStyle={readerTheme || 'paper'} defaultBackground={bgChoice} onClose={() => setSharing(false)} />
)}
{text.key === key && text.value && READER_TRANSLATIONS.find((t) => t.id === translation)?.notice && (
<p className="mt-2 text-[10px] leading-snug text-[var(--r-sub)]">{READER_TRANSLATIONS.find((t) => t.id === translation).notice}</p>
)}
</div>
)}
{reading && (
<details className="mt-3 border-t border-[var(--r-line)] pt-3">
<summary className="cursor-pointer text-sm font-medium">Spurgeon, {part === 'morning' ? 'Morning' : 'Evening'}: “{reading.heading_text}”</summary>
<div className="mt-2 flex gap-1.5">
{['morning', 'evening'].map((p) => (
<button key={p} type="button" onClick={() => setPart(p)} aria-pressed={part === p}
className={`rounded-full border px-2.5 py-0.5 text-[11px] ${part === p ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)]'}`}>{p === 'morning' ? 'Morning' : 'Evening'}</button>
))}
</div>
<p className="mt-1 text-xs text-[var(--r-sub)]">{reading.heading_citation}</p>
<div className="mt-2 max-h-72 overflow-y-auto whitespace-pre-line font-serif text-[15px] leading-relaxed">{reading.body}</div>
<p className="mt-2 text-[10px] text-[var(--r-sub)]">Charles Spurgeon, Morning and Evening (1866), via IQ Bible.</p>
</details>
)}
</section>
);
}