Reorganize admin workflow and persist episode discussion questions

This commit is contained in:
nmemmert
2026-04-27 16:25:26 -04:00
parent 83743cc78b
commit b26103b7b1
7 changed files with 2291 additions and 602 deletions
+628 -11
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import type { ChangeEvent } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote } from './App'
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './App'
import { DEFAULTS } from './App'
interface Props {
@@ -60,6 +61,26 @@ interface AdminStats {
}
}
interface AdminAsset {
filename: string
url: string
sizeBytes: number
updatedAt: string
}
interface PublishState {
draftUpdatedAt: string | null
publishedAt: string | null
}
interface OpsStatus {
buildCommit: string | null
buildNumber: string | null
deployedAt: string | null
cachePurge: { ok: boolean; at: string | null; error: string | null }
deployHook: { ok: boolean; at: string | null; error: string | null }
}
interface Question {
id: string
submittedAt: string
@@ -71,7 +92,7 @@ interface Question {
isApproved: boolean
approvedAt: string | null
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries'>
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal'>
type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share'
@@ -174,14 +195,19 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions' | 'brand'>('content')
const [adminTab, setAdminTab] = useState<'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand'>('content')
const [contentTab, setContentTab] = useState<'main' | 'custom'>('main')
const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [maintenanceMsg, setMaintenanceMsg] = useState('')
const [opsMsg, setOpsMsg] = useState('')
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
const [selectedBackup, setSelectedBackup] = useState('')
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
const [assets, setAssets] = useState<AdminAsset[]>([])
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
const [assetUploadPending, setAssetUploadPending] = useState(false)
const [questions, setQuestions] = useState<Question[]>([])
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
@@ -234,6 +260,34 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
})
.catch(() => {})
fetch('/api/admin-content-state')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load publish state'))))
.then(data => {
const state = (data as { publishState?: PublishState }).publishState
if (state) {
setPublishState({
draftUpdatedAt: state.draftUpdatedAt ?? null,
publishedAt: state.publishedAt ?? null,
})
}
})
.catch(() => {})
fetch('/api/admin-assets')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load assets'))))
.then(data => {
const list = Array.isArray((data as { assets?: unknown }).assets) ? (data as { assets: AdminAsset[] }).assets : []
setAssets(list)
})
.catch(() => {})
fetch('/api/admin-ops/status')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status'))))
.then(data => {
setOpsStatus(data as OpsStatus)
})
.catch(() => {})
}, [])
useEffect(() => {
@@ -288,6 +342,256 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
}
async function reloadAssets() {
const r = await fetch('/api/admin-assets')
if (!r.ok) throw new Error('Could not refresh assets')
const data = await r.json() as { assets?: AdminAsset[] }
setAssets(Array.isArray(data.assets) ? data.assets : [])
}
async function reloadOpsStatus() {
const r = await fetch('/api/admin-ops/status')
if (!r.ok) throw new Error('Could not refresh ops status')
const data = await r.json() as OpsStatus
setOpsStatus(data)
}
async function handleSaveDraft() {
setStatus('saving')
setErrorMsg('')
try {
const res = await fetch('/api/admin-content-draft', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ siteContent: form }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Draft save failed')
}
const data = await res.json() as { updatedAt?: string }
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
setStatus('saved')
setTimeout(() => setStatus('idle'), 3500)
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
setStatus('error')
}
}
async function handlePublishDraft() {
if (!confirm('Publish current changes to the live site now?')) return
setStatus('saving')
setErrorMsg('')
try {
// Always save the current form to draft first so publish never fails from a missing draft
const draftRes = await fetch('/api/admin-content-draft', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ siteContent: form }),
})
if (!draftRes.ok) {
const data = await draftRes.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Draft save failed')
}
const draftData = await draftRes.json() as { updatedAt?: string }
setPublishState(prev => ({ ...prev, draftUpdatedAt: draftData.updatedAt ?? new Date().toISOString() }))
const publishRes = await fetch('/api/admin-content/publish', { method: 'POST' })
if (!publishRes.ok) {
const data = await publishRes.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Publish failed')
}
const published = await publishRes.json() as { publishedAt?: string }
const latestRes = await fetch('/api/admin-content')
if (!latestRes.ok) throw new Error('Failed to refresh published content')
const latest = await latestRes.json() as { siteContent?: Partial<SiteContent> }
if (latest?.siteContent) {
const next = { ...DEFAULTS, ...latest.siteContent }
setForm(next)
onSave(next)
setLastSavedSnapshot(JSON.stringify(next))
}
setPublishState(prev => ({ ...prev, publishedAt: published.publishedAt ?? new Date().toISOString() }))
await reloadStats()
setStatus('saved')
setTimeout(() => setStatus('idle'), 3500)
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
setStatus('error')
}
}
function openDraftPreview() {
window.open('/preview', '_blank', 'noopener,noreferrer')
}
function updateSeoField(field: keyof SeoSettings, value: string | string[]) {
setForm(f => ({
...f,
seo: {
...f.seo,
[field]: value,
},
}))
}
function updateLegalField(field: keyof LegalSettings, value: string | string[]) {
setForm(f => ({
...f,
legal: {
...f.legal,
[field]: value,
},
}))
}
function addRedirectRule() {
setForm(f => ({
...f,
redirects: [
...(f.redirects ?? []),
{
id: Date.now().toString(36),
path: '/new-short-link',
target: 'https://',
statusCode: 301,
},
],
}))
}
function updateRedirectRule(id: string, field: keyof RedirectRule, value: string | number) {
setForm(f => ({
...f,
redirects: (f.redirects ?? []).map(rule => rule.id === id
? {
...rule,
[field]: field === 'statusCode' ? (Number(value) === 302 ? 302 : 301) : value,
}
: rule),
}))
}
function removeRedirectRule(id: string) {
setForm(f => ({ ...f, redirects: (f.redirects ?? []).filter(rule => rule.id !== id) }))
}
function addPodcastLink() {
setForm(f => ({
...f,
podcastFeaturedLinks: [
...(f.podcastFeaturedLinks ?? []),
{
id: Date.now().toString(36),
title: '',
episodeNumber: '',
summary: '',
url: '',
embedUrl: '',
showNotes: '',
discussionQuestions: [],
},
],
}))
}
function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string) {
setForm(f => ({
...f,
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link),
}))
}
function updatePodcastLinkQuestions(id: string, raw: string) {
const questions = raw.split('\n').map(q => q.trim()).filter(Boolean)
setForm(f => ({
...f,
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link =>
link.id === id ? { ...link, discussionQuestions: questions } : link
),
}))
}
function removePodcastLink(id: string) {
setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) }))
}
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (!file) return
setAssetUploadPending(true)
setOpsMsg('')
try {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
const res = await fetch('/api/admin-assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, dataUrl }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Upload failed')
}
await reloadAssets()
setOpsMsg('Asset uploaded successfully.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Upload failed.')
} finally {
setAssetUploadPending(false)
event.target.value = ''
}
}
async function handleDeleteAsset(filename: string) {
if (!confirm(`Delete asset ${filename}?`)) return
try {
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed')
await reloadAssets()
setOpsMsg('Asset deleted.')
} catch {
setOpsMsg('Asset delete failed.')
}
}
async function handlePurgeCache() {
try {
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Cache purge failed')
}
await reloadOpsStatus()
setOpsMsg('Cache purge triggered.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Cache purge failed.')
}
}
async function handleDeployHook() {
try {
const res = await fetch('/api/admin-ops/deploy', { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Deploy hook failed')
}
await reloadOpsStatus()
setOpsMsg('Deploy hook triggered.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Deploy hook failed.')
}
}
function maskIp(ip: string) {
if (!ip || ip === 'unknown') return 'unknown'
if (ip.includes('.')) {
@@ -402,7 +706,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
return confirm('You have unsaved changes. Leave this section without saving?')
}
function handleAdminTabChange(nextTab: 'content' | 'stats' | 'questions' | 'brand') {
function handleAdminTabChange(nextTab: 'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand') {
if (nextTab === adminTab) return
if (!confirmLeaveUnsavedChanges()) return
setAdminTab(nextTab)
@@ -807,11 +1111,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<button
type="button"
role="tab"
aria-selected={adminTab === 'stats'}
className={`admin-tab ${adminTab === 'stats' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('stats')}
aria-selected={adminTab === 'episodes'}
className={`admin-tab ${adminTab === 'episodes' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('episodes')}
>
Site Stats
Episodes
</button>
<button
type="button"
@@ -822,6 +1126,33 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
>
Questions ({questions.length})
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'analytics'}
className={`admin-tab ${adminTab === 'analytics' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('analytics')}
>
Analytics
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'settings'}
className={`admin-tab ${adminTab === 'settings' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('settings')}
>
Settings
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'publish'}
className={`admin-tab ${adminTab === 'publish' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('publish')}
>
Publish
</button>
<button
type="button"
role="tab"
@@ -829,7 +1160,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
className={`admin-tab ${adminTab === 'brand' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('brand')}
>
Brand Kit
Brand
</button>
</div>
@@ -894,8 +1225,294 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section>
)}
{adminTab === 'stats' && (
<section className="admin-stats" aria-label="Site hit statistics">
{adminTab === 'publish' && (
<section className="admin-stats" aria-label="Publishing and deployment">
<div className="admin-stats-head">
<h2>Publishing Workflow</h2>
<p>Save drafts, preview before release, and publish when ready.</p>
</div>
<div className="admin-stats-grid">
<article>
<h3>Draft Updated</h3>
<p>{formatDate(publishState.draftUpdatedAt)}</p>
</article>
<article>
<h3>Last Published</h3>
<p>{formatDate(publishState.publishedAt)}</p>
</article>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-save" onClick={handleSaveDraft}>Save Draft</button>
<button type="button" className="btn-admin-reset" onClick={openDraftPreview}>Preview Draft</button>
<button type="button" className="btn-admin-reset" onClick={handlePublishDraft}>Publish Draft</button>
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Deployment and Cache</h2>
<p>Trigger deployment hooks and cache purge hooks.</p>
</div>
<div className="admin-stats-grid">
<article>
<h3>Build Commit</h3>
<p>{opsStatus?.buildCommit ?? 'Not available'}</p>
</article>
<article>
<h3>Build Number</h3>
<p>{opsStatus?.buildNumber ?? 'Not available'}</p>
</article>
<article>
<h3>Deployed At</h3>
<p>{formatDate(opsStatus?.deployedAt ?? null)}</p>
</article>
<article>
<h3>Cache Purge</h3>
<p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p>
<p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p>
</article>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={handlePurgeCache}>Purge Cache</button>
<button type="button" className="btn-admin-reset" onClick={handleDeployHook}>Trigger Deploy</button>
<button type="button" className="btn-admin-reset" onClick={() => { void reloadOpsStatus() }}>Refresh Status</button>
</div>
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
{status === 'error' && <p className="admin-status admin-status--err"> {errorMsg}</p>}
{status === 'saved' && <p className="admin-status admin-status--ok"> Draft action completed.</p>}
</section>
)}
{adminTab === 'episodes' && (
<section className="admin-stats" aria-label="Episode highlights">
<div className="admin-stats-head">
<h2>Episode Highlights</h2>
<p>Featured episodes shown on the Episodes page. Fill in discussion questions, show notes, or an embed URL and the highlight automatically gets its own detail page at <code>/episodes/[id]</code>.</p>
</div>
{(form.podcastFeaturedLinks ?? []).length === 0 && (
<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)} />
</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>
))}
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button>
<div className="admin-actions" style={{ marginTop: '2rem' }}>
<button
type="button"
className="btn-admin-save"
onClick={handleSave}
disabled={status === 'saving'}
>
{status === 'saving' ? 'Saving…' : 'Save Changes'}
</button>
</div>
{status === 'saved' && <p className="admin-status admin-status--ok"> Changes saved.</p>}
{status === 'error' && <p className="admin-status admin-status--err"> {errorMsg}</p>}
</section>
)}
{adminTab === 'settings' && (
<section className="admin-stats" aria-label="Site settings">
<div className="admin-stats-head">
<h2>SEO Settings</h2>
<p>Control metadata, canonical URL, robots policy, and sitemap paths.</p>
</div>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="seo-title">SEO Title</label>
<input id="seo-title" type="text" value={form.seo?.title ?? ''} onChange={e => updateSeoField('title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-description">SEO Description</label>
<textarea id="seo-description" rows={3} value={form.seo?.description ?? ''} onChange={e => updateSeoField('description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-og-title">Open Graph Title</label>
<input id="seo-og-title" type="text" value={form.seo?.ogTitle ?? ''} onChange={e => updateSeoField('ogTitle', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-og-description">Open Graph Description</label>
<textarea id="seo-og-description" rows={3} value={form.seo?.ogDescription ?? ''} onChange={e => updateSeoField('ogDescription', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-og-image">Open Graph Image URL</label>
<input id="seo-og-image" type="text" value={form.seo?.ogImage ?? ''} onChange={e => updateSeoField('ogImage', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-canonical">Canonical URL</label>
<input id="seo-canonical" type="text" value={form.seo?.canonicalUrl ?? ''} onChange={e => updateSeoField('canonicalUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-robots">Robots Policy</label>
<input id="seo-robots" type="text" value={form.seo?.robotsPolicy ?? ''} onChange={e => updateSeoField('robotsPolicy', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-sitemap">Sitemap Paths (one per line)</label>
<textarea
id="seo-sitemap"
rows={4}
value={(form.seo?.sitemapPaths ?? []).join('\n')}
onChange={e => updateSeoField('sitemapPaths', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))}
/>
</div>
</div>
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Redirect Manager</h2>
<p>Maintain first-party short links like /spotify without code changes.</p>
</div>
{(form.redirects ?? []).map(rule => (
<div key={rule.id} className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`redirect-path-${rule.id}`}>Path</label>
<input id={`redirect-path-${rule.id}`} type="text" value={rule.path} onChange={e => updateRedirectRule(rule.id, 'path', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`redirect-target-${rule.id}`}>Target URL</label>
<input id={`redirect-target-${rule.id}`} type="text" value={rule.target} onChange={e => updateRedirectRule(rule.id, 'target', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`redirect-status-${rule.id}`}>Status</label>
<select id={`redirect-status-${rule.id}`} value={rule.statusCode} onChange={e => updateRedirectRule(rule.id, 'statusCode', Number(e.target.value))}>
<option value={301}>301 Permanent</option>
<option value={302}>302 Temporary</option>
</select>
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeRedirectRule(rule.id)}>Remove</button>
</div>
))}
<button type="button" className="btn-admin-add" onClick={addRedirectRule}>+ Add Redirect</button>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Legal Pages</h2>
<p>Edit Privacy and Terms pages from admin.</p>
</div>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="legal-privacy-title">Privacy Title</label>
<input id="legal-privacy-title" type="text" value={form.legal?.privacyTitle ?? ''} onChange={e => updateLegalField('privacyTitle', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="legal-privacy-body">Privacy Body (one paragraph per line)</label>
<textarea
id="legal-privacy-body"
rows={4}
value={(form.legal?.privacyBody ?? []).join('\n')}
onChange={e => updateLegalField('privacyBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))}
/>
</div>
<div className="admin-field">
<label htmlFor="legal-terms-title">Terms Title</label>
<input id="legal-terms-title" type="text" value={form.legal?.termsTitle ?? ''} onChange={e => updateLegalField('termsTitle', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="legal-terms-body">Terms Body (one paragraph per line)</label>
<textarea
id="legal-terms-body"
rows={4}
value={(form.legal?.termsBody ?? []).join('\n')}
onChange={e => updateLegalField('termsBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))}
/>
</div>
</div>
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Asset Manager</h2>
<p>Upload and reuse hosted images from this domain.</p>
</div>
<div className="admin-actions admin-actions--maintenance">
<label className="btn-admin-reset" style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}>
{assetUploadPending ? 'Uploading...' : 'Upload Image'}
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={handleAssetUpload} style={{ display: 'none' }} disabled={assetUploadPending} />
</label>
</div>
{assets.length === 0 ? (
<p className="admin-stats-note">No uploaded assets yet.</p>
) : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr>
<th>Preview</th>
<th>URL</th>
<th>Size</th>
<th>Updated</th>
<th>Action</th>
</tr>
</thead>
<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>{asset.url}</td>
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
<td>{formatDate(asset.updatedAt)}</td>
<td><button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="admin-actions" style={{ marginTop: '2rem' }}>
<button
type="button"
className="btn-admin-save"
onClick={handleSave}
disabled={status === 'saving'}
>
{status === 'saving' ? 'Saving…' : 'Save Changes'}
</button>
</div>
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
{status === 'saved' && <p className="admin-status admin-status--ok"> Changes saved.</p>}
{status === 'error' && <p className="admin-status admin-status--err"> {errorMsg}</p>}
</section>
)}
{adminTab === 'analytics' && (
<section className="admin-stats" aria-label="Site analytics">
<div className="admin-stats-head">
<h2>Site Hit Stats</h2>
<p>Built-in page traffic and visitor intelligence from this server.</p>