Beta: TOTP 2FA, admin asset manager, resource page redesign, rate limiting, and security hardening

This commit is contained in:
nmemmert
2026-05-04 13:56:04 -04:00
parent 7f56060d6b
commit 1551599305
16 changed files with 1597 additions and 682 deletions
+470 -89
View File
@@ -213,6 +213,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
const [selectedBackup, setSelectedBackup] = useState('')
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
// TOTP management state
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null)
const [totpSetupQr, setTotpSetupQr] = useState<string | null>(null)
const [totpSetupSecret, setTotpSetupSecret] = useState<string | null>(null)
const [totpConfirmCode, setTotpConfirmCode] = useState('')
const [totpMsg, setTotpMsg] = useState('')
const [totpRecoveryCodes, setTotpRecoveryCodes] = useState<string[] | null>(null)
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
const [assets, setAssets] = useState<AdminAsset[]>([])
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
@@ -243,6 +251,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
useEffect(() => {
fetch('/api/admin-auth/status')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => setTotpEnabled(!!(data as { totpEnabled?: boolean }).totpEnabled))
.catch(() => {})
}, [])
useEffect(() => {
fetch('/api/admin-stats')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
@@ -458,6 +473,53 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}))
}
async function handleTotpSetupInit() {
setTotpMsg('')
setTotpRecoveryCodes(null)
const res = await fetch('/api/admin-auth/totp-setup-init', { method: 'POST' })
const data = await res.json().catch(() => ({})) as { qrDataUrl?: string; secret?: string; message?: string }
if (!res.ok) { setTotpMsg(data.message ?? 'Setup failed.'); return }
setTotpSetupQr(data.qrDataUrl ?? null)
setTotpSetupSecret(data.secret ?? null)
setTotpConfirmCode('')
}
async function handleTotpSetupConfirm(e: React.FormEvent) {
e.preventDefault()
setTotpMsg('')
const res = await fetch('/api/admin-auth/totp-setup-confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: totpConfirmCode }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string }
if (!res.ok) { setTotpMsg(data.message ?? 'Confirmation failed.'); return }
setTotpEnabled(true)
setTotpSetupQr(null)
setTotpSetupSecret(null)
setTotpConfirmCode('')
setTotpRecoveryCodes(data.recoveryCodes ?? null)
setTotpMsg('Two-factor authentication enabled.')
}
async function handleTotpDisable() {
if (!confirm('Disable two-factor authentication? This will make your admin less secure.')) return
setTotpMsg('')
const res = await fetch('/api/admin-auth/totp-disable', { method: 'POST' })
if (res.ok) { setTotpEnabled(false); setTotpRecoveryCodes(null); setTotpMsg('Two-factor authentication disabled.') }
else { const d = await res.json().catch(() => ({})) as { message?: string }; setTotpMsg(d.message ?? 'Failed to disable TOTP.') }
}
async function handleTotpRegenRecovery() {
if (!confirm('Regenerate recovery codes? Your old codes will stop working immediately.')) return
setTotpMsg('')
const res = await fetch('/api/admin-auth/totp-regen-recovery', { method: 'POST' })
const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string }
if (!res.ok) { setTotpMsg(data.message ?? 'Failed.'); return }
setTotpRecoveryCodes(data.recoveryCodes ?? null)
setTotpMsg('New recovery codes generated. Save these now.')
}
function addRedirectRule() {
setForm(f => ({
...f,
@@ -760,6 +822,35 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
)
}
function renderFileAssetSelector(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 file (${currentValue})`, value: currentValue })
}
if (assetOptions.length === 0) return null
return (
<div className="admin-field-asset-picker">
<label htmlFor={fieldId}>Choose an existing file</label>
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
<option value="">Select file</option>
{assetOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)
}
function isImageAsset(filename: string) {
return /\.(png|jpe?g|webp|gif)$/i.test(filename)
}
function confirmLeaveUnsavedChanges() {
if (!isDirty) return true
return confirm('You have unsaved changes. Leave this section without saving?')
@@ -792,7 +883,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
...f,
customLinks: [
...(f.customLinks ?? []),
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'resources' as const },
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'resources' as const },
],
}))
}
@@ -808,6 +899,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setForm(f => ({ ...f, customLinks: (f.customLinks ?? []).filter(l => l.id !== id) }))
}
function moveLinkToResources(id: string) {
setForm(f => ({
...f,
customLinks: (f.customLinks ?? []).map(link => (
link.id === id ? { ...link, placement: 'resources' as const } : link
)),
}))
}
function addBlock() {
setForm(f => ({
...f,
@@ -870,7 +970,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
...series,
resourceLinks: [
...(series.resourceLinks ?? []),
{ id: `${seriesId}-${Date.now().toString(36)}`, label: '', url: '' },
{ id: `${seriesId}-${Date.now().toString(36)}`, label: '', description: '', url: '' },
],
}
: series),
@@ -923,6 +1023,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{
id: `${seriesId}-${Date.now().toString(36)}`,
label: source.label,
description: source.description ?? '',
url: source.url,
},
],
@@ -949,6 +1050,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
.map(link => ({
id: `${seriesId}-${Date.now().toString(36)}-${link.id}`,
label: link.label,
description: link.description ?? '',
url: link.url,
}))
@@ -1114,6 +1216,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
alert('Failed to delete question')
}
}
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => {
return count + (series.resourceLinks ?? []).length
}, 0)
return (
<div className="admin-page">
<div className="admin-header">
@@ -1301,39 +1409,50 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<p className="admin-stats-note">No episode highlights yet. Add one below.</p>
)}
{(form.podcastFeaturedLinks ?? []).map(item => (
<div key={item.id} className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`podcast-ep-${item.id}`}>Episode Number</label>
<input id={`podcast-ep-${item.id}`} type="text" placeholder="e.g. 42" value={item.episodeNumber ?? ''} onChange={e => updatePodcastLink(item.id, 'episodeNumber', e.target.value)} />
<details key={item.id} className="admin-collapsible-card">
<summary className="admin-collapsible-summary">
<div>
<strong>{item.title || 'Untitled episode highlight'}</strong>
<p>{item.episodeNumber ? `Episode ${item.episodeNumber}` : 'No episode number yet'}</p>
</div>
<div className="admin-field">
<label htmlFor={`podcast-title-${item.id}`}>Title</label>
<input id={`podcast-title-${item.id}`} type="text" value={item.title} onChange={e => updatePodcastLink(item.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-summary-${item.id}`}>Summary</label>
<textarea id={`podcast-summary-${item.id}`} rows={2} value={item.summary} onChange={e => updatePodcastLink(item.id, 'summary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-url-${item.id}`}>Platform URL (external link)</label>
<input id={`podcast-url-${item.id}`} type="text" placeholder="https://open.spotify.com/..." value={item.url} onChange={e => updatePodcastLink(item.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-embed-${item.id}`}>Embed URL (optional enables in-page player)</label>
<input id={`podcast-embed-${item.id}`} type="text" placeholder="https://open.spotify.com/embed/episode/..." value={item.embedUrl ?? ''} onChange={e => updatePodcastLink(item.id, 'embedUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-notes-${item.id}`}>Show Notes</label>
<textarea id={`podcast-notes-${item.id}`} rows={4} placeholder="Key points, scripture references, timestamps..." value={item.showNotes ?? ''} onChange={e => updatePodcastLink(item.id, 'showNotes', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-dq-${item.id}`}>Discussion Questions (one per line)</label>
<textarea id={`podcast-dq-${item.id}`} rows={5} placeholder="What stood out to you in this passage?" value={(item.discussionQuestions ?? []).join('\n')} onChange={e => updatePodcastLinkQuestions(item.id, e.target.value)} />
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`podcast-ep-${item.id}`}>Episode Number</label>
<input id={`podcast-ep-${item.id}`} type="text" placeholder="e.g. 42" value={item.episodeNumber ?? ''} onChange={e => updatePodcastLink(item.id, 'episodeNumber', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-title-${item.id}`}>Title</label>
<input id={`podcast-title-${item.id}`} type="text" value={item.title} onChange={e => updatePodcastLink(item.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-summary-${item.id}`}>Summary</label>
<textarea id={`podcast-summary-${item.id}`} rows={2} value={item.summary} onChange={e => updatePodcastLink(item.id, 'summary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-url-${item.id}`}>Platform URL (external link)</label>
<input id={`podcast-url-${item.id}`} type="text" placeholder="https://open.spotify.com/..." value={item.url} onChange={e => updatePodcastLink(item.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-embed-${item.id}`}>Embed URL (optional enables in-page player)</label>
<input id={`podcast-embed-${item.id}`} type="text" placeholder="https://open.spotify.com/embed/episode/..." value={item.embedUrl ?? ''} onChange={e => updatePodcastLink(item.id, 'embedUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-notes-${item.id}`}>Show Notes</label>
<textarea id={`podcast-notes-${item.id}`} rows={4} placeholder="Key points, scripture references, timestamps..." value={item.showNotes ?? ''} onChange={e => updatePodcastLink(item.id, 'showNotes', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-dq-${item.id}`}>Discussion Questions (one per line)</label>
<textarea id={`podcast-dq-${item.id}`} rows={5} placeholder="What stood out to you in this passage?" value={(item.discussionQuestions ?? []).join('\n')} onChange={e => updatePodcastLinkQuestions(item.id, e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removePodcastLink(item.id)}>Remove</button>
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removePodcastLink(item.id)}>Remove</button>
</div>
</details>
))}
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button>
@@ -1459,6 +1578,75 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{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>}
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Two-Factor Authentication</h2>
<p>Require a time-based one-time code from an authenticator app on every login.</p>
</div>
{totpEnabled === null && <p className="admin-stats-note">Loading</p>}
{totpEnabled === false && !totpSetupQr && (
<div className="admin-array-row">
<div className="admin-array-fields">
<p className="admin-stats-note">2FA is currently <strong>off</strong>. Enable it to require an authenticator app (Google Authenticator, Authy, etc.) at every login.</p>
<button type="button" className="btn-primary" onClick={handleTotpSetupInit}>Enable 2FA</button>
</div>
</div>
)}
{totpSetupQr && (
<div className="admin-array-row">
<div className="admin-array-fields">
<p className="admin-stats-note">Scan this QR code with your authenticator app, then enter the 6-digit code below to confirm.</p>
<img src={totpSetupQr} alt="TOTP QR code" style={{ width: 200, height: 200, display: 'block', margin: '0.5rem 0' }} />
{totpSetupSecret && <p className="admin-stats-note" style={{ wordBreak: 'break-all' }}>Manual entry key: <code>{totpSetupSecret}</code></p>}
<form onSubmit={handleTotpSetupConfirm} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
<label htmlFor="totp-confirm-code">Confirmation Code</label>
<input
id="totp-confirm-code"
type="text"
inputMode="numeric"
value={totpConfirmCode}
onChange={e => setTotpConfirmCode(e.target.value)}
placeholder="000000"
autoFocus
required
/>
</div>
<button type="submit" className="btn-primary">Confirm &amp; Enable</button>
<button type="button" className="btn-secondary" onClick={() => { setTotpSetupQr(null); setTotpSetupSecret(null) }}>Cancel</button>
</form>
</div>
</div>
)}
{totpEnabled === true && !totpSetupQr && (
<div className="admin-array-row">
<div className="admin-array-fields">
<p className="admin-stats-note">2FA is currently <strong>on</strong>. A code from your authenticator app is required at every login.</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-admin-reset" onClick={handleTotpRegenRecovery}>Regenerate Recovery Codes</button>
<button type="button" className="btn-admin-remove" onClick={handleTotpDisable}>Disable 2FA</button>
</div>
</div>
</div>
)}
{totpRecoveryCodes && (
<div className="admin-array-row">
<div className="admin-array-fields">
<p className="admin-stats-note"><strong>Save these recovery codes somewhere safe.</strong> Each can be used once instead of the 6-digit code if you lose access to your authenticator app. They will not be shown again.</p>
<ul style={{ fontFamily: 'monospace', lineHeight: 2 }}>
{totpRecoveryCodes.map(c => <li key={c}>{c}</li>)}
</ul>
<button type="button" className="btn-secondary" onClick={() => setTotpRecoveryCodes(null)}>I've saved these</button>
</div>
</div>
)}
{totpMsg && <p className="admin-stats-note">{totpMsg}</p>}
</section>
)}
@@ -1470,9 +1658,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</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 className="btn-admin-reset" style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}>
{assetUploadPending ? 'Uploading...' : 'Upload File'}
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif,application/pdf,.pdf,application/msword,.doc,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.docx" onChange={handleAssetUpload} style={{ display: 'none' }} disabled={assetUploadPending} />
</label>
</div>
@@ -1494,7 +1682,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<tbody>
{assets.map(asset => (
<tr key={asset.filename}>
<td><img src={asset.url} alt={asset.filename} style={{ width: '68px', height: '68px', objectFit: 'cover', borderRadius: '8px' }} /></td>
<td>
{isImageAsset(asset.filename)
? <img src={asset.url} alt={asset.filename} style={{ width: '68px', height: '68px', objectFit: 'cover', borderRadius: '8px' }} />
: <a href={asset.url} target="_blank" rel="noreferrer">{asset.filename}</a>}
</td>
<td>{asset.url}</td>
<td>
<input
@@ -1835,7 +2027,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
className={`admin-tab ${contentTab === 'resources' ? 'admin-tab--active' : ''}`}
onClick={() => handleContentTabChange('resources')}
>
Resources
Downloads
</button>
<button
type="button"
@@ -1908,71 +2100,250 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<>
<div className="admin-content-summary">
<div className="admin-summary-card">
<h3>Resource Links</h3>
<p>{(form.customLinks ?? []).filter(link => link.placement === 'resources').length}</p>
<h3>Companion Study Guide</h3>
<p>Included</p>
</div>
<div className="admin-summary-card">
<h3>Current Downloads</h3>
<p>{resourceLinks.length}</p>
</div>
<div className="admin-summary-card">
<h3>Previous Study Downloads</h3>
<p>{archivedResourceCount}</p>
</div>
<div className="admin-summary-card">
<h3>Total Library Items</h3>
<p>{resourceLinks.length + archivedResourceCount}</p>
</div>
</div>
<div className="admin-section-header">
<h3>More Resources</h3>
<p>Manage additional resources shown on the More Resources page.</p>
<h3>Companion Study Guide</h3>
<p>This is the featured primary download at the top of the Downloads page.</p>
</div>
{(form.customLinks ?? []).filter(link => link.placement === 'resources').length === 0 && (
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="resources-study-guide-title">Title</label>
<input
id="resources-study-guide-title"
type="text"
value={form.studyGuideTitle}
placeholder="Companion Study Guide"
onChange={e => handleChange('studyGuideTitle', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="resources-study-guide-description">Description</label>
<textarea
id="resources-study-guide-description"
rows={3}
value={form.studyGuideDescription}
placeholder="Describe the study guide download."
onChange={e => handleChange('studyGuideDescription', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="resources-series-image-url">Cover Image URL</label>
<input
id="resources-series-image-url"
type="text"
value={form.seriesImageUrl}
placeholder="/uploads/study-guide-cover.png"
onChange={e => handleChange('seriesImageUrl', e.target.value)}
/>
{renderImageAssetSelector(form.seriesImageUrl, value => handleChange('seriesImageUrl', value), 'resources-series-image-url-asset')}
</div>
<div className="admin-field">
<label htmlFor="resources-study-guide-download-url">Primary Download URL</label>
<input
id="resources-study-guide-download-url"
type="url"
value={form.studyGuideDownloadUrl}
placeholder="/uploads/new-guide.pdf or https://..."
onChange={e => handleChange('studyGuideDownloadUrl', e.target.value)}
/>
{renderFileAssetSelector(form.studyGuideDownloadUrl, value => handleChange('studyGuideDownloadUrl', value), 'resources-study-guide-download-url-asset')}
<p className="admin-stats-note">This is the file URL the main guide download form will deliver after submission.</p>
</div>
<div className="admin-field">
<label htmlFor="resources-study-guide-url">Printed Copy URL (Amazon button)</label>
<input
id="resources-study-guide-url"
type="url"
value={form.studyGuideUrl}
placeholder="https://..."
onChange={e => handleChange('studyGuideUrl', e.target.value)}
/>
</div>
</div>
</div>
<div className="admin-section-header">
<h3>Download Library</h3>
<p>Manage the current downloads shown below the featured guide.</p>
</div>
{resourceLinks.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)}
/>
{resourceLinks.map(link => (
<details key={link.id} className="admin-collapsible-card">
<summary className="admin-collapsible-summary">
<div>
<strong>{link.label || 'Untitled download'}</strong>
<p>{link.description || 'Current download item'}</p>
</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))}
/>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row">
<p className="admin-stats-note">Source: Custom link</p>
<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-description-${link.id}`}>Description</label>
<textarea
id={`resource-description-${link.id}`}
rows={3}
value={link.description ?? ''}
placeholder="Short description shown on the download page"
onChange={e => updateLink(link.id, 'description', 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>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>
Remove
</button>
</div>
</details>
))}
<button type="button" className="btn-admin-add" onClick={addResource}>
+ Add Resource
+ Add Download
</button>
<div className="admin-section-header">
<h3>Previous Study Downloads</h3>
<p>These downloads appear in the Previous Studies area of the download library.</p>
</div>
{(form.archivedSeries ?? []).length === 0 && (
<p className="admin-stats-note">No archived series yet. Add one in Custom Content, then manage its downloads here.</p>
)}
{(form.archivedSeries ?? []).map(series => (
<details key={series.id} className="admin-collapsible-card admin-collapsible-card--group">
<summary className="admin-collapsible-summary">
<div>
<strong>{series.title || 'Untitled archived series'}</strong>
<p>{(series.resourceLinks ?? []).length} download{(series.resourceLinks ?? []).length === 1 ? '' : 's'}</p>
</div>
<span className="admin-collapsible-hint">Expand to manage</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-archive-subsection">
<div className="admin-archive-subsection-head">
<h5>{series.title || 'Untitled archived series'}</h5>
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesLink(series.id)}>
+ Add Link
</button>
</div>
{(series.resourceLinks ?? []).length === 0 && (
<p className="admin-stats-note">No archived resource links yet.</p>
)}
{(series.resourceLinks ?? []).map(link => (
<details key={link.id} className="admin-collapsible-card admin-collapsible-card--nested">
<summary className="admin-collapsible-summary">
<div>
<strong>{link.label || 'Untitled archived download'}</strong>
<p>{link.description || 'Previous study download'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row admin-array-row--nested">
<p className="admin-stats-note">Source: Archived series</p>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`resources-archive-link-label-${link.id}`}>Label</label>
<input
id={`resources-archive-link-label-${link.id}`}
type="text"
value={link.label}
placeholder="Episode guide"
onChange={e => updateArchivedSeriesLink(series.id, link.id, 'label', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-url-${link.id}`}>URL</label>
<input
id={`resources-archive-link-url-${link.id}`}
type="url"
value={link.url}
placeholder="https://..."
onChange={e => updateArchivedSeriesLink(series.id, link.id, 'url', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-description-${link.id}`}>Description</label>
<textarea
id={`resources-archive-link-description-${link.id}`}
rows={3}
value={link.description ?? ''}
placeholder="Short description shown on the download page"
onChange={e => updateArchivedSeriesLink(series.id, link.id, 'description', e.target.value)}
/>
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesLink(series.id, link.id)}>
Remove
</button>
</div>
</div>
</details>
))}
</div>
</div>
</details>
))}
</>
)}
@@ -2056,6 +2427,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<option value="resources">More Resources Section</option>
</select>
</div>
<div className="admin-field">
<label>Quick Action</label>
<button
type="button"
className="btn-admin-apply"
onClick={() => moveLinkToResources(link.id)}
>
Move to Resources
</button>
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>
Remove
+135 -393
View File
@@ -919,93 +919,6 @@
margin-bottom: 0.75rem;
}
/* ── Contact ── */
.section-chatbot-feature {
padding: 5rem 0;
background:
radial-gradient(circle at top left, rgba(201, 168, 76, 0.14), transparent 38%),
linear-gradient(180deg, rgba(22, 18, 10, 0.96), rgba(11, 11, 11, 0.98));
border-top: 1px solid rgba(201, 168, 76, 0.18);
border-bottom: 1px solid rgba(201, 168, 76, 0.18);
}
.chatbot-feature-inner {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(300px, 0.85fr);
gap: 2rem;
align-items: stretch;
}
.chatbot-feature-copy,
.chatbot-feature-card {
background: rgba(18, 18, 18, 0.84);
border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 18px;
padding: 1.7rem;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
}
.chatbot-feature-copy .section-heading {
text-align: left;
margin-bottom: 1rem;
}
.chatbot-feature-lead,
.chatbot-feature-sub,
.chatbot-feature-kicker {
font-family: var(--brand-font-body);
margin: 0;
color: #d9c9a0;
}
.chatbot-feature-lead {
font-size: clamp(1.2rem, 2vw, 1.55rem);
line-height: 1.45;
color: var(--brand-warm-white);
text-wrap: balance;
}
.chatbot-feature-sub {
margin-top: 0.85rem;
font-size: 1.02rem;
line-height: 1.65;
color: #ab9568;
}
.chatbot-feature-actions {
margin-top: 1.35rem;
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
}
.chatbot-feature-card {
display: flex;
flex-direction: column;
justify-content: center;
}
.chatbot-feature-kicker {
font-size: 0.82rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--brand-gold);
margin-bottom: 1rem;
}
.chatbot-feature-prompts {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.chatbot-prompt-btn--feature {
font-size: 0.86rem;
padding: 0.55rem 0.9rem;
text-align: left;
color: #efd8a1;
}
.section-contact {
background: #090909;
border-top: 1px solid rgba(201, 168, 76, 0.18);
@@ -1539,6 +1452,65 @@
letter-spacing: 0.03em;
}
.section-download-library {
background:
radial-gradient(circle at top center, rgba(201, 168, 76, 0.1), transparent 26%),
var(--brand-black);
}
.download-library-head {
max-width: 760px;
margin: 0 auto 2rem;
text-align: center;
}
.download-library-copy {
font-family: var(--brand-font-body);
font-size: 1.05rem;
line-height: 1.7;
color: var(--brand-muted);
margin: 0.85rem auto 0;
}
.download-library-group {
background: rgba(17, 17, 17, 0.82);
border: 1px solid rgba(201, 168, 76, 0.14);
border-radius: 24px;
padding: 1.5rem;
}
.download-library-group + .download-library-group {
margin-top: 1.5rem;
}
.download-library-group-head {
margin-bottom: 1rem;
}
.download-library-group-head h3,
.download-library-series-head h4 {
margin: 0;
font-family: var(--brand-font-heading);
color: var(--brand-warm-white);
font-size: clamp(1.25rem, 2vw, 1.7rem);
}
.download-library-group-head p,
.download-library-series-head p {
margin: 0.45rem 0 0;
font-family: var(--brand-font-body);
color: var(--brand-muted);
line-height: 1.65;
}
.download-library-series-head {
margin: 1.35rem 0 0.9rem;
}
.download-library-series-head:first-child {
margin-top: 0;
}
/* ── Custom content blocks ── */
.section-custom-block {
background: #0d0d0d;
@@ -1600,6 +1572,17 @@
font-weight: 700;
}
.guide-page-title {
font-family: var(--brand-font-heading);
font-size: clamp(2rem, 3.6vw, 3rem);
color: var(--brand-warm-white);
margin: 0.1rem 0 0.85rem;
}
.guide-page-intro {
max-width: 52ch;
}
.guide-text p {
font-family: var(--brand-font-body);
font-weight: 300;
@@ -2749,6 +2732,71 @@
margin-bottom: 0.75rem;
}
.admin-collapsible-card {
margin-bottom: 0.85rem;
border: 1px solid rgba(201, 168, 76, 0.15);
border-radius: 10px;
background: #0d0d0d;
overflow: hidden;
}
.admin-collapsible-card[open] {
border-color: rgba(201, 168, 76, 0.28);
}
.admin-collapsible-card--group {
margin-top: 0.75rem;
}
.admin-collapsible-card--nested {
margin-bottom: 0.75rem;
}
.admin-collapsible-summary {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.95rem 1rem;
cursor: pointer;
}
.admin-collapsible-summary::-webkit-details-marker {
display: none;
}
.admin-collapsible-summary strong {
display: block;
font-family: var(--brand-font-heading);
color: var(--brand-warm-white);
font-size: 1.05rem;
}
.admin-collapsible-summary p {
margin: 0.2rem 0 0;
font-family: var(--brand-font-body);
color: var(--brand-muted);
font-size: 0.92rem;
}
.admin-collapsible-hint {
flex-shrink: 0;
font-family: var(--brand-font-body);
color: var(--brand-gold);
font-size: 0.78rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.admin-collapsible-body {
padding: 0 1rem 1rem;
}
.admin-collapsible-body .admin-array-row {
margin-bottom: 0;
}
.back-to-top {
position: fixed;
right: 1.5rem;
@@ -2928,19 +2976,6 @@
gap: 1.5rem;
}
.chatbot-feature-inner {
grid-template-columns: 1fr;
}
.chatbot-feature-copy .section-heading {
text-align: center;
}
.chatbot-feature-actions,
.chatbot-feature-prompts {
justify-content: center;
}
.contact-card {
max-width: 100%;
padding: 1.45rem;
@@ -2981,7 +3016,6 @@
.section-series,
.section-guide,
.section-home-jump,
.section-chatbot-feature,
.section-contact {
padding: 3.5rem 0;
}
@@ -3380,298 +3414,6 @@
cursor: pointer;
transition: all 0.2s ease;
}
/* ══════════════════════════════════════════════════════════
FLOATING CHATBOT
══════════════════════════════════════════════════════════ */
/* Bubble trigger */
.chatbot-bubble {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1000;
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: var(--brand-gold);
color: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45);
transition: background 0.2s, transform 0.2s;
}
.chatbot-bubble:hover {
background: #e8a91a;
transform: scale(1.08);
}
.chatbot-bubble--open {
background: #555;
}
/* Panel */
.chatbot-panel {
position: fixed;
bottom: 5.5rem;
right: 1.5rem;
z-index: 999;
width: min(360px, calc(100vw - 2rem));
max-height: min(520px, calc(100vh - 8rem));
display: flex;
flex-direction: column;
background: #1a1a1a;
border: 1px solid rgba(201, 168, 76, 0.35);
border-radius: 0.75rem;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
overflow: hidden;
animation: chatSlideUp 0.22s ease;
}
.chatbot-page {
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(201, 168, 76, 0.14), transparent 40%),
var(--brand-black);
padding: 1.25rem;
}
.chatbot-panel--standalone {
position: relative;
inset: auto;
right: auto;
bottom: auto;
width: min(780px, 100%);
max-height: calc(100vh - 2.5rem);
min-height: calc(100vh - 2.5rem);
margin: 0 auto;
}
.chatbot-panel--standalone .chatbot-messages {
padding: 1rem 1.1rem;
}
.chatbot-panel--standalone .chatbot-msg {
max-width: 92%;
}
@keyframes chatSlideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Header */
.chatbot-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
background: rgba(201, 168, 76, 0.12);
border-bottom: 1px solid rgba(201, 168, 76, 0.25);
font-size: 0.9rem;
font-weight: 600;
color: #e8c87a;
}
.chatbot-header-actions {
display: flex;
align-items: center;
gap: 0.55rem;
}
.chatbot-header-btn {
background: rgba(201, 168, 76, 0.12);
border: 1px solid rgba(201, 168, 76, 0.28);
color: #d9bc7a;
border-radius: 999px;
padding: 0.28rem 0.72rem;
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
text-decoration: none;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.chatbot-header-btn:hover {
background: rgba(201, 168, 76, 0.22);
border-color: rgba(201, 168, 76, 0.5);
color: #f4ddb0;
}
.chatbot-header-btn--link {
display: inline-flex;
align-items: center;
}
.chatbot-close {
background: none;
border: none;
color: var(--brand-muted);
font-size: 1rem;
cursor: pointer;
line-height: 1;
padding: 0 0.25rem;
transition: color 0.15s;
}
.chatbot-close:hover { color: #e8c87a; }
/* Message list */
.chatbot-messages {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.chatbot-msg {
max-width: 85%;
padding: 0.6rem 0.85rem;
border-radius: 0.65rem;
font-size: 0.88rem;
line-height: 1.5;
}
.chatbot-msg-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-top: 0.85rem;
}
.chatbot-msg p {
margin: 0;
}
.chatbot-msg p + p {
margin-top: 0.4rem;
}
.chatbot-msg--bot {
background: rgba(201, 168, 76, 0.12);
border: 1px solid rgba(201, 168, 76, 0.2);
color: #e8d8b0;
align-self: flex-start;
border-bottom-left-radius: 0.15rem;
}
.chatbot-msg--user {
background: rgba(80, 80, 80, 0.45);
color: #ddd;
align-self: flex-end;
border-bottom-right-radius: 0.15rem;
}
/* Typing dots */
.chatbot-msg--typing {
display: flex;
gap: 0.3rem;
align-items: center;
padding: 0.75rem 1rem;
}
.chatbot-msg--typing span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--brand-gold);
animation: typingDot 1.2s infinite;
}
.chatbot-msg--typing span:nth-child(2) { animation-delay: 0.2s; }
.chatbot-msg--typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingDot {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.85); }
40% { opacity: 1; transform: scale(1.1); }
}
/* Suggested prompts */
.chatbot-prompts {
padding: 0 0.75rem 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chatbot-prompt-btn {
background: rgba(201, 168, 76, 0.1);
border: 1px solid rgba(201, 168, 76, 0.3);
color: var(--brand-gold);
border-radius: 1rem;
padding: 0.3rem 0.75rem;
font-size: 0.78rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.chatbot-prompt-btn:hover {
background: rgba(201, 168, 76, 0.22);
border-color: var(--brand-gold);
}
/* Input row */
.chatbot-form {
display: flex;
align-items: center;
border-top: 1px solid rgba(201, 168, 76, 0.2);
padding: 0.6rem 0.75rem;
gap: 0.5rem;
}
.chatbot-style-select {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(201, 168, 76, 0.25);
color: #d9cba8;
border-radius: 0.4rem;
padding: 0.45rem 0.5rem;
font-size: 0.78rem;
outline: none;
max-width: 6.8rem;
}
.chatbot-style-select:focus {
border-color: var(--brand-gold);
}
.chatbot-input {
flex: 1;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(201, 168, 76, 0.25);
border-radius: 0.4rem;
color: #e8d8b0;
padding: 0.45rem 0.75rem;
font-size: 0.88rem;
outline: none;
transition: border-color 0.2s;
}
.chatbot-input::placeholder { color: #7a6a50; }
.chatbot-input:focus { border-color: var(--brand-gold); }
.chatbot-send {
background: var(--brand-gold);
border: none;
border-radius: 0.4rem;
color: #fff;
width: 2.2rem;
height: 2.2rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
}
.chatbot-send:hover:not(:disabled) { background: #e8a91a; }
.chatbot-send:disabled { opacity: 0.35; cursor: default; }
/* New here page cards */
.start-grid {
display: grid;
+280 -60
View File
@@ -18,13 +18,14 @@ const AMAZON_MUSIC_URL = '/amazon'
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
function StudyDownloadForm() {
function StudyDownloadForm({ buttonText = 'Download Guide' }: { 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('')
const [downloadUrl, setDownloadUrl] = useState('')
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
@@ -35,6 +36,7 @@ function StudyDownloadForm() {
setStatus('submitting')
setErrorMsg('')
setSuccessMsg('')
setDownloadUrl('')
try {
const res = await fetch('/api/study-downloads/titus', {
@@ -52,6 +54,7 @@ function StudyDownloadForm() {
setStatus('success')
setSuccessMsg('Your download should start now. If not, use the link below.')
setDownloadUrl(data.downloadUrl)
window.location.assign(data.downloadUrl)
} catch {
setErrorMsg('Could not connect. Please try again later.')
@@ -94,8 +97,13 @@ function StudyDownloadForm() {
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
{status === 'success' && <p className="study-download-success">{successMsg}</p>}
{status === 'success' && downloadUrl && (
<p className="study-download-success">
<a href={downloadUrl}>Click here if your download does not start automatically.</a>
</p>
)}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Preparing Download...' : 'Download Titus Study'}
{status === 'submitting' ? 'Preparing Download...' : buttonText}
</button>
</form>
)
@@ -108,6 +116,7 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [successMsg, setSuccessMsg] = useState('')
const [downloadUrl, setDownloadUrl] = useState('')
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
@@ -118,6 +127,7 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
setStatus('submitting')
setErrorMsg('')
setSuccessMsg('')
setDownloadUrl('')
try {
const res = await fetch('/api/resource-download', {
@@ -135,6 +145,7 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
setStatus('success')
setSuccessMsg('Your download should start now. If not, use the link below.')
setDownloadUrl(data.downloadUrl)
window.location.assign(data.downloadUrl)
} catch {
setErrorMsg('Could not connect. Please try again later.')
@@ -177,6 +188,11 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
{status === 'success' && <p className="study-download-success">{successMsg}</p>}
{status === 'success' && downloadUrl && (
<p className="study-download-success">
<a href={downloadUrl}>Click here if your download does not start automatically.</a>
</p>
)}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Preparing Download...' : buttonText}
</button>
@@ -184,6 +200,71 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
)
}
function buildCustomResourceDownloadId(id: string) {
return `custom:${id}`
}
function buildArchivedResourceDownloadId(seriesId: string, linkId: string) {
return `archived:${seriesId}:${linkId}`
}
function buildCustomDownloadPageId(id: string) {
return `custom--${id}`
}
function buildArchivedDownloadPageId(seriesId: string, linkId: string) {
return `archived--${seriesId}--${linkId}`
}
interface DownloadPageResource {
id: string
label: string
imageUrl?: string
summary: string
tags: string[]
buttonText: string
resourceId: string
}
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
if (!pageId) return null
if (pageId.startsWith('custom--')) {
const customId = pageId.slice('custom--'.length)
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
if (!resource) return null
return {
id: pageId,
label: resource.label,
imageUrl: resource.imageUrl,
summary: resource.description || 'Complete the short form below and your download will start right away.',
tags: resource.tags ?? [],
buttonText: `Download ${resource.label}`,
resourceId: buildCustomResourceDownloadId(resource.id),
}
}
if (pageId.startsWith('archived--')) {
const [, seriesId, linkId] = pageId.split('--')
const series = (content.archivedSeries ?? []).find(item => item.id === seriesId)
const link = (series?.resourceLinks ?? []).find(item => item.id === linkId)
if (!series || !link) return null
return {
id: pageId,
label: link.label || series.title || 'Download Resource',
imageUrl: series.imageUrl,
summary: link.description || series.description || 'Fill out the form below to access this download from a previous study.',
tags: [],
buttonText: `Download ${link.label || series.title || 'Resource'}`,
resourceId: buildArchivedResourceDownloadId(series.id, link.id),
}
}
return null
}
function AnalyticsConsentBanner() {
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
const saved = localStorage.getItem(CONSENT_KEY)
@@ -341,7 +422,7 @@ function SiteHeader() {
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
<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="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Downloads</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
@@ -473,10 +554,13 @@ function StudyGuideSection({ content }: { content: SiteContent }) {
/>
</div>
<div className="guide-text">
<p className="eyebrow">Downloads</p>
<h1 className="guide-page-title">Study Guides and Downloads</h1>
<p className="guide-page-intro">Start with the primary guide below, then explore the rest of the download library further down the page.</p>
<p className="eyebrow">Free Download</p>
<h2>{content.studyGuideTitle}</h2>
<p>{content.studyGuideDescription}</p>
<StudyDownloadForm />
<StudyDownloadForm buttonText={`Download ${content.studyGuideTitle || 'Guide'}`} />
{content.studyGuideUrl && (
<div className="guide-actions">
<a
@@ -590,40 +674,132 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
)
}
function CustomResourcesSection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(l => l.placement === 'resources')
if (resources.length === 0) return null
function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0)
if (resources.length === 0 && archivedWithResources.length === 0) return null
return (
<section className="section-resources" aria-label="More resources">
<section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> More Resources{' '}
<span className="ornament"></span>
</h2>
<div className="resources-list">
{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 className="download-library-head">
<p className="eyebrow">Download Library</p>
<h2 className="section-heading">More guides, worksheets, and past study downloads.</h2>
<p className="download-library-copy">Keep the main guide featured at the top, and use this library for every other download you want available on the page.</p>
</div>
{resources.length > 0 && (
<div className="download-library-group">
<div className="download-library-group-head">
<h3>Current Downloads</h3>
<p>Extra files you want people to grab right now.</p>
</div>
<div className="resources-list">
{resources.map(resource => (
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
<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.description && <p className="resource-link-description">{resource.description}</p>}
{(resource.tags ?? []).length > 0 && (
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
)}
<span className="resource-link-action">Open download page </span>
</div>
</div>
</Link>
))}
</div>
</div>
)}
{archivedWithResources.length > 0 && (
<div className="download-library-group">
<div className="download-library-group-head">
<h3>Previous Studies</h3>
<p>Downloads from earlier series that you still want available.</p>
</div>
{archivedWithResources.map(series => (
<div key={series.id} className="archive-series-resources">
<div className="download-library-series-head">
<h4>{series.title || 'Archived Study'}</h4>
{series.description && <p>{series.description}</p>}
</div>
<div className="resources-list">
{(series.resourceLinks ?? []).map(link => (
<Link key={link.id} to={`/downloads/${buildArchivedDownloadPageId(series.id, link.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{series.imageUrl && (
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{link.label || series.title || 'Download Resource'}</span>
{link.description && <p className="resource-link-description">{link.description}</p>}
<span className="resource-link-action">Open download page </span>
</div>
</div>
</Link>
))}
</div>
</div>
<ResourceDownloadForm resourceId={resource.id} buttonText={`Download ${resource.label}`} />
</article>
))}
</div>
))}
</div>
)}
</div>
</section>
)
}
function DownloadDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const resource = resolveDownloadPageResource(content, id)
if (!resource) {
return (
<main className="thanks-page" aria-label="Download not found">
<div className="thanks-card">
<p className="eyebrow">Downloads</p>
<h1>Download not found</h1>
<p>The download you requested is not available right now.</p>
<Link to="/resources" className="btn-primary">Back to Downloads</Link>
</div>
</main>
)
}
return (
<main className="thanks-page download-detail-page" aria-label={resource.label}>
<div className="thanks-card download-detail-card">
<p className="eyebrow">Downloads</p>
<h1>{resource.label}</h1>
<p>{resource.summary}</p>
{resource.imageUrl && (
<div className="download-detail-art">
<img src={resource.imageUrl} alt={resource.label} className="guide-cover-img" />
</div>
)}
{resource.tags.length > 0 && (
<div className="resource-link-tags">{resource.tags.join(', ')}</div>
)}
<div className="download-detail-form-wrap">
<ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />
</div>
<div className="download-detail-actions">
<Link to="/resources" className="btn-secondary">Back to Downloads</Link>
</div>
</div>
</main>
)
}
function CustomBlocksSection({ content }: { content: SiteContent }) {
return (
<>
@@ -703,7 +879,7 @@ function LandingPage({ content }: { content: SiteContent }) {
<SpotifyIcon />
Listen to Series
</a>
<Link to="/resources" className="btn-secondary">View Study Resources</Link>
<Link to="/resources" className="btn-secondary">View Downloads</Link>
</div>
</article>
</div>
@@ -722,8 +898,8 @@ function LandingPage({ content }: { content: SiteContent }) {
<p>Listen to latest episodes and platform links.</p>
</Link>
<Link to="/resources" className="home-jump-card">
<h3>Resources</h3>
<p>Study guide, links, and archived study resources.</p>
<h3>Downloads</h3>
<p>Main guide, extra downloads, and past study files.</p>
</Link>
<Link to="/questions" className="home-jump-card">
<h3>Q&amp;A</h3>
@@ -851,36 +1027,11 @@ function EpisodesPage({ content }: { content: SiteContent }) {
}
function ResourcesPage({ content }: { content: SiteContent }) {
const archivedSeries = content.archivedSeries ?? []
return (
<div className="site">
<SiteHeader />
<StudyGuideSection content={content} />
<CustomResourcesSection content={content} />
{archivedSeries.length > 0 && (
<section className="section-resources" aria-label="Archived studies">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament">✦</span> Archived Studies{' '}
<span className="ornament">✦</span>
</h2>
<div className="resources-list">
{archivedSeries.map(series => (
<a
key={series.id}
href={series.listenUrl || SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="resource-link"
>
{series.title} {series.description ? `— ${series.description}` : ''}
</a>
))}
</div>
</div>
</section>
)}
<DownloadLibrarySection content={content} />
<CustomBlocksSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
@@ -1125,6 +1276,10 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
const [password, setPassword] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const [submitting, setSubmitting] = useState(false)
// TOTP two-step state
const [totpRequired, setTotpRequired] = useState(false)
const [pendingToken, setPendingToken] = useState('')
const [totpCode, setTotpCode] = useState('')
useEffect(() => {
fetch('/api/admin-auth/status')
@@ -1152,9 +1307,16 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string }
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg((data as { message?: string }).message ?? 'Login failed.')
setErrorMsg(data.message ?? 'Login failed.')
setSubmitting(false)
return
}
if (data.totpRequired && data.pendingToken) {
setPendingToken(data.pendingToken)
setTotpRequired(true)
setPassword('')
setSubmitting(false)
return
}
@@ -1167,11 +1329,45 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
}
}
async function handleTotpVerify(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setErrorMsg('')
try {
const res = await fetch('/api/admin-auth/totp-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pendingToken, code: totpCode }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; usedRecoveryCode?: boolean; remainingRecoveryCodes?: number; message?: string }
if (!res.ok) {
setErrorMsg(data.message ?? 'Invalid code.')
setSubmitting(false)
return
}
setStatus('authenticated')
setTotpCode('')
} catch {
setErrorMsg('Verification failed.')
} finally {
setSubmitting(false)
}
}
function handleBackToPassword() {
setTotpRequired(false)
setPendingToken('')
setTotpCode('')
setErrorMsg('')
}
async function handleLogout() {
try {
await fetch('/api/admin-auth/logout', { method: 'POST' })
} finally {
setStatus('unauthenticated')
setTotpRequired(false)
setPendingToken('')
}
}
@@ -1188,7 +1384,7 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
{status === 'misconfigured' && (
<p className="admin-auth-note">Set the ADMIN_PASSWORD environment variable on the server to enable admin login.</p>
)}
{status === 'unauthenticated' && (
{status === 'unauthenticated' && !totpRequired && (
<form className="admin-auth-form" onSubmit={handleLogin}>
<label>
Password
@@ -1207,6 +1403,29 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
<Link to="/" className="btn-secondary">Back to Site</Link>
</form>
)}
{status === 'unauthenticated' && totpRequired && (
<form className="admin-auth-form" onSubmit={handleTotpVerify}>
<p className="admin-auth-note">Enter the 6-digit code from your authenticator app, or one of your recovery codes.</p>
<label>
Code
<input
type="text"
inputMode="numeric"
value={totpCode}
onChange={e => setTotpCode(e.target.value)}
autoComplete="one-time-code"
placeholder="000000 or XXXX-XXXX-XXXX"
autoFocus
required
/>
</label>
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Verifying' : 'Verify'}
</button>
<button type="button" className="btn-secondary" onClick={handleBackToPassword}>Back</button>
</form>
)}
</div>
</main>
)
@@ -1350,6 +1569,7 @@ export default function App() {
<Route path="/episodes" element={<EpisodesPage content={content} />} />
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
<Route path="/resources" element={<ResourcesPage content={content} />} />
<Route path="/downloads/:id" element={<DownloadDetailPage content={content} />} />
<Route path="/about" element={<AboutPage content={content} />} />
<Route path="/contact" element={<ContactPage content={content} />} />
<Route path="/questions" element={<QuestionsPage />} />
+4
View File
@@ -4,6 +4,7 @@ export interface CustomLink {
url: string
placement: 'platforms' | 'footer' | 'resources'
imageUrl?: string
description?: string
tags?: string[]
}
@@ -16,6 +17,7 @@ export interface CustomBlock {
export interface ArchivedSeriesResourceLink {
id: string
label: string
description?: string
url: string
}
@@ -103,6 +105,7 @@ export interface SiteContent {
seriesListenUrl: string
studyGuideTitle: string
studyGuideDescription: string
studyGuideDownloadUrl: string
studyGuideUrl: string
shareHeading: string
shareP: string
@@ -152,6 +155,7 @@ export const DEFAULTS: SiteContent = {
studyGuideTitle: 'Companion Study Guide',
studyGuideDescription:
'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.',
studyGuideDownloadUrl: '',
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.',