Verse of the day with picture backgrounds, shareable image, and study covers
Build and Push Docker Image / build-and-push (push) Failing after 21s
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:
1 parent
d2bbf1feb4
commit
aabd4267b8
15 files changed
+826
-8
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' }) }));
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user