Add NLT, CSB and NASB via api.bible proxy; new app logo and icons
Build and Push Docker Image / build-and-push (push) Failing after 21s
Reader and Reading Plans get a translation picker. Licensed translations are fetched through a server-side api.bible proxy so the key stays off the client. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
No files matched your search
@@ -10,3 +10,4 @@ coverage/
|
||||
.api.pid
|
||||
.api.log
|
||||
.DS_Store
|
||||
.env
|
||||
|
Before Width: | Height: | Size: 694 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 757 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 420 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Bible Study Project">
|
||||
<defs>
|
||||
<radialGradient id="glow" cx="50%" cy="34%" r="45%">
|
||||
<stop offset="0" stop-color="#fbbf24" stop-opacity=".35"/>
|
||||
<stop offset="1" stop-color="#fbbf24" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="#0f172a"/>
|
||||
<rect width="512" height="512" rx="112" fill="url(#glow)"/>
|
||||
<!-- cross -->
|
||||
<rect x="238" y="82" width="36" height="170" rx="6" fill="#fbbf24"/>
|
||||
<rect x="200" y="122" width="112" height="34" rx="6" fill="#fbbf24"/>
|
||||
<!-- open book -->
|
||||
<path d="M256 392 C206 356 146 350 80 366 L80 262 C146 246 206 252 256 286 Z" fill="#f8fafc"/>
|
||||
<path d="M256 392 C306 356 366 350 432 366 L432 262 C366 246 306 252 256 286 Z" fill="#e2e8f0"/>
|
||||
<path d="M60 384 C136 366 206 374 256 414 C306 374 376 366 452 384" fill="none" stroke="#fbbf24" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M256 286 V392" stroke="#0f172a" stroke-width="6" opacity=".35"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -19,7 +19,13 @@
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Proxy for licensed translations (NLT, CSB, NASB) served by api.bible.
|
||||
// The API key stays on the server (API_BIBLE_KEY, loaded from .env if present);
|
||||
// the browser only ever talks to /api/bible/...
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Minimal .env loader so the key doesn't have to be exported in every launcher
|
||||
// (setup.sh, systemd, docker). Real environment variables win.
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
||||
}
|
||||
}
|
||||
|
||||
const API_BASE = 'https://rest.api.bible/v1';
|
||||
|
||||
export const LICENSED_TRANSLATIONS = {
|
||||
NLT: { bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' },
|
||||
CSB: { bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' },
|
||||
NASB: { bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' },
|
||||
};
|
||||
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
const CACHE_MAX = 1000;
|
||||
const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
|
||||
|
||||
// api.bible returns plain text with inline "[12]" verse markers, poetry line
|
||||
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
|
||||
export function parseChapterText(content) {
|
||||
const clean = String(content ?? '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/#/g, '');
|
||||
const parts = clean.split(/\[(\d+)\]/);
|
||||
const verses = [];
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
const text = parts[i + 1].replace(/\s+/g, ' ').replace(/\s+,$/, '').trim();
|
||||
if (text) verses.push({ number: Number(parts[i]), text });
|
||||
}
|
||||
return verses;
|
||||
}
|
||||
|
||||
export function registerBibleRoutes(app, requireAuth) {
|
||||
app.get('/api/bible/translations', requireAuth, (_req, res) => {
|
||||
res.json(Object.entries(LICENSED_TRANSLATIONS).map(([id, t]) => ({
|
||||
id, name: t.name, available: Boolean(process.env.API_BIBLE_KEY),
|
||||
})));
|
||||
});
|
||||
|
||||
app.get('/api/bible/:translation/:book/:chapter', requireAuth, async (req, res) => {
|
||||
const translation = LICENSED_TRANSLATIONS[req.params.translation.toUpperCase()];
|
||||
const { book, chapter } = req.params;
|
||||
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
|
||||
if (!/^[1-3A-Z]{3}$/i.test(book) || !/^\d{1,3}$/.test(chapter)) {
|
||||
return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||
}
|
||||
const apiKey = process.env.API_BIBLE_KEY;
|
||||
if (!apiKey) return res.status(503).json({ error: 'API_BIBLE_KEY is not configured on the server.' });
|
||||
|
||||
const key = `${req.params.translation.toUpperCase()}/${book.toUpperCase()}/${chapter}`;
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL) return res.json(hit.payload);
|
||||
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false',
|
||||
'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false',
|
||||
});
|
||||
const upstream = await fetch(
|
||||
`${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`,
|
||||
{ headers: { 'api-key': apiKey } },
|
||||
);
|
||||
if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' });
|
||||
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||
const body = await upstream.json();
|
||||
const verses = parseChapterText(body.data?.content);
|
||||
if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' });
|
||||
const payload = {
|
||||
translation: req.params.translation.toUpperCase(),
|
||||
verses,
|
||||
copyright: body.data?.copyright ?? '',
|
||||
fumsToken: body.meta?.fumsToken ?? null,
|
||||
};
|
||||
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
|
||||
cache.set(key, { at: Date.now(), payload });
|
||||
res.json(payload);
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach the Bible provider.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||||
} from './auth.js';
|
||||
import { startReminderScheduler } from './reminders.js';
|
||||
import { registerBibleRoutes } from './bibleApi.js';
|
||||
|
||||
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||||
function userPayload(user) {
|
||||
@@ -450,6 +451,8 @@ app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/reading-plans — the current user's whole progress list
|
||||
registerBibleRoutes(app, requireAuth);
|
||||
|
||||
app.get('/api/reading-plans', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getReadingProgress(req.session.userId));
|
||||
|
||||
@@ -2,14 +2,31 @@
|
||||
// Audio playback stays in App because the study page's book player shares
|
||||
// the same <audio> element.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { bookOptions, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||
import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
|
||||
|
||||
export const READER_TRANSLATIONS = [
|
||||
{ id: 'BSB', name: 'Berean Standard Bible' },
|
||||
{ id: 'NLT', name: 'New Living Translation', licensed: true,
|
||||
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
|
||||
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
|
||||
notice: 'Christian Standard Bible® and CSB® are federally registered trademarks of Holman Bible Publishers. All rights reserved. bhpublishinggroup.com' },
|
||||
{ id: 'NASB', name: 'New American Standard Bible', licensed: true,
|
||||
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' },
|
||||
];
|
||||
|
||||
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
||||
|
||||
export function useBibleReader(currentPage) {
|
||||
const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||
const [readerChapter, setReaderChapter] = useState(1);
|
||||
const [readerTranslation, setReaderTranslationState] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('reader-translation');
|
||||
return READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'BSB';
|
||||
} catch { return 'BSB'; }
|
||||
});
|
||||
const [readerCopyright, setReaderCopyright] = useState('');
|
||||
const [readerVerses, setReaderVerses] = useState([]);
|
||||
const [readerTotalChapters, setReaderTotalChapters] = useState(1);
|
||||
const [readerLoading, setReaderLoading] = useState(false);
|
||||
@@ -89,19 +106,39 @@ export function useBibleReader(currentPage) {
|
||||
}
|
||||
};
|
||||
|
||||
const loadReaderChapter = async (bookAbbrev, chapterNumber) => {
|
||||
const setReaderTranslation = (id) => {
|
||||
setReaderTranslationState(id);
|
||||
try { localStorage.setItem('reader-translation', id); } catch {}
|
||||
};
|
||||
|
||||
// BSB comes straight from HelloAO; licensed translations (NLT/CSB/NASB) go
|
||||
// through the server's api.bible proxy so the API key never reaches the browser.
|
||||
const loadReaderChapter = async (bookAbbrev, chapterNumber, translation = readerTranslation) => {
|
||||
setReaderLoading(true);
|
||||
setReaderError('');
|
||||
try {
|
||||
let verses;
|
||||
let copyright = '';
|
||||
let totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? chapterNumber;
|
||||
if (translation === 'BSB') {
|
||||
const res = await fetch(`https://bible.helloao.org/api/BSB/${bookAbbrev}/${chapterNumber}.json`);
|
||||
if (!res.ok) throw new Error('Unable to load chapter.');
|
||||
const data = await res.json();
|
||||
const verses = parseBibleChapter(data);
|
||||
verses = parseBibleChapter(data);
|
||||
totalChapters = data.book?.numberOfChapters ?? totalChapters;
|
||||
} else {
|
||||
const res = await fetch(`/api/bible/${translation}/${bookAbbrev}/${chapterNumber}`, { credentials: 'include' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Unable to load ${translation}.`);
|
||||
verses = data.verses;
|
||||
copyright = data.copyright ?? '';
|
||||
}
|
||||
if (!Array.isArray(verses) || verses.length === 0) {
|
||||
throw new Error('Invalid Bible data returned.');
|
||||
}
|
||||
setReaderVerses(verses);
|
||||
setReaderTotalChapters(data.book?.numberOfChapters ?? chapterNumber);
|
||||
setReaderCopyright(copyright);
|
||||
setReaderTotalChapters(totalChapters);
|
||||
} catch (err) {
|
||||
setReaderError(err.message || 'Failed to load chapter.');
|
||||
setReaderVerses([]);
|
||||
@@ -112,8 +149,8 @@ export function useBibleReader(currentPage) {
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 'reader') return;
|
||||
loadReaderChapter(readerBookAbbrev, readerChapter);
|
||||
}, [currentPage, readerBookAbbrev, readerChapter]);
|
||||
loadReaderChapter(readerBookAbbrev, readerChapter, readerTranslation);
|
||||
}, [currentPage, readerBookAbbrev, readerChapter, readerTranslation]);
|
||||
|
||||
// After navigating to a verse (from the bookmarks panel or a whole-Bible search
|
||||
// result), scroll to it and briefly flash its background so it's easy to spot.
|
||||
@@ -294,6 +331,8 @@ export function useBibleReader(currentPage) {
|
||||
return {
|
||||
readerBookAbbrev, setReaderBookAbbrev,
|
||||
readerChapter, setReaderChapter,
|
||||
readerTranslation, setReaderTranslation,
|
||||
readerCopyright,
|
||||
readerVerses,
|
||||
readerTotalChapters,
|
||||
readerLoading,
|
||||
|
||||
@@ -6,18 +6,20 @@ import AudioPlayer from '../components/AudioPlayer.jsx';
|
||||
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
|
||||
import { getPlan, getPlanSchedule, findDayIndexForRef } from '../utils/readingPlans.js';
|
||||
import DrawCanvas from './DrawCanvas.jsx';
|
||||
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
|
||||
|
||||
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
|
||||
|
||||
// Copies "Book 3:16 BSB — text" to the clipboard.
|
||||
const copyVerse = (bookName, chapterNum, verseNum, text) => {
|
||||
navigator.clipboard?.writeText(`${bookName} ${chapterNum}:${verseNum} BSB — ${text}`);
|
||||
// Copies "Book 3:16 NLT — text" to the clipboard.
|
||||
const copyVerse = (bookName, chapterNum, verseNum, text, translation) => {
|
||||
navigator.clipboard?.writeText(`${bookName} ${chapterNum}:${verseNum} ${translation} — ${text}`);
|
||||
};
|
||||
|
||||
export default function ReaderPage() {
|
||||
const {
|
||||
goHome,
|
||||
readerBookAbbrev,
|
||||
readerTranslation, setReaderTranslation,
|
||||
readerChapter, setReaderChapter,
|
||||
readerVerses,
|
||||
readerTotalChapters,
|
||||
@@ -63,6 +65,7 @@ export default function ReaderPage() {
|
||||
} = useApp();
|
||||
|
||||
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
||||
const readerNotice = READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.notice ?? '';
|
||||
|
||||
// Active reading plans whose schedule includes the chapter currently on screen.
|
||||
const planMatches = useMemo(() => {
|
||||
@@ -300,7 +303,7 @@ export default function ReaderPage() {
|
||||
<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">Read the Bible (BSB)</h1>
|
||||
<h1 className="mt-2 text-2xl font-semibold">Read the Bible ({readerTranslation})</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -324,6 +327,16 @@ export default function ReaderPage() {
|
||||
|
||||
{/* Navigation + tools bar */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
|
||||
<label className="text-sm text-slate-600">
|
||||
Translation{' '}
|
||||
<select
|
||||
value={readerTranslation}
|
||||
onChange={(e) => setReaderTranslation(e.target.value)}
|
||||
className="rounded-xl border border-slate-300 px-2 py-1 text-sm"
|
||||
>
|
||||
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-600">
|
||||
Book{' '}
|
||||
<select
|
||||
@@ -442,7 +455,7 @@ export default function ReaderPage() {
|
||||
<button type="button"
|
||||
onClick={() => { setReaderSearchScope('bible'); if (bibleIndexStatus === 'idle') loadBibleIndex(); }}
|
||||
className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'bible' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
|
||||
Whole Bible
|
||||
Whole Bible (BSB)
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -577,8 +590,9 @@ export default function ReaderPage() {
|
||||
{!readerDrawMode && !readerPageMode && (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||
<h2 className="mb-1 text-xl font-semibold text-slate-900">
|
||||
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
|
||||
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">({readerTranslation})</span>
|
||||
</h2>
|
||||
{readerNotice && <p className="mb-2 text-[11px] leading-snug text-slate-400">{readerNotice}</p>}
|
||||
{readerInterlinear && (
|
||||
<p className="mb-4 text-xs text-slate-400">Click a verse number to see original words & pronunciation · bookmark icon to save · copy icon to copy</p>
|
||||
)}
|
||||
@@ -639,7 +653,7 @@ export default function ReaderPage() {
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
|
||||
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text, readerTranslation)}
|
||||
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||
title="Copy verse">
|
||||
<CopyIcon />
|
||||
@@ -727,8 +741,9 @@ export default function ReaderPage() {
|
||||
onPointerUp={handleSelectionEnd}>
|
||||
<div className="px-6 pt-6 pb-3 break-inside-avoid">
|
||||
<h2 className="mb-1 text-xl font-semibold text-slate-900">
|
||||
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
|
||||
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">({readerTranslation})</span>
|
||||
</h2>
|
||||
{readerNotice && <p className="mb-2 text-[11px] leading-snug text-slate-400">{readerNotice}</p>}
|
||||
{readerInterlinear
|
||||
? <p className="text-xs text-slate-400">Click a verse number to see original words & pronunciation</p>
|
||||
: <p className="text-xs text-slate-400">Hover a verse for actions</p>
|
||||
@@ -774,7 +789,7 @@ export default function ReaderPage() {
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
|
||||
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text, readerTranslation)}
|
||||
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||
title="Copy verse">
|
||||
<CopyIcon />
|
||||
@@ -859,7 +874,7 @@ export default function ReaderPage() {
|
||||
onPointerUp={handleSelectionEnd}>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold text-slate-900">{readerBook?.name} {readerChapter}</span>
|
||||
<span className="ml-2 text-xs text-slate-400">BSB</span>
|
||||
<span className="ml-2 text-xs text-slate-400">{readerTranslation}</span>
|
||||
<p className="mt-1 text-xs text-slate-400 font-sans">Select text to highlight</p>
|
||||
</div>
|
||||
{!readerLoading && readerVerses.map((v) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
|
||||
import AuthStatus from '../components/AuthStatus.jsx';
|
||||
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.jsx';
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ export default function ReadingPlansPage() {
|
||||
readingPlanSyncStatus,
|
||||
readingPlanSyncError,
|
||||
retryReadingPlanSync,
|
||||
readerTranslation, setReaderTranslation,
|
||||
} = useApp();
|
||||
const [builderMode, setBuilderMode] = useState(null); // null | 'new' | a custom plan id to edit
|
||||
|
||||
@@ -70,6 +72,16 @@ export default function ReadingPlansPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-slate-300">
|
||||
Read in{' '}
|
||||
<select
|
||||
value={readerTranslation}
|
||||
onChange={(e) => setReaderTranslation(e.target.value)}
|
||||
className="rounded-xl border border-slate-500 bg-slate-800 px-2 py-1.5 text-sm text-white"
|
||||
>
|
||||
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
|
||||