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