Beta: admin assets, resource download forms, and resource page redesign

This commit is contained in:
nmemmert
2026-04-30 15:47:10 -04:00
parent 10bd6848ef
commit 7f56060d6b
14 changed files with 2000 additions and 1029 deletions
+287 -148
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import type { ChangeEvent } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './App'
import { DEFAULTS } from './App'
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
import { DEFAULTS } from './content'
interface Props {
content: SiteContent
@@ -66,6 +66,7 @@ interface AdminAsset {
url: string
sizeBytes: number
updatedAt: string
tags?: string[]
}
interface PublishState {
@@ -94,7 +95,7 @@ interface Question {
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal'>
type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share'
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share'
const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; description: string }> = [
{
@@ -110,7 +111,12 @@ const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; desc
{
id: 'about',
title: 'About Section',
description: 'Manage the main show description and Nate bio content.',
description: 'Manage the main show description, Nate bio, and about images.',
},
{
id: 'contact',
title: 'Contact Section',
description: 'Manage the contact page profile image and contact copy.',
},
{
id: 'series',
@@ -178,6 +184,9 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true, section: 'about' },
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true, section: 'about' },
{ key: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' },
{ key: 'aboutPhotoUrl', label: 'About Section Photo URL', section: 'about' },
{ key: 'aboutVerseArtUrl', label: 'About Verse Art Image URL', section: 'about' },
{ key: 'contactPhotoUrl', label: 'Contact Profile Photo URL', section: 'contact' },
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
@@ -195,8 +204,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [adminTab, setAdminTab] = useState<'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand'>('content')
const [contentTab, setContentTab] = useState<'main' | 'custom'>('main')
const [adminTab, setAdminTab] = useState<'content' | 'episodes' | 'settings' | 'analytics' | 'questions' | 'brand' | 'assets'>('content')
const [contentTab, setContentTab] = useState<'main' | 'resources' | 'custom'>('main')
const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [maintenanceMsg, setMaintenanceMsg] = useState('')
@@ -206,6 +215,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
const [assets, setAssets] = useState<AdminAsset[]>([])
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
const [assetUploadPending, setAssetUploadPending] = useState(false)
@@ -274,13 +284,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
})
.catch(() => {})
fetch('/api/admin-assets')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load assets'))))
.then(data => {
const list = Array.isArray((data as { assets?: unknown }).assets) ? (data as { assets: AdminAsset[] }).assets : []
setAssets(list)
})
.catch(() => {})
void reloadAssets()
fetch('/api/admin-ops/status')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status'))))
@@ -346,7 +350,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const r = await fetch('/api/admin-assets')
if (!r.ok) throw new Error('Could not refresh assets')
const data = await r.json() as { assets?: AdminAsset[] }
setAssets(Array.isArray(data.assets) ? data.assets : [])
const incomingAssets = Array.isArray(data.assets) ? data.assets : []
setAssets(incomingAssets)
setAssetTagEdits(incomingAssets.reduce<Record<string, string>>((memo, asset) => {
memo[asset.filename] = (asset.tags ?? []).join(', ')
return memo
}, {}))
}
async function reloadOpsStatus() {
@@ -371,6 +380,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
const data = await res.json() as { updatedAt?: string }
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
setLastSavedSnapshot(JSON.stringify(form))
setStatus('saved')
setTimeout(() => setStatus('idle'), 3500)
} catch (err) {
@@ -564,6 +574,30 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
}
function handleAssetTagChange(filename: string, value: string) {
setAssetTagEdits(prev => ({ ...prev, [filename]: value }))
}
async function handleSaveAssetTags(filename: string) {
const tagsText = assetTagEdits[filename] ?? ''
const tags = tagsText.split(',').map(tag => tag.trim()).filter(Boolean)
try {
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Save failed')
}
await reloadAssets()
setOpsMsg('Asset tags saved.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Save failed.')
}
}
async function handlePurgeCache() {
try {
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
@@ -701,18 +735,43 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setForm(f => ({ ...f, [key]: value }))
}
function renderImageAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) {
const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))]
const currentValue = value?.trim() ?? ''
if (currentValue && !assetOptions.some(item => item.value === currentValue)) {
assetOptions.unshift({ label: `Current image (${currentValue})`, value: currentValue })
}
if (assetOptions.length === 0) return null
return (
<div className="admin-field-asset-picker">
<label htmlFor={fieldId}>Choose an existing image</label>
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
<option value="">Select image</option>
{assetOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)
}
function confirmLeaveUnsavedChanges() {
if (!isDirty) return true
return confirm('You have unsaved changes. Leave this section without saving?')
}
function handleAdminTabChange(nextTab: 'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand') {
function handleAdminTabChange(nextTab: 'content' | 'episodes' | 'settings' | 'analytics' | 'questions' | 'brand' | 'assets') {
if (nextTab === adminTab) return
if (!confirmLeaveUnsavedChanges()) return
setAdminTab(nextTab)
}
function handleContentTabChange(nextTab: 'main' | 'custom') {
function handleContentTabChange(nextTab: 'main' | 'resources' | 'custom') {
if (nextTab === contentTab) return
if (!confirmLeaveUnsavedChanges()) return
setContentTab(nextTab)
@@ -723,12 +782,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
...f,
customLinks: [
...(f.customLinks ?? []),
{ id: Date.now().toString(36), label: '', url: '', placement: 'platforms' as const },
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'platforms' as const },
],
}))
}
function updateLink(id: string, field: keyof CustomLink, value: string) {
function addResource() {
setForm(f => ({
...f,
customLinks: [
...(f.customLinks ?? []),
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'resources' as const },
],
}))
}
function updateLink(id: string, field: keyof CustomLink, value: string | string[]) {
setForm(f => ({
...f,
customLinks: (f.customLinks ?? []).map(l => l.id === id ? { ...l, [field]: value } : l),
@@ -985,29 +1054,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setContentTab('custom')
}
async function handleSave() {
setStatus('saving')
setErrorMsg('')
try {
const res = await fetch('/api/admin-content', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ siteContent: form }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Save failed')
}
onSave(form)
setLastSavedSnapshot(JSON.stringify(form))
setStatus('saved')
setTimeout(() => setStatus('idle'), 3500)
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
setStatus('error')
}
}
function handleReset() {
if (confirm('Reset all fields to defaults?')) {
setForm(DEFAULTS)
@@ -1147,11 +1193,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<button
type="button"
role="tab"
aria-selected={adminTab === 'publish'}
className={`admin-tab ${adminTab === 'publish' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('publish')}
aria-selected={adminTab === 'assets'}
className={`admin-tab ${adminTab === 'assets' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('assets')}
>
Publish
Assets
</button>
<button
type="button"
@@ -1163,6 +1209,25 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
Brand
</button>
</div>
<div className="admin-action-toolbar">
<div className="admin-action-toolbar-meta">
{isDirty && <span className="admin-status admin-status--warn">Unsaved changes</span>}
<span>Draft saved: {formatDate(publishState.draftUpdatedAt)}</span>
<span>Last published: {formatDate(publishState.publishedAt)}</span>
</div>
<div className="admin-actions admin-actions--toolbar">
<button
type="button"
className="btn-admin-save"
onClick={handleSaveDraft}
disabled={status === 'saving' || !isDirty}
>
{status === 'saving' ? 'Saving…' : 'Save Draft'}
</button>
<button type="button" className="btn-admin-reset" onClick={openDraftPreview}>Preview Draft</button>
<button type="button" className="btn-admin-reset" onClick={handlePublishDraft}>Publish Draft</button>
</div>
</div>
{adminTab === 'brand' && (
<section className="admin-brand-kit" aria-label="Brand kit">
@@ -1225,64 +1290,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section>
)}
{adminTab === 'publish' && (
<section className="admin-stats" aria-label="Publishing and deployment">
<div className="admin-stats-head">
<h2>Publishing Workflow</h2>
<p>Save drafts, preview before release, and publish when ready.</p>
</div>
<div className="admin-stats-grid">
<article>
<h3>Draft Updated</h3>
<p>{formatDate(publishState.draftUpdatedAt)}</p>
</article>
<article>
<h3>Last Published</h3>
<p>{formatDate(publishState.publishedAt)}</p>
</article>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-save" onClick={handleSaveDraft}>Save Draft</button>
<button type="button" className="btn-admin-reset" onClick={openDraftPreview}>Preview Draft</button>
<button type="button" className="btn-admin-reset" onClick={handlePublishDraft}>Publish Draft</button>
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Deployment and Cache</h2>
<p>Trigger deployment hooks and cache purge hooks.</p>
</div>
<div className="admin-stats-grid">
<article>
<h3>Build Commit</h3>
<p>{opsStatus?.buildCommit ?? 'Not available'}</p>
</article>
<article>
<h3>Build Number</h3>
<p>{opsStatus?.buildNumber ?? 'Not available'}</p>
</article>
<article>
<h3>Deployed At</h3>
<p>{formatDate(opsStatus?.deployedAt ?? null)}</p>
</article>
<article>
<h3>Cache Purge</h3>
<p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p>
<p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p>
</article>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={handlePurgeCache}>Purge Cache</button>
<button type="button" className="btn-admin-reset" onClick={handleDeployHook}>Trigger Deploy</button>
<button type="button" className="btn-admin-reset" onClick={() => { void reloadOpsStatus() }}>Refresh Status</button>
</div>
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
{status === 'error' && <p className="admin-status admin-status--err"> {errorMsg}</p>}
{status === 'saved' && <p className="admin-status admin-status--ok"> Draft action completed.</p>}
</section>
)}
{adminTab === 'episodes' && (
<section className="admin-stats" aria-label="Episode highlights">
@@ -1330,16 +1337,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
))}
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button>
<div className="admin-actions" style={{ marginTop: '2rem' }}>
<button
type="button"
className="btn-admin-save"
onClick={handleSave}
disabled={status === 'saving'}
>
{status === 'saving' ? 'Saving…' : 'Save Changes'}
</button>
</div>
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
{status === 'saved' && <p className="admin-status admin-status--ok"> Changes saved.</p>}
{status === 'error' && <p className="admin-status admin-status--err"> {errorMsg}</p>}
</section>
@@ -1373,6 +1371,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<div className="admin-field">
<label htmlFor="seo-og-image">Open Graph Image URL</label>
<input id="seo-og-image" type="text" value={form.seo?.ogImage ?? ''} onChange={e => updateSeoField('ogImage', e.target.value)} />
{renderImageAssetSelector(form.seo?.ogImage, value => updateSeoField('ogImage', value), 'seo-og-image-asset')}
</div>
<div className="admin-field">
<label htmlFor="seo-canonical">Canonical URL</label>
@@ -1457,28 +1456,39 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</div>
</div>
<div className="admin-stats-head admin-stats-head--visitors">
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
{status === 'saved' && <p className="admin-status admin-status--ok"> Changes saved.</p>}
{status === 'error' && <p className="admin-status admin-status--err"> {errorMsg}</p>}
</section>
)}
{adminTab === 'assets' && (
<section className="admin-stats" aria-label="Asset manager">
<div className="admin-stats-head">
<h2>Asset Manager</h2>
<p>Upload and reuse hosted images from this domain.</p>
<p>Upload, tag, and reuse hosted images from this domain.</p>
</div>
<div className="admin-actions admin-actions--maintenance">
<label className="btn-admin-reset" style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}>
{assetUploadPending ? 'Uploading...' : 'Upload Image'}
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={handleAssetUpload} style={{ display: 'none' }} disabled={assetUploadPending} />
</label>
</div>
{assets.length === 0 ? (
<p className="admin-stats-note">No uploaded assets yet.</p>
) : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<table className="admin-visits-table admin-assets-table">
<thead>
<tr>
<th>Preview</th>
<th>URL</th>
<th>Tags</th>
<th>Size</th>
<th>Updated</th>
<th>Action</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -1486,9 +1496,20 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<tr key={asset.filename}>
<td><img src={asset.url} alt={asset.filename} style={{ width: '68px', height: '68px', objectFit: 'cover', borderRadius: '8px' }} /></td>
<td>{asset.url}</td>
<td>
<input
type="text"
value={assetTagEdits[asset.filename] ?? ''}
onChange={e => handleAssetTagChange(asset.filename, e.target.value)}
placeholder="comma separated tags"
/>
</td>
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
<td>{formatDate(asset.updatedAt)}</td>
<td><button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button></td>
<td className="admin-assets-actions">
<button type="button" className="btn-admin-apply" onClick={() => handleSaveAssetTags(asset.filename)}>Save Tags</button>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
</td>
</tr>
))}
</tbody>
@@ -1496,19 +1517,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</div>
)}
<div className="admin-actions" style={{ marginTop: '2rem' }}>
<button
type="button"
className="btn-admin-save"
onClick={handleSave}
disabled={status === 'saving'}
>
{status === 'saving' ? 'Saving…' : 'Save Changes'}
</button>
</div>
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
{status === 'saved' && <p className="admin-status admin-status--ok"> Changes saved.</p>}
{status === 'error' && <p className="admin-status admin-status--err"> {errorMsg}</p>}
</section>
)}
{adminTab === 'analytics' && (
@@ -1731,6 +1740,31 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</article>
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Deployment & Cache Status</h2>
<p>Current deployment metadata and cache purge health for the live app.</p>
</div>
<div className="admin-stats-grid">
<article>
<h3>Build Commit</h3>
<p>{opsStatus?.buildCommit ?? 'Not available'}</p>
</article>
<article>
<h3>Build Number</h3>
<p>{opsStatus?.buildNumber ?? 'Not available'}</p>
</article>
<article>
<h3>Deployed At</h3>
<p>{formatDate(opsStatus?.deployedAt ?? null)}</p>
</article>
<article>
<h3>Cache Purge</h3>
<p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p>
<p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p>
</article>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={handleExport}>Export JSON</button>
<button type="button" className="btn-admin-reset" onClick={handleBackupNow}>Backup Now</button>
@@ -1738,6 +1772,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<button type="button" className="btn-admin-remove" onClick={handleClear}>Clear Analytics</button>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={handlePurgeCache}>Purge Cache</button>
<button type="button" className="btn-admin-reset" onClick={handleDeployHook}>Trigger Deploy</button>
<button type="button" className="btn-admin-reset" onClick={() => { void reloadOpsStatus() }}>Refresh Status</button>
</div>
<div className="admin-restore-row">
<label htmlFor="restore-backup">Restore Backup</label>
<select
@@ -1776,7 +1816,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{adminTab === 'content' && (
<form
className="admin-form"
onSubmit={e => { e.preventDefault(); handleSave() }}
onSubmit={e => { e.preventDefault(); handleSaveDraft() }}
>
<div className="admin-tabs" role="tablist" aria-label="Content editor tabs">
<button
@@ -1788,6 +1828,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
>
Main Content
</button>
<button
type="button"
role="tab"
aria-selected={contentTab === 'resources'}
className={`admin-tab ${contentTab === 'resources' ? 'admin-tab--active' : ''}`}
onClick={() => handleContentTabChange('resources')}
>
Resources
</button>
<button
type="button"
role="tab"
@@ -1836,12 +1885,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
rows={4}
/>
) : (
<input
id={`field-${key}`}
type="text"
value={form[key] as string}
onChange={e => handleChange(key, e.target.value)}
/>
<>
<input
id={`field-${key}`}
type="text"
value={form[key] as string}
onChange={e => handleChange(key, e.target.value)}
/>
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
</>
)}
</div>
))}
@@ -1852,12 +1904,84 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</>
)}
{contentTab === 'resources' && (
<>
<div className="admin-content-summary">
<div className="admin-summary-card">
<h3>Resource Links</h3>
<p>{(form.customLinks ?? []).filter(link => link.placement === 'resources').length}</p>
</div>
</div>
<div className="admin-section-header">
<h3>More Resources</h3>
<p>Manage additional resources shown on the More Resources page.</p>
</div>
{(form.customLinks ?? []).filter(link => link.placement === 'resources').length === 0 && (
<p className="admin-stats-note">No resources yet.</p>
)}
{(form.customLinks ?? []).filter(link => link.placement === 'resources').map(link => (
<div key={link.id} className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`resource-label-${link.id}`}>Label</label>
<input
id={`resource-label-${link.id}`}
type="text"
value={link.label}
placeholder="Resource title"
onChange={e => updateLink(link.id, 'label', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`resource-url-${link.id}`}>URL</label>
<input
id={`resource-url-${link.id}`}
type="url"
value={link.url}
placeholder="https://..."
onChange={e => updateLink(link.id, 'url', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`resource-image-${link.id}`}>Image URL</label>
<input
id={`resource-image-${link.id}`}
type="text"
value={link.imageUrl ?? ''}
placeholder="/uploads/example.png"
onChange={e => updateLink(link.id, 'imageUrl', e.target.value)}
/>
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `resource-image-${link.id}-asset`)}
</div>
<div className="admin-field">
<label htmlFor={`resource-tags-${link.id}`}>Tags</label>
<input
id={`resource-tags-${link.id}`}
type="text"
value={(link.tags ?? []).join(', ')}
placeholder="sermon, bible study, faith"
onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))}
/>
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>
Remove
</button>
</div>
))}
<button type="button" className="btn-admin-add" onClick={addResource}>
+ Add Resource
</button>
</>
)}
{contentTab === 'custom' && (
<>
<div className="admin-content-summary">
<div className="admin-summary-card">
<h3>Custom Links</h3>
<p>{(form.customLinks ?? []).length}</p>
<p>{(form.customLinks ?? []).filter(link => link.placement !== 'resources').length}</p>
</div>
<div className="admin-summary-card">
<h3>Custom Blocks</h3>
@@ -1871,12 +1995,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<div className="admin-section-header">
<h3>Custom Links</h3>
<p>Add links to show in the platform buttons row, footer, or a dedicated "More Resources" section.</p>
<p>Add links to show in the platform buttons row or footer navigation.</p>
</div>
{(form.customLinks ?? []).length === 0 && (
{(form.customLinks ?? []).filter(link => link.placement !== 'resources').length === 0 && (
<p className="admin-stats-note">No custom links yet.</p>
)}
{(form.customLinks ?? []).map(link => (
{(form.customLinks ?? []).filter(link => link.placement !== 'resources').map(link => (
<div key={link.id} className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
@@ -1899,6 +2023,27 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
onChange={e => updateLink(link.id, 'url', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`link-image-${link.id}`}>Image URL</label>
<input
id={`link-image-${link.id}`}
type="text"
value={link.imageUrl ?? ''}
placeholder="/uploads/example.png"
onChange={e => updateLink(link.id, 'imageUrl', e.target.value)}
/>
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `link-image-${link.id}-asset`)}
</div>
<div className="admin-field">
<label htmlFor={`link-tags-${link.id}`}>Tags</label>
<input
id={`link-tags-${link.id}`}
type="text"
value={(link.tags ?? []).join(', ')}
placeholder="listen, podcast, study"
onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))}
/>
</div>
<div className="admin-field">
<label htmlFor={`link-placement-${link.id}`}>Show in</label>
<select
@@ -2020,6 +2165,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
placeholder="/images/titus-cover.png"
onChange={e => updateArchivedSeries(series.id, 'imageUrl', e.target.value)}
/>
{renderImageAssetSelector(series.imageUrl, value => updateArchivedSeries(series.id, 'imageUrl', value), `archive-image-${series.id}-asset`)}
</div>
<div className="admin-field">
<label htmlFor={`archive-listen-${series.id}`}>Listen URL</label>
@@ -2184,13 +2330,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
)}
<div className="admin-actions">
<button
type="submit"
className="btn-admin-save"
disabled={status === 'saving'}
>
{status === 'saving' ? 'Saving…' : 'Save Changes'}
</button>
<button
type="button"
className="btn-admin-reset"
+225 -34
View File
@@ -1455,29 +1455,89 @@
}
.resources-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.resource-link {
font-family: var(--brand-font-body);
font-weight: 500;
font-size: 1rem;
letter-spacing: 0.06em;
color: var(--brand-gold);
text-decoration: none;
border: 1px solid rgba(201, 168, 76, 0.3);
border-radius: 999px;
padding: 0.6rem 1.4rem;
transition: background 200ms, color 200ms;
}
.resource-link {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 1rem;
min-height: 86px;
padding: 1rem 1.2rem;
font-family: var(--brand-font-body);
font-weight: 600;
font-size: 1rem;
letter-spacing: 0.02em;
color: var(--brand-warm-white);
text-decoration: none;
background: #111;
border: 1px solid rgba(201, 168, 76, 0.18);
border-radius: 18px;
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
}
.resource-link:hover {
background: rgba(201, 168, 76, 0.1);
color: #d4b87a;
}
.resource-link:hover {
transform: translateY(-1px);
background: rgba(201, 168, 76, 0.08);
border-color: rgba(201, 168, 76, 0.35);
}
.resource-link--with-image {
grid-template-columns: auto 1fr auto;
}
.resource-download-card {
background: #111;
border: 1px solid rgba(201, 168, 76, 0.18);
border-radius: 18px;
padding: 1rem;
display: grid;
gap: 1rem;
}
.resource-download-header {
display: flex;
gap: 1rem;
align-items: center;
}
.resource-download-meta {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.resource-link-label {
display: block;
font-size: 1rem;
color: var(--brand-warm-white);
}
.resource-link-action {
color: var(--brand-gold);
font-size: 0.95rem;
white-space: nowrap;
}
.resource-link-image {
width: 72px;
height: 72px;
border-radius: 16px;
object-fit: cover;
border: 1px solid rgba(201, 168, 76, 0.2);
flex-shrink: 0;
}
.resource-link-tags {
margin-top: 0.15rem;
color: rgba(201, 168, 76, 0.9);
font-size: 0.88rem;
letter-spacing: 0.03em;
}
/* ── Custom content blocks ── */
.section-custom-block {
@@ -1783,7 +1843,8 @@
}
.admin-form-wrap {
max-width: 760px;
max-width: 1400px;
width: min(100%, 1400px);
margin: 0 auto;
padding: 3rem 2rem 5rem;
}
@@ -1795,6 +1856,31 @@
margin-bottom: 1.1rem;
}
.admin-action-toolbar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.admin-action-toolbar-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
color: var(--brand-muted);
font-size: 0.95rem;
align-items: center;
}
.admin-actions--toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: flex-end;
}
.admin-top-tabs .admin-tab {
flex: 1 1 180px;
min-width: 180px;
@@ -2111,6 +2197,28 @@
margin-top: 0.9rem;
}
.admin-assets-table input[type="text"] {
width: 100%;
padding: 0.35rem 0.45rem;
border: 1px solid rgba(201, 168, 76, 0.18);
border-radius: 8px;
background: #090909;
color: var(--brand-warm-white);
}
.admin-assets-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.resource-link-tags {
margin-top: 0.35rem;
color: var(--brand-gold);
font-size: 0.88rem;
opacity: 0.9;
}
.admin-restore-row {
margin-top: 0.9rem;
display: flex;
@@ -2303,11 +2411,16 @@
}
.admin-form-section-fields {
display: flex;
flex-direction: column;
display: grid;
gap: 1rem;
}
@media (min-width: 1024px) {
.admin-form-section-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.admin-form-section .admin-section-header {
border-top: none;
padding-top: 0;
@@ -2424,21 +2537,61 @@
/* ── Admin: select input ── */
.admin-field select {
background: #111;
border: 1px solid rgba(201, 168, 76, 0.22);
border: 1px solid rgba(201, 168, 76, 0.35);
border-radius: 6px;
color: var(--brand-warm-white);
font-family: var(--brand-font-body);
font-size: 1rem;
font-weight: 300;
padding: 0.65rem 0.85rem;
padding: 0.65rem 1rem 0.65rem 0.85rem;
outline: none;
appearance: none;
transition: border-color 200ms;
appearance: auto;
transition: border-color 200ms, background-image 200ms;
cursor: pointer;
background-image: linear-gradient(45deg, transparent 50%, rgba(255,255,255,0.9) 50%), linear-gradient(135deg, rgba(255,255,255,0.9) 50%, transparent 50%);
background-position: calc(100% - 0.75rem) calc(50% - 0.15rem), calc(100% - 0.4rem) calc(50% - 0.15rem);
background-size: 0.45rem 0.45rem, 0.45rem 0.45rem;
background-repeat: no-repeat;
}
.admin-field select:focus {
border-color: rgba(201, 168, 76, 0.65);
border-color: rgba(201, 168, 76, 0.75);
}
.admin-field-asset-picker {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.75rem;
padding: 0.85rem 0.9rem;
border: 1px solid rgba(201, 168, 76, 0.16);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
.admin-field-asset-picker label {
color: var(--brand-gold);
}
.admin-field-asset-picker label {
font-size: 0.78rem;
color: var(--brand-gold);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.admin-field-asset-picker select {
width: 100%;
min-width: 0;
background: #111;
border: 1px solid rgba(201, 168, 76, 0.45);
border-radius: 6px;
color: var(--brand-warm-white);
padding: 0.65rem 0.85rem;
}
.admin-field-asset-picker select:focus {
border-color: rgba(201, 168, 76, 0.75);
}
/* ── Admin: section divider ── */
@@ -2596,12 +2749,29 @@
margin-bottom: 0.75rem;
}
.back-to-top {
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
z-index: 20;
background: rgba(12, 12, 12, 0.96);
color: var(--brand-gold);
border: 1px solid rgba(201, 168, 76, 0.45);
border-radius: 999px;
padding: 0.85rem 1rem;
cursor: pointer;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25);
font-family: var(--brand-font-body);
font-weight: 700;
transition: transform 0.25s ease, background 0.2s ease;
}
.back-to-top:hover {
transform: translateY(-2px);
background: rgba(201, 168, 76, 0.08);
}
.btn-admin-add {
background: transparent;
color: var(--brand-gold);
font-family: var(--brand-font-body);
font-weight: 600;
font-size: 0.95rem;
letter-spacing: 0.06em;
padding: 0.6rem 1.2rem;
border-radius: 999px;
@@ -2637,6 +2807,27 @@
border-color: rgba(224, 92, 92, 0.4);
}
.btn-admin-apply {
background: transparent;
color: var(--brand-gold);
font-family: var(--brand-font-body);
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.05em;
padding: 0.5rem 0.85rem;
border-radius: 6px;
border: 1px solid rgba(201, 168, 76, 0.35);
cursor: pointer;
flex-shrink: 0;
align-self: flex-start;
transition: background 200ms, border-color 200ms;
}
.btn-admin-apply:hover {
background: rgba(201, 168, 76, 0.08);
border-color: rgba(201, 168, 76, 0.65);
}
/* ── Responsive ── */
@media (max-width: 820px) {
.hero {
+142 -561
View File
@@ -1,6 +1,11 @@
import { useState, useEffect } from 'react'
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
import AdminPage from './AdminPage'
import QASection from './components/QASection'
import ContactForm from './components/ContactForm'
import { FacebookIcon, SpotifyIcon, YouTubeIcon, AmazonMusicIcon } from './icons'
import type { SiteContent } from './content'
import { DEFAULTS } from './content'
import './App.css'
const SPOTIFY_SHOW_URL = '/spotify'
@@ -13,546 +18,6 @@ const AMAZON_MUSIC_URL = '/amazon'
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
function FacebookIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.234 2.686.234v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
</svg>
)
}
export interface CustomLink {
id: string
label: string
url: string
placement: 'platforms' | 'footer' | 'resources'
}
export interface CustomBlock {
id: string
heading: string
body: string
}
export interface ArchivedSeriesResourceLink {
id: string
label: string
url: string
}
export interface ArchivedSeriesNote {
id: string
heading: string
body: string
}
export interface ArchivedSeries {
id: string
label: string
title: string
description: string
imageUrl: string
listenUrl: string
studyGuideTitle: string
studyGuideDescription: string
studyGuideUrl: string
resourceLinks: ArchivedSeriesResourceLink[]
notes: ArchivedSeriesNote[]
}
export interface RedirectRule {
id: string
path: string
target: string
statusCode: 301 | 302
}
export interface PodcastFeaturedLink {
id: string
title: string
episodeNumber?: string
summary: string
url: string
embedUrl?: string
showNotes?: string
discussionQuestions?: string[]
}
export interface SeoSettings {
title: string
description: string
ogTitle: string
ogDescription: string
ogImage: string
canonicalUrl: string
robotsPolicy: string
sitemapPaths: string[]
}
export interface LegalSettings {
privacyTitle: string
privacyBody: string[]
termsTitle: string
termsBody: string[]
}
export interface SiteContent {
eyebrow: string
heroTagline: string
startHereHeading: string
startHereIntro: string
startHereStep1Title: string
startHereStep1Body: string
startHereStep1Cta: string
startHereStep2Title: string
startHereStep2Body: string
startHereStep2Cta: string
startHereStep3Title: string
startHereStep3Body: string
startHereStep3Cta: string
aboutShowHeading: string
aboutShowP1: string
aboutShowP2: string
aboutNate: string
seriesLabel: string
seriesTitle: string
seriesDescription: string
seriesImageUrl: string
seriesListenUrl: string
studyGuideTitle: string
studyGuideDescription: string
studyGuideUrl: string
shareHeading: string
shareP: string
customLinks: CustomLink[]
customBlocks: CustomBlock[]
archivedSeries: ArchivedSeries[]
redirects: RedirectRule[]
podcastFeaturedLinks: PodcastFeaturedLink[]
seo: SeoSettings
legal: LegalSettings
}
export const DEFAULTS: SiteContent = {
eyebrow: 'A Journey Through Scripture',
heroTagline: "Exploring God's Word one verse at a time",
startHereHeading: 'New Here? Start Here',
startHereIntro:
'If this is your first visit, this path helps you get grounded quickly and make the most of the site.',
startHereStep1Title: 'Step 1: Listen to 3 Starter Episodes',
startHereStep1Body:
'Start with the newest episode, one from the beginning of the current study, and one from the middle.',
startHereStep1Cta: 'Open Episodes',
startHereStep2Title: 'Step 2: Use Q&A to Go Deeper',
startHereStep2Body:
'Browse by topic or search key words to find concise biblical answers and related follow-up questions.',
startHereStep2Cta: 'Go to Q&A',
startHereStep3Title: 'Step 3: Ask Nate Directly',
startHereStep3Body:
'Use the contact form to submit your Bible question for future Q&A or an upcoming episode.',
startHereStep3Cta: 'Submit a Question',
aboutShowHeading: 'Depth. Clarity. Application.',
aboutShowP1:
'Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.',
aboutShowP2:
"Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
aboutNate:
"Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
seriesLabel: 'Now Playing',
seriesTitle: 'Study of Titus: Sound Doctrine',
seriesDescription:
"A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
seriesImageUrl: '/images/titus-cover.png',
seriesListenUrl: SPOTIFY_SHOW_URL,
studyGuideTitle: 'Companion Study Guide',
studyGuideDescription:
'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.',
studyGuideUrl: 'https://a.co/d/01sG2tOJ',
shareHeading: 'Help one more person hear the Word this week.',
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
customLinks: [],
customBlocks: [],
archivedSeries: [],
redirects: [
{
id: 'spotify',
path: '/spotify',
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
statusCode: 301,
},
{
id: 'apple',
path: '/apple',
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
statusCode: 301,
},
{
id: 'amazon',
path: '/amazon',
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
statusCode: 301,
},
],
podcastFeaturedLinks: [],
seo: {
title: 'Verse by Verse with Nate',
description: "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
ogTitle: 'Verse by Verse with Nate',
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
ogImage: '/images/podcast-art.jpeg',
canonicalUrl: 'https://versebyversewithnate.us/',
robotsPolicy: 'index,follow',
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
},
legal: {
privacyTitle: 'Privacy Policy',
privacyBody: [
'We respect your privacy and collect limited data to operate and improve this site.',
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
'Contact form details are used only to respond to your message and ministry communication requests.',
],
termsTitle: 'Terms',
termsBody: [
'Content on this site is for informational and ministry purposes.',
'External links are provided for convenience and are subject to third-party policies.',
'By using this site, you agree to lawful use and respectful communication.',
],
},
}
function SpotifyIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</svg>
)
}
function YouTubeIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
)
}
function AmazonMusicIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
)
}
interface PublicQuestion {
id: string
firstName: string
question: string
answer: string
topic?: string
}
const QA_PAGE_SIZE = 6
function QASection() {
const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/questions')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
.then(data => {
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
setLoading(false)
})
.catch(() => {
setLoading(false)
})
}, [])
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[]
const filteredQuestions = questions.filter(q => {
const matchesTopic = !selectedTopic || q.topic === selectedTopic
const matchesSearch = !searchQuery ||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
return matchesTopic && matchesSearch
})
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
const toggleExpanded = (id: string) => {
setExpanded(e => ({ ...e, [id]: !e[id] }))
}
const handleSearch = (value: string) => {
setSearchQuery(value)
setPage(0)
}
const handleTopic = (topic: string | null) => {
setSelectedTopic(topic)
setPage(0)
}
const tokenizeForRelated = (text: string) =>
text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(token => token.length > 3)
const getRelatedQuestions = (current: PublicQuestion) => {
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
const related = questions
.filter(candidate => candidate.id !== current.id)
.map(candidate => {
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic)
const score = overlap + (sameTopic ? 5 : 0)
return { candidate, score }
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map(item => item.candidate)
return related
}
return (
<section id="qa" className="section-qa" aria-label="Questions and answers">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Questions & Answers <span className="ornament"></span>
</h2>
{loading ? (
<p className="qa-no-results">Loading questions</p>
) : questions.length === 0 ? (
<div className="qa-no-results">
<p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p>
</div>
) : (
<>
<div className="qa-filters">
<div className="qa-topics">
<button
className={`qa-topic-btn${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(null)}
>
All
</button>
{topics.map(topic => (
<button
key={topic}
className={`qa-topic-btn${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(topic)}
>
{topic}
</button>
))}
</div>
<div className="qa-search">
<label>
<span className="visually-hidden">Search Questions</span>
<input
type="text"
placeholder="Search questions…"
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
/>
</label>
</div>
</div>
{filteredQuestions.length === 0 ? (
<div className="qa-no-results">
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
</div>
) : (
<>
<div className="qa-cards">
{pagedQuestions.map(question => (
<div key={question.id} className="qa-card-scene">
<div
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
onClick={() => toggleExpanded(question.id)}
role="button"
tabIndex={0}
aria-expanded={!!expanded[question.id]}
aria-label={question.question}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && toggleExpanded(question.id)}
>
<div className="qa-card-face qa-card-front">
<span className="qa-face-label">Q</span>
<p className="qa-question-text">{question.question}</p>
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
</div>
<div className="qa-card-face qa-card-back">
<span className="qa-face-label">A</span>
<p className="qa-answer-text">{question.answer}</p>
{getRelatedQuestions(question).length > 0 && (
<div className="qa-related-wrap">
<p className="qa-related-label">Related questions</p>
<div className="qa-related-list">
{getRelatedQuestions(question).map(related => (
<button
key={related.id}
type="button"
className="qa-related-btn"
onClick={e => {
e.stopPropagation()
handleTopic(null)
handleSearch(related.question)
setExpanded({ [related.id]: true })
}}
>
{related.question}
</button>
))}
</div>
</div>
)}
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}> Answered by Nate</p>
</div>
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div className="qa-pagination">
<button
className="qa-page-btn"
onClick={() => setPage(p => p - 1)}
disabled={page === 0}
aria-label="Previous page"
>
Prev
</button>
<span className="qa-page-info">
{page + 1} / {totalPages}
</span>
<button
className="qa-page-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= totalPages - 1}
aria-label="Next page"
>
Next
</button>
</div>
)}
</>
)}
</>
)}
</div>
</section>
)
}
function ContactForm() {
const navigate = useNavigate()
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '', message: '', messageType: 'question' })
const [subscribe, setSubscribe] = useState(true)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('submitting')
setErrorMsg('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...fields, subscribe, _honey: honey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
setStatus('error')
return
}
navigate('/thanks')
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
<form className="contact-form" onSubmit={handleSubmit} noValidate>
<input
type="text"
className="contact-honeypot"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={honey}
onChange={e => setHoney(e.target.value)}
/>
<label>
First Name
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
</label>
<label>
Last Name
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
</label>
<label>
Email
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
</label>
<label>
Message Type
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
<option value="question">Bible Question</option>
<option value="testimony">Testimony</option>
<option value="topic">Topic Request</option>
<option value="general">General Message</option>
</select>
</label>
<label>
Message
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
</label>
<label className="contact-consent">
<input
type="checkbox"
checked={subscribe}
onChange={e => setSubscribe(e.target.checked)}
/>
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send Message'}
</button>
</form>
)
}
function StudyDownloadForm() {
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
const [subscribe, setSubscribe] = useState(true)
@@ -636,6 +101,89 @@ function StudyDownloadForm() {
)
}
function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string; buttonText: string }) {
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
const [subscribe, setSubscribe] = useState(true)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [successMsg, setSuccessMsg] = useState('')
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('submitting')
setErrorMsg('')
setSuccessMsg('')
try {
const res = await fetch('/api/resource-download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resourceId, ...fields, subscribe, _honey: honey }),
})
const data = await res.json().catch(() => ({})) as { message?: string; downloadUrl?: string }
if (!res.ok || !data.downloadUrl) {
setErrorMsg(data.message ?? 'Could not process your request. Please try again.')
setStatus('error')
return
}
setStatus('success')
setSuccessMsg('Your download should start now. If not, use the link below.')
window.location.assign(data.downloadUrl)
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
<form className="study-download-form" onSubmit={handleSubmit} noValidate>
<input
type="text"
className="contact-honeypot"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={honey}
onChange={e => setHoney(e.target.value)}
/>
<div className="study-download-grid">
<label>
First Name
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
</label>
<label>
Last Name
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
</label>
<label>
Email
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
</label>
</div>
<label className="contact-consent">
<input
type="checkbox"
checked={subscribe}
onChange={e => setSubscribe(e.target.checked)}
/>
<span>Subscribe me to updates from Verse by Verse with Nate.</span>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
{status === 'success' && <p className="study-download-success">{successMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Preparing Download...' : buttonText}
</button>
</form>
)
}
function AnalyticsConsentBanner() {
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
const saved = localStorage.getItem(CONSENT_KEY)
@@ -776,11 +324,6 @@ function LatestEpisodesList() {
function SiteHeader() {
const [menuOpen, setMenuOpen] = useState(false)
const location = useLocation()
useEffect(() => {
setMenuOpen(false)
}, [location.pathname])
return (
<header className="site-header">
@@ -796,11 +339,11 @@ function SiteHeader() {
{menuOpen ? 'Close' : 'Menu'}
</button>
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
<NavLink to="/" end className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>Home</NavLink>
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>Episodes</NavLink>
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>Resources</NavLink>
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>About</NavLink>
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>Contact</NavLink>
<NavLink to="/" end className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Home</NavLink>
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes</NavLink>
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources</NavLink>
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
<a
href={SPOTIFY_SHOW_URL}
target="_blank"
@@ -892,7 +435,7 @@ function AboutSection({ content }: { content: SiteContent }) {
<section className="section-about" aria-label="About the show">
<div className="section-inner about-inner">
<div className="about-photo">
<img src="/images/nate-photo.jpeg" alt="Nate Emmert" />
<img src={content.aboutPhotoUrl || '/images/nate-photo.jpeg'} alt="Nate Emmert" />
<div className="about-photo-divider" aria-hidden="true" />
<div className="about-nate-copy">
<p className="eyebrow">About Nate</p>
@@ -908,7 +451,7 @@ function AboutSection({ content }: { content: SiteContent }) {
<p>{content.aboutShowP2}</p>
<div className="rule-divider" aria-hidden="true" />
<img
src="/images/hebrews-4-12-verse-art.png"
src={content.aboutVerseArtUrl || '/images/hebrews-4-12-verse-art.png'}
alt="Hebrews 4:12 verse artwork"
className="about-verse-art"
/>
@@ -952,7 +495,7 @@ function StudyGuideSection({ content }: { content: SiteContent }) {
)
}
function ContactSection() {
function ContactSection({ content }: { content: SiteContent }) {
return (
<section className="section-contact" id="contact" aria-label="Contact form">
<div className="section-inner contact-inner">
@@ -963,7 +506,7 @@ function ContactSection() {
<div className="contact-profile">
<img
src="/images/nate-contact-photo.png"
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
alt="Nate"
className="contact-profile-photo"
/>
@@ -1059,10 +602,21 @@ function CustomResourcesSection({ content }: { content: SiteContent }) {
<span className="ornament"></span>
</h2>
<div className="resources-list">
{resources.map(l => (
<a key={l.id} href={l.url} target="_blank" rel="noreferrer" className="resource-link">
{l.label}
</a>
{resources.map(resource => (
<article key={resource.id} className="resource-download-card">
<div className="resource-download-header">
{resource.imageUrl && (
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{resource.label}</span>
{(resource.tags ?? []).length > 0 && (
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
)}
</div>
</div>
<ResourceDownloadForm resourceId={resource.id} buttonText={`Download ${resource.label}`} />
</article>
))}
</div>
</div>
@@ -1349,7 +903,7 @@ function ContactPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader />
<ContactSection />
<ContactSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
</div>
@@ -1789,8 +1343,9 @@ export default function App() {
}, [content])
return (
<Routes>
<Route path="/" element={<LandingPage content={content} />} />
<>
<Routes>
<Route path="/" element={<LandingPage content={content} />} />
<Route path="/start-here" element={<StartHerePage content={content} />} />
<Route path="/episodes" element={<EpisodesPage content={content} />} />
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
@@ -1822,5 +1377,31 @@ export default function App() {
)}
/>
</Routes>
<BackToTopButton />
</>
)
}
function BackToTopButton() {
const [visible, setVisible] = useState(false)
useEffect(() => {
const onScroll = () => setVisible(window.scrollY > 320)
window.addEventListener('scroll', onScroll)
onScroll()
return () => window.removeEventListener('scroll', onScroll)
}, [])
if (!visible) return null
return (
<button
type="button"
className="back-to-top"
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
aria-label="Scroll back to top"
>
Top
</button>
)
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react'
import type { ChangeEvent } from 'react'
import { useNavigate } from 'react-router-dom'
interface ContactFields {
firstName: string
lastName: string
email: string
message: string
messageType: string
}
export default function ContactForm() {
const navigate = useNavigate()
const [fields, setFields] = useState<ContactFields>({
firstName: '',
lastName: '',
email: '',
message: '',
messageType: 'question',
})
const [subscribe, setSubscribe] = useState(true)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('submitting')
setErrorMsg('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...fields, subscribe, _honey: honey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
setStatus('error')
return
}
navigate('/thanks')
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
<form className="contact-form" onSubmit={handleSubmit} noValidate>
<input
type="text"
className="contact-honeypot"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={honey}
onChange={e => setHoney(e.target.value)}
/>
<label>
First Name
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
</label>
<label>
Last Name
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
</label>
<label>
Email
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
</label>
<label>
Message Type
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
<option value="question">Bible Question</option>
<option value="testimony">Testimony</option>
<option value="topic">Topic Request</option>
<option value="general">General Message</option>
</select>
</label>
<label>
Message
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
</label>
<label className="contact-consent">
<input
type="checkbox"
checked={subscribe}
onChange={e => setSubscribe(e.target.checked)}
/>
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send Message'}
</button>
</form>
)
}
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from 'react'
import type { KeyboardEvent } from 'react'
interface PublicQuestion {
id: string
firstName: string
question: string
answer: string
topic?: string
}
const QA_PAGE_SIZE = 6
function tokenizeForRelated(text: string) {
return text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(token => token.length > 3)
}
export default function QASection() {
const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/questions')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
.then(data => {
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
setLoading(false)
})
.catch(() => {
setLoading(false)
})
}, [])
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[]
const filteredQuestions = questions.filter(q => {
const matchesTopic = !selectedTopic || q.topic === selectedTopic
const matchesSearch =
!searchQuery ||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
return matchesTopic && matchesSearch
})
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
const toggleExpanded = (id: string) => {
setExpanded(state => ({ ...state, [id]: !state[id] }))
}
const handleSearch = (value: string) => {
setSearchQuery(value)
setPage(0)
}
const handleTopic = (topic: string | null) => {
setSelectedTopic(topic)
setPage(0)
}
const getRelatedQuestions = (current: PublicQuestion) => {
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
return questions
.filter(candidate => candidate.id !== current.id)
.map(candidate => {
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic)
const score = overlap + (sameTopic ? 5 : 0)
return { candidate, score }
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map(item => item.candidate)
}
return (
<section id="qa" className="section-qa" aria-label="Questions and answers">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Questions & Answers <span className="ornament"></span>
</h2>
{loading ? (
<p className="qa-no-results">Loading questions</p>
) : questions.length === 0 ? (
<div className="qa-no-results">
<p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p>
</div>
) : (
<>
<div className="qa-filters">
<div className="qa-topics">
<button
className={`qa-topic-btn${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(null)}
>
All
</button>
{topics.map(topic => (
<button
key={topic}
className={`qa-topic-btn${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(topic)}
>
{topic}
</button>
))}
</div>
<div className="qa-search">
<label>
<span className="visually-hidden">Search Questions</span>
<input
type="text"
placeholder="Search questions…"
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
/>
</label>
</div>
</div>
{filteredQuestions.length === 0 ? (
<div className="qa-no-results">
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
</div>
) : (
<>
<div className="qa-cards">
{pagedQuestions.map(question => (
<div key={question.id} className="qa-card-scene">
<div
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
onClick={() => toggleExpanded(question.id)}
role="button"
tabIndex={0}
aria-expanded={!!expanded[question.id]}
aria-label={question.question}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') toggleExpanded(question.id)
}}
>
<div className="qa-card-face qa-card-front">
<span className="qa-face-label">Q</span>
<p className="qa-question-text">{question.question}</p>
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
</div>
<div className="qa-card-face qa-card-back">
<span className="qa-face-label">A</span>
<p className="qa-answer-text">{question.answer}</p>
{getRelatedQuestions(question).length > 0 && (
<div className="qa-related-wrap">
<p className="qa-related-label">Related questions</p>
<div className="qa-related-list">
{getRelatedQuestions(question).map(related => (
<button
key={related.id}
type="button"
className="qa-related-btn"
onClick={e => {
e.stopPropagation()
handleTopic(null)
handleSearch(related.question)
setExpanded({ [related.id]: true })
}}
>
{related.question}
</button>
))}
</div>
</div>
)}
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}>
Answered by Nate
</p>
</div>
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div className="qa-pagination">
<button
className="qa-page-btn"
onClick={() => setPage(p => p - 1)}
disabled={page === 0}
aria-label="Previous page"
>
Prev
</button>
<span className="qa-page-info">
{page + 1} / {totalPages}
</span>
<button
className="qa-page-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= totalPages - 1}
aria-label="Next page"
>
Next
</button>
</div>
)}
</>
)}
</>
)}
</div>
</section>
)
}
+206
View File
@@ -0,0 +1,206 @@
export interface CustomLink {
id: string
label: string
url: string
placement: 'platforms' | 'footer' | 'resources'
imageUrl?: string
tags?: string[]
}
export interface CustomBlock {
id: string
heading: string
body: string
}
export interface ArchivedSeriesResourceLink {
id: string
label: string
url: string
}
export interface ArchivedSeriesNote {
id: string
heading: string
body: string
}
export interface ArchivedSeries {
id: string
label: string
title: string
description: string
imageUrl: string
listenUrl: string
studyGuideTitle: string
studyGuideDescription: string
studyGuideUrl: string
resourceLinks: ArchivedSeriesResourceLink[]
notes: ArchivedSeriesNote[]
}
export interface RedirectRule {
id: string
path: string
target: string
statusCode: 301 | 302
}
export interface PodcastFeaturedLink {
id: string
title: string
episodeNumber?: string
summary: string
url: string
embedUrl?: string
showNotes?: string
discussionQuestions?: string[]
}
export interface SeoSettings {
title: string
description: string
ogTitle: string
ogDescription: string
ogImage: string
canonicalUrl: string
robotsPolicy: string
sitemapPaths: string[]
}
export interface LegalSettings {
privacyTitle: string
privacyBody: string[]
termsTitle: string
termsBody: string[]
}
export interface SiteContent {
eyebrow: string
heroTagline: string
startHereHeading: string
startHereIntro: string
startHereStep1Title: string
startHereStep1Body: string
startHereStep1Cta: string
startHereStep2Title: string
startHereStep2Body: string
startHereStep2Cta: string
startHereStep3Title: string
startHereStep3Body: string
startHereStep3Cta: string
aboutShowHeading: string
aboutShowP1: string
aboutShowP2: string
aboutNate: string
aboutPhotoUrl: string
aboutVerseArtUrl: string
contactPhotoUrl: string
seriesLabel: string
seriesTitle: string
seriesDescription: string
seriesImageUrl: string
seriesListenUrl: string
studyGuideTitle: string
studyGuideDescription: string
studyGuideUrl: string
shareHeading: string
shareP: string
customLinks: CustomLink[]
customBlocks: CustomBlock[]
archivedSeries: ArchivedSeries[]
redirects: RedirectRule[]
podcastFeaturedLinks: PodcastFeaturedLink[]
seo: SeoSettings
legal: LegalSettings
}
export const DEFAULTS: SiteContent = {
eyebrow: 'A Journey Through Scripture',
heroTagline: "Exploring God's Word one verse at a time",
startHereHeading: 'New Here? Start Here',
startHereIntro:
'If this is your first visit, this path helps you get grounded quickly and make the most of the site.',
startHereStep1Title: 'Step 1: Listen to 3 Starter Episodes',
startHereStep1Body:
'Start with the newest episode, one from the beginning of the current study, and one from the middle.',
startHereStep1Cta: 'Open Episodes',
startHereStep2Title: 'Step 2: Use Q&A to Go Deeper',
startHereStep2Body:
'Browse by topic or search key words to find concise biblical answers and related follow-up questions.',
startHereStep2Cta: 'Go to Q&A',
startHereStep3Title: 'Step 3: Ask Nate Directly',
startHereStep3Body:
'Use the contact form to submit your Bible question for future Q&A or an upcoming episode.',
startHereStep3Cta: 'Submit a Question',
aboutShowHeading: 'Depth. Clarity. Application.',
aboutShowP1:
'Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.',
aboutShowP2:
"Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
aboutNate:
"Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
aboutPhotoUrl: '/images/nate-photo.jpeg',
aboutVerseArtUrl: '/images/hebrews-4-12-verse-art.png',
contactPhotoUrl: '/images/nate-contact-photo.png',
seriesLabel: 'Now Playing',
seriesTitle: 'Study of Titus: Sound Doctrine',
seriesDescription:
"A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
seriesImageUrl: '/images/titus-cover.png',
seriesListenUrl: '/spotify',
studyGuideTitle: 'Companion Study Guide',
studyGuideDescription:
'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.',
studyGuideUrl: 'https://a.co/d/01sG2tOJ',
shareHeading: 'Help one more person hear the Word this week.',
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
customLinks: [],
customBlocks: [],
archivedSeries: [],
redirects: [
{
id: 'spotify',
path: '/spotify',
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
statusCode: 301,
},
{
id: 'apple',
path: '/apple',
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
statusCode: 301,
},
{
id: 'amazon',
path: '/amazon',
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
statusCode: 301,
},
],
podcastFeaturedLinks: [],
seo: {
title: 'Verse by Verse with Nate',
description: "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
ogTitle: 'Verse by Verse with Nate',
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
ogImage: '/images/podcast-art.jpeg',
canonicalUrl: 'https://versebyversewithnate.us/',
robotsPolicy: 'index,follow',
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
},
legal: {
privacyTitle: 'Privacy Policy',
privacyBody: [
'We respect your privacy and collect limited data to operate and improve this site.',
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
'Contact form details are used only to respond to your message and ministry communication requests.',
],
termsTitle: 'Terms',
termsBody: [
'Content on this site is for informational and ministry purposes.',
'External links are provided for convenience and are subject to third-party policies.',
'By using this site, you agree to lawful use and respectful communication.',
],
},
}
+31
View File
@@ -0,0 +1,31 @@
export function FacebookIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.234 2.686.234v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
</svg>
)
}
export function SpotifyIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</svg>
)
}
export function YouTubeIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
)
}
export function AmazonMusicIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
)
}