Beta: TOTP 2FA, admin asset manager, resource page redesign, rate limiting, and security hardening
This commit is contained in:
+470
-89
@@ -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 & 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
@@ -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
@@ -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&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,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.',
|
||||
|
||||
Reference in New Issue
Block a user