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
|
||||
|
||||
Reference in New Issue
Block a user