Reorganize admin workflow and persist episode discussion questions
This commit is contained in:
+628
-11
@@ -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>
|
||||
|
||||
+179
-87
@@ -42,6 +42,20 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-menu-btn {
|
||||
display: none;
|
||||
border: 1px solid rgba(201, 168, 76, 0.42);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: var(--brand-gold);
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.88rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-nav a {
|
||||
color: var(--brand-muted);
|
||||
text-decoration: none;
|
||||
@@ -57,6 +71,10 @@
|
||||
color: var(--brand-gold);
|
||||
}
|
||||
|
||||
.header-nav-link--active {
|
||||
color: var(--brand-gold) !important;
|
||||
}
|
||||
|
||||
.header-cta {
|
||||
color: var(--brand-gold) !important;
|
||||
border: 1px solid rgba(201, 168, 76, 0.5) !important;
|
||||
@@ -749,104 +767,156 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ── QR / Share ── */
|
||||
.section-qr {
|
||||
/* ── Home Jump Section ── */
|
||||
.section-home-jump {
|
||||
background: var(--brand-black);
|
||||
padding: 5rem 0;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.16);
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
|
||||
padding: 4.5rem 0;
|
||||
}
|
||||
|
||||
.section-qr .section-inner {
|
||||
max-width: 1080px;
|
||||
.home-jump-head {
|
||||
text-align: center;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.qr-inner {
|
||||
.home-jump-head .section-heading {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.home-jump-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 4.5rem;
|
||||
align-items: center;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.qr-text {
|
||||
background: linear-gradient(180deg, rgba(17, 17, 17, 0.92), rgba(11, 11, 11, 0.94));
|
||||
border: 1px solid rgba(201, 168, 76, 0.22);
|
||||
border-radius: 16px;
|
||||
padding: 2rem 2.1rem;
|
||||
.home-jump-card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
background: linear-gradient(180deg, rgba(20, 20, 18, 0.95), rgba(11, 11, 10, 0.95));
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
border-radius: 14px;
|
||||
padding: 1.1rem 1.15rem;
|
||||
transition: transform 180ms, border-color 180ms, background 180ms;
|
||||
}
|
||||
|
||||
.qr-text h2 {
|
||||
.home-jump-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(201, 168, 76, 0.45);
|
||||
background: linear-gradient(180deg, rgba(28, 27, 23, 0.98), rgba(14, 14, 12, 0.98));
|
||||
}
|
||||
|
||||
.home-jump-card h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: clamp(1.6rem, 2.5vw, 2.2rem);
|
||||
color: var(--brand-warm-white);
|
||||
margin: 0.5rem 0 1rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.28rem;
|
||||
}
|
||||
|
||||
.qr-text p {
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 300;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.65;
|
||||
color: var(--brand-muted);
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.share-show-wrap {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.btn-share-show {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.share-feedback {
|
||||
.home-jump-card p {
|
||||
margin: 0;
|
||||
font-family: var(--brand-font-body);
|
||||
color: #cdb483;
|
||||
font-size: 0.9rem;
|
||||
color: var(--brand-muted);
|
||||
line-height: 1.55;
|
||||
font-size: 0.97rem;
|
||||
}
|
||||
|
||||
.qr-code-wrap {
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
background: rgba(16, 16, 16, 0.9);
|
||||
border: 1px solid rgba(201, 168, 76, 0.22);
|
||||
border-radius: 16px;
|
||||
padding: 1.35rem 1.2rem 1rem;
|
||||
box-shadow: 0 10px 35px rgba(0, 0, 0, 0.38);
|
||||
/* ── Episode Detail Page ── */
|
||||
.section-episode-detail {
|
||||
background: var(--brand-black);
|
||||
padding: 4rem 0 5rem;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.qr-frame {
|
||||
.episode-detail-back {
|
||||
display: inline-block;
|
||||
padding: 16px 18px;
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
display: block;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.scan-label {
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 500;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.92rem;
|
||||
color: var(--brand-gold);
|
||||
margin-top: 0.85rem;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.75rem;
|
||||
letter-spacing: 0.02em;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.episode-detail-back:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.episode-detail-number {
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.85rem;
|
||||
color: var(--brand-gold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.episode-detail-title {
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: clamp(1.6rem, 3.5vw, 2.4rem);
|
||||
color: var(--brand-warm-white);
|
||||
line-height: 1.2;
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
|
||||
.episode-detail-summary {
|
||||
font-family: var(--brand-font-body);
|
||||
color: var(--brand-muted);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
margin: 0 0 2rem;
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.episode-detail-embed {
|
||||
margin: 1.5rem 0 2rem;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.episode-detail-listen-btn {
|
||||
display: inline-block;
|
||||
margin: 0 0 2.5rem;
|
||||
}
|
||||
|
||||
.episode-detail-show-notes,
|
||||
.episode-detail-questions {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.14);
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.episode-detail-section-heading {
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: 1.25rem;
|
||||
color: var(--brand-gold);
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.episode-detail-show-notes p {
|
||||
font-family: var(--brand-font-body);
|
||||
color: var(--brand-muted);
|
||||
line-height: 1.75;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.episode-detail-questions-list {
|
||||
padding-left: 1.4rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.episode-detail-questions-list li {
|
||||
font-family: var(--brand-font-body);
|
||||
color: var(--brand-warm-white);
|
||||
line-height: 1.7;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Contact ── */
|
||||
@@ -2658,15 +2728,8 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.qr-inner {
|
||||
.home-jump-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-text .btn-secondary {
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.contact-inner {
|
||||
@@ -2726,7 +2789,7 @@
|
||||
.section-about,
|
||||
.section-series,
|
||||
.section-guide,
|
||||
.section-qr,
|
||||
.section-home-jump,
|
||||
.section-chatbot-feature,
|
||||
.section-contact {
|
||||
padding: 3.5rem 0;
|
||||
@@ -2738,14 +2801,43 @@
|
||||
|
||||
.header-inner {
|
||||
padding: 0.75rem 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-menu-btn {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
gap: 1rem;
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 120;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
background: rgba(10, 10, 8, 0.98);
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.2);
|
||||
}
|
||||
|
||||
.header-nav a:not(.header-cta) {
|
||||
display: none;
|
||||
.header-nav--open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header-nav .header-nav-link {
|
||||
display: block;
|
||||
padding: 0.8rem 0.25rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
|
||||
}
|
||||
|
||||
.header-nav .header-cta {
|
||||
margin-top: 0.75rem;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-top-tabs .admin-tab {
|
||||
|
||||
+679
-472
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user