Verse of the day with picture backgrounds, shareable image, and study covers
Build and Push Docker Image / build-and-push (push) Failing after 21s

- 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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 09:21:44 -04:00
1 parent d2bbf1feb4
commit aabd4267b8
15 files changed
+826 -8

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
// The picture on a study's card: a coloured cover for the kind of book, with the book's name.
import { coverFor } from '../utils/bookCovers.js';
export default function BookCover({ summary, className = '' }) {
const { group, name, extra } = coverFor(summary);
const [from, to] = group.colors;
const id = `cover-${group.id}`;
return (
<div className={`relative overflow-hidden rounded-xl ${className}`} role="img" aria-label={`${name}${extra ? ` and ${extra} more` : ''}, ${group.label}`}>
<svg viewBox="0 0 400 120" preserveAspectRatio="xMidYMid slice" className="block h-full w-full" aria-hidden="true">
<defs>
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor={from} /><stop offset="1" stopColor={to} /></linearGradient>
</defs>
<rect width="400" height="120" fill={`url(#${id})`} />
<g fill="none" stroke="#fff" strokeOpacity="0.1" strokeWidth="1.5">
<circle cx="360" cy="20" r="70" /><circle cx="360" cy="20" r="50" /><circle cx="360" cy="20" r="30" />
<circle cx="30" cy="120" r="60" /><circle cx="30" cy="120" r="40" />
</g>
<rect x="20" y="22" width="28" height="3" rx="1.5" fill="#fff" fillOpacity="0.7" />
<text x="20" y="62" fill="#fff" fontFamily="Georgia, 'Times New Roman', serif" fontSize={name.length > 13 ? 26 : 32} fontWeight="600">{name}</text>
<text x="20" y="90" fill="#fff" fillOpacity="0.8" fontFamily="-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" fontSize="13" letterSpacing="1.5">
{group.label.toUpperCase()}{extra ? ` · +${extra} MORE` : ''}
</text>
</svg>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
// "Share as image": previews a verse card, lets you pick its look (plain theme, a scene, one of your
// photos, or a photo from your device), then shares or saves the picture.
import { useEffect, useRef, useState } from 'react';
import { READER_THEMES } from './reader/readerThemes.js';
import { renderVerseCard, shareOrSaveImage, verseCardFileName } from '../utils/verseCard.js';
import { SCENES, loadImage, loadPhotoList, resolveBackground } from '../utils/verseBackgrounds.js';
import { useToast } from './Toasts.jsx';
const STYLE_KEY = 'verse-card-style';
export const BACKGROUND_KEY = 'votd-background';
export default function VerseShareImage({ text, reference, translation, notice = '', defaultStyle = 'paper', defaultBackground = 'plain', onClose }) {
const { toast } = useToast();
const [style, setStyle] = useState(() => { try { return localStorage.getItem(STYLE_KEY) || defaultStyle; } catch { return defaultStyle; } });
const [choice, setChoice] = useState(defaultBackground); // 'plain' | 'auto' | scene id | photo id | 'mine'
const [photos, setPhotos] = useState([]);
const [mine, setMine] = useState(null); // a photo picked from this device: { image, name }
const [image, setImage] = useState({ blob: null, url: '', error: '' });
const fileRef = useRef(null);
useEffect(() => { let live = true; loadPhotoList().then((list) => { if (live) setPhotos(list); }); return () => { live = false; }; }, []);
useEffect(() => {
let live = true;
let url = '';
const background = choice === 'mine' && mine ? { kind: 'image', image: mine.image } : resolveBackground(choice === 'mine' ? 'plain' : choice, photos);
renderVerseCard({ text, reference, translation, notice, themeId: style, background })
.then((blob) => { if (!live) return; url = URL.createObjectURL(blob); setImage({ blob, url, error: '' }); })
.catch((err) => { if (live) setImage({ blob: null, url: '', error: err.message }); });
return () => { live = false; if (url) URL.revokeObjectURL(url); };
}, [text, reference, translation, notice, style, choice, photos, mine]);
const chooseStyle = (id) => { setStyle(id); setChoice('plain'); try { localStorage.setItem(STYLE_KEY, id); } catch { /* storage unavailable */ } };
const pickPhoto = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
const url = URL.createObjectURL(file);
try { setMine({ image: await loadImage(url), name: file.name }); setChoice('mine'); } catch (err) { toast(err.message, { tone: 'error' }); } finally { URL.revokeObjectURL(url); }
};
const go = async () => {
const result = await shareOrSaveImage(image.blob, verseCardFileName(reference), { title: reference, text: `${reference} (${translation})` });
if (result === 'saved') toast('Image saved');
};
return (
<div className="mt-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3" role="region" aria-label="Verse image">
<div className="mb-2 flex items-center justify-between gap-2">
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]">Background
<select value={choice} onChange={(e) => (e.target.value === 'upload' ? fileRef.current?.click() : setChoice(e.target.value))} aria-label="Image background"
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2 py-1 text-xs text-[var(--r-text)]">
<option value="plain">Plain</option>
<option value="auto">Picture of the day</option>
<optgroup label="Scenes">{SCENES.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</optgroup>
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
{mine && <option value="mine">{mine.name}</option>}
<option value="upload">Use a photo from this device…</option>
</select>
</label>
{onClose && <button type="button" onClick={onClose} aria-label="Close image" className="rounded p-1 text-[var(--r-sub)] hover:bg-black/5">×</button>}
</div>
<input ref={fileRef} type="file" accept="image/*" onChange={pickPhoto} className="hidden" aria-label="Choose a background photo" />
{choice === 'plain' && (
<div role="group" aria-label="Image style" className="mb-2 flex w-fit overflow-hidden rounded-full border border-[var(--r-line)] text-xs">
{Object.entries(READER_THEMES).map(([id, t]) => (
<button key={id} type="button" aria-pressed={style === id} onClick={() => chooseStyle(id)}
className={`px-3 py-1 ${style === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}</button>
))}
</div>
)}
{image.error ? <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{image.error}</p>
: image.url ? <img src={image.url} alt={`${reference} (${translation}) as a picture`} className="mx-auto w-full max-w-xs rounded-lg shadow-sm" />
: <p className="text-sm text-[var(--r-sub)]">Making the image…</p>}
<div className="mt-3 flex gap-2">
<button type="button" onClick={go} disabled={!image.blob} className="flex-1 rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">
{typeof navigator !== 'undefined' && navigator.canShare ? 'Share image' : 'Save image'}
</button>
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
vi.mock('../utils/verseCard.js', async (orig) => ({
...(await orig()),
renderVerseCard: vi.fn(async () => new Blob(['png'], { type: 'image/png' })),
shareOrSaveImage: vi.fn(async () => 'saved'),
}));
vi.mock('./Toasts.jsx', () => ({ useToast: () => ({ toast: vi.fn() }) }));
import VerseShareImage from './VerseShareImage.jsx';
import { renderVerseCard, shareOrSaveImage } from '../utils/verseCard.js';
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
URL.createObjectURL = vi.fn(() => 'blob:card');
URL.revokeObjectURL = vi.fn();
});
const props = { text: 'For God so loved the world.', reference: 'John 3:16', translation: 'BSB' };
test('previews the card, redraws when the look changes, and remembers the look', async () => {
const user = userEvent.setup();
render(<VerseShareImage {...props} />);
expect(await screen.findByAltText(/John 3:16 \(BSB\) as a picture/)).toBeInTheDocument();
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ themeId: 'paper', reference: 'John 3:16' }));
await user.click(screen.getByRole('button', { name: 'Dark' }));
await screen.findByAltText(/as a picture/);
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ themeId: 'dark' }));
expect(localStorage.getItem('verse-card-style')).toBe('dark');
});
test('Save/Share sends the image under a file name from the reference', async () => {
const user = userEvent.setup();
render(<VerseShareImage {...props} />);
await screen.findByAltText(/as a picture/);
await user.click(screen.getByRole('button', { name: /save image|share image/i }));
expect(shareOrSaveImage).toHaveBeenCalledWith(expect.any(Blob), 'john-3-16.png', expect.objectContaining({ title: 'John 3:16' }));
});
test('says so if the picture can\'t be drawn', async () => {
renderVerseCard.mockRejectedValueOnce(new Error('This browser can’t draw the image.'));
render(<VerseShareImage {...props} />);
expect(await screen.findByRole('alert')).toHaveTextContent(/can’t draw/);
});
test('a background picture can be chosen, and is passed to the card; the plain looks come back with Plain', async () => {
const user = userEvent.setup();
render(<VerseShareImage {...props} />);
await screen.findByAltText(/as a picture/);
expect(screen.getByRole('group', { name: /image style/i })).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText('Image background'), 'sea');
await screen.findByAltText(/as a picture/);
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: { kind: 'scene', id: 'sea' } }));
expect(screen.queryByRole('group', { name: /image style/i })).toBeNull(); // theme looks only apply to the plain card
await user.selectOptions(screen.getByLabelText('Image background'), 'plain');
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: null }));
});
test('the picture of the day is used when the Home card passes it in', async () => {
render(<VerseShareImage {...props} defaultBackground="auto" />);
await screen.findByAltText(/as a picture/);
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: expect.objectContaining({ kind: 'scene' }) }));
});
+44 -5
View File
@@ -2,6 +2,8 @@
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';
@@ -12,7 +14,7 @@ export default function IqDailyCard() {
}
function Card() {
const { jumpToReaderVerse, openBibleReader, readerTranslation } = useApp();
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 ''; } });
@@ -20,6 +22,14 @@ function Card() {
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;
@@ -36,6 +46,9 @@ function Card() {
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 (
@@ -44,16 +57,42 @@ function Card() {
<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 */}
<p className="mt-1 font-serif text-[15px] leading-relaxed">{text.key === key && text.value ? text.value : text.key === key && text.error ? verse.text : '…'}</p>
{text.key === key && text.error && translation !== 'KJV' && <p className="text-[11px] text-[var(--r-sub)]">Couldn’t load {translation} just now, so this is the KJV.</p>}
<button type="button" onClick={() => { jumpToReaderVerse(verse.book_usfm, verse.chapter_number, verse.verse_number); openBibleReader(); }}
className="mt-1 text-xs font-medium text-[var(--r-accent)] hover:underline">{name(verse.book_usfm)} {verse.chapter_number}:{verse.verse_number} — read in context</button>
{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>
)}
+12 -1
View File
@@ -10,6 +10,7 @@ import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
import { useIqAvailable } from '../../hooks/useIq.js';
import VerseShareImage from '../VerseShareImage.jsx';
import IqLexicon from '../iq/IqLexicon.jsx';
import IqStudyAids from '../iq/IqStudyAids.jsx';
@@ -160,6 +161,7 @@ export default function VerseSheet({
const TABS = iqOn ? [...BASE_TABS, ['aids', 'Study aids']] : BASE_TABS;
const [tab, setTab] = useState('commentary');
const [copied, setCopied] = useState(false);
const [imageOpen, setImageOpen] = useState(false);
const [preview, setPreview] = useState(null); // the cross-reference being read in the panel
const appliedRequest = useRef(0);
// Leaving this verse closes the preview; a cross-reference tapped in the text opens it here. (Tracks the
@@ -177,7 +179,7 @@ export default function VerseSheet({
}
}, [verse?.number, request?.n]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { if (verse && tab === 'refs') onLoadCrossRefs(); }, [verse?.number, tab]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setCopied(false); }, [verse?.number]);
useEffect(() => { setCopied(false); setImageOpen(false); }, [verse?.number]);
if (!verse) return null;
const copy = () => {
@@ -201,12 +203,21 @@ export default function VerseSheet({
<span className="block h-4 w-4 rounded-full border border-black/10" style={{ backgroundColor: bookmarkColor }} />
</button>
)}
<button type="button" onClick={() => setImageOpen((o) => !o)} aria-pressed={imageOpen} className={`${actionBtn} text-xs`} aria-label="Share verse as image" title="Share as image">
Image
</button>
<button type="button" onClick={copy} className={actionBtn} aria-label="Copy verse">
{copied ? <span className="px-1 text-xs">Copied</span> : <CopyIcon />}
</button>
</>
)}
>
{imageOpen && (
<div className="mb-3">
<VerseShareImage text={verse.text} reference={`${bookName} ${chapter}:${verse.number}`} translation={translation}
notice={READER_TRANSLATIONS.find((t) => t.id === translation)?.notice ?? ''} onClose={() => setImageOpen(false)} />
</div>
)}
{onHighlightVerse && (
// One-tap highlighting of the whole verse (to mark just some words, select them in the text)
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Highlight this verse">