Beta: admin assets, resource download forms, and resource page redesign
This commit is contained in:
+287
-148
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './App'
|
||||
import { DEFAULTS } from './App'
|
||||
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
||||
import { DEFAULTS } from './content'
|
||||
|
||||
interface Props {
|
||||
content: SiteContent
|
||||
@@ -66,6 +66,7 @@ interface AdminAsset {
|
||||
url: string
|
||||
sizeBytes: number
|
||||
updatedAt: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
interface PublishState {
|
||||
@@ -94,7 +95,7 @@ interface Question {
|
||||
}
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal'>
|
||||
|
||||
type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share'
|
||||
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share'
|
||||
|
||||
const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; description: string }> = [
|
||||
{
|
||||
@@ -110,7 +111,12 @@ const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; desc
|
||||
{
|
||||
id: 'about',
|
||||
title: 'About Section',
|
||||
description: 'Manage the main show description and Nate bio content.',
|
||||
description: 'Manage the main show description, Nate bio, and about images.',
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
title: 'Contact Section',
|
||||
description: 'Manage the contact page profile image and contact copy.',
|
||||
},
|
||||
{
|
||||
id: 'series',
|
||||
@@ -178,6 +184,9 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
|
||||
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true, section: 'about' },
|
||||
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true, section: 'about' },
|
||||
{ key: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' },
|
||||
{ key: 'aboutPhotoUrl', label: 'About Section Photo URL', section: 'about' },
|
||||
{ key: 'aboutVerseArtUrl', label: 'About Verse Art Image URL', section: 'about' },
|
||||
{ key: 'contactPhotoUrl', label: 'Contact Profile Photo URL', section: 'contact' },
|
||||
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
|
||||
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
|
||||
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
|
||||
@@ -195,8 +204,8 @@ 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' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand'>('content')
|
||||
const [contentTab, setContentTab] = useState<'main' | 'custom'>('main')
|
||||
const [adminTab, setAdminTab] = useState<'content' | 'episodes' | 'settings' | 'analytics' | 'questions' | 'brand' | 'assets'>('content')
|
||||
const [contentTab, setContentTab] = useState<'main' | 'resources' | 'custom'>('main')
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [maintenanceMsg, setMaintenanceMsg] = useState('')
|
||||
@@ -206,6 +215,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
|
||||
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
|
||||
const [assets, setAssets] = useState<AdminAsset[]>([])
|
||||
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
|
||||
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
|
||||
const [assetUploadPending, setAssetUploadPending] = useState(false)
|
||||
|
||||
@@ -274,13 +284,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
})
|
||||
.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(() => {})
|
||||
void reloadAssets()
|
||||
|
||||
fetch('/api/admin-ops/status')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status'))))
|
||||
@@ -346,7 +350,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
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 : [])
|
||||
const incomingAssets = Array.isArray(data.assets) ? data.assets : []
|
||||
setAssets(incomingAssets)
|
||||
setAssetTagEdits(incomingAssets.reduce<Record<string, string>>((memo, asset) => {
|
||||
memo[asset.filename] = (asset.tags ?? []).join(', ')
|
||||
return memo
|
||||
}, {}))
|
||||
}
|
||||
|
||||
async function reloadOpsStatus() {
|
||||
@@ -371,6 +380,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}
|
||||
const data = await res.json() as { updatedAt?: string }
|
||||
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
|
||||
setLastSavedSnapshot(JSON.stringify(form))
|
||||
setStatus('saved')
|
||||
setTimeout(() => setStatus('idle'), 3500)
|
||||
} catch (err) {
|
||||
@@ -564,6 +574,30 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleAssetTagChange(filename: string, value: string) {
|
||||
setAssetTagEdits(prev => ({ ...prev, [filename]: value }))
|
||||
}
|
||||
|
||||
async function handleSaveAssetTags(filename: string) {
|
||||
const tagsText = assetTagEdits[filename] ?? ''
|
||||
const tags = tagsText.split(',').map(tag => tag.trim()).filter(Boolean)
|
||||
try {
|
||||
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tags }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { message?: string }).message ?? 'Save failed')
|
||||
}
|
||||
await reloadAssets()
|
||||
setOpsMsg('Asset tags saved.')
|
||||
} catch (err) {
|
||||
setOpsMsg(err instanceof Error ? err.message : 'Save failed.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePurgeCache() {
|
||||
try {
|
||||
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
|
||||
@@ -701,18 +735,43 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
setForm(f => ({ ...f, [key]: value }))
|
||||
}
|
||||
|
||||
function renderImageAssetSelector(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 image (${currentValue})`, value: currentValue })
|
||||
}
|
||||
|
||||
if (assetOptions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="admin-field-asset-picker">
|
||||
<label htmlFor={fieldId}>Choose an existing image</label>
|
||||
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">Select image</option>
|
||||
{assetOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function confirmLeaveUnsavedChanges() {
|
||||
if (!isDirty) return true
|
||||
return confirm('You have unsaved changes. Leave this section without saving?')
|
||||
}
|
||||
|
||||
function handleAdminTabChange(nextTab: 'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand') {
|
||||
function handleAdminTabChange(nextTab: 'content' | 'episodes' | 'settings' | 'analytics' | 'questions' | 'brand' | 'assets') {
|
||||
if (nextTab === adminTab) return
|
||||
if (!confirmLeaveUnsavedChanges()) return
|
||||
setAdminTab(nextTab)
|
||||
}
|
||||
|
||||
function handleContentTabChange(nextTab: 'main' | 'custom') {
|
||||
function handleContentTabChange(nextTab: 'main' | 'resources' | 'custom') {
|
||||
if (nextTab === contentTab) return
|
||||
if (!confirmLeaveUnsavedChanges()) return
|
||||
setContentTab(nextTab)
|
||||
@@ -723,12 +782,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
...f,
|
||||
customLinks: [
|
||||
...(f.customLinks ?? []),
|
||||
{ id: Date.now().toString(36), label: '', url: '', placement: 'platforms' as const },
|
||||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'platforms' as const },
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function updateLink(id: string, field: keyof CustomLink, value: string) {
|
||||
function addResource() {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customLinks: [
|
||||
...(f.customLinks ?? []),
|
||||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'resources' as const },
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function updateLink(id: string, field: keyof CustomLink, value: string | string[]) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customLinks: (f.customLinks ?? []).map(l => l.id === id ? { ...l, [field]: value } : l),
|
||||
@@ -985,29 +1054,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
setContentTab('custom')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setStatus('saving')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const res = await fetch('/api/admin-content', {
|
||||
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 ?? 'Save failed')
|
||||
}
|
||||
onSave(form)
|
||||
setLastSavedSnapshot(JSON.stringify(form))
|
||||
setStatus('saved')
|
||||
setTimeout(() => setStatus('idle'), 3500)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (confirm('Reset all fields to defaults?')) {
|
||||
setForm(DEFAULTS)
|
||||
@@ -1147,11 +1193,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={adminTab === 'publish'}
|
||||
className={`admin-tab ${adminTab === 'publish' ? 'admin-tab--active' : ''}`}
|
||||
onClick={() => handleAdminTabChange('publish')}
|
||||
aria-selected={adminTab === 'assets'}
|
||||
className={`admin-tab ${adminTab === 'assets' ? 'admin-tab--active' : ''}`}
|
||||
onClick={() => handleAdminTabChange('assets')}
|
||||
>
|
||||
Publish
|
||||
Assets
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1163,6 +1209,25 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
Brand
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-action-toolbar">
|
||||
<div className="admin-action-toolbar-meta">
|
||||
{isDirty && <span className="admin-status admin-status--warn">Unsaved changes</span>}
|
||||
<span>Draft saved: {formatDate(publishState.draftUpdatedAt)}</span>
|
||||
<span>Last published: {formatDate(publishState.publishedAt)}</span>
|
||||
</div>
|
||||
<div className="admin-actions admin-actions--toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-save"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={status === 'saving' || !isDirty}
|
||||
>
|
||||
{status === 'saving' ? 'Saving…' : '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>
|
||||
|
||||
{adminTab === 'brand' && (
|
||||
<section className="admin-brand-kit" aria-label="Brand kit">
|
||||
@@ -1225,64 +1290,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{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">
|
||||
@@ -1330,16 +1337,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
))}
|
||||
<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>
|
||||
{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>
|
||||
@@ -1373,6 +1371,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
<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)} />
|
||||
{renderImageAssetSelector(form.seo?.ogImage, value => updateSeoField('ogImage', value), 'seo-og-image-asset')}
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="seo-canonical">Canonical URL</label>
|
||||
@@ -1457,28 +1456,39 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
{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 === 'assets' && (
|
||||
<section className="admin-stats" aria-label="Asset manager">
|
||||
<div className="admin-stats-head">
|
||||
<h2>Asset Manager</h2>
|
||||
<p>Upload and reuse hosted images from this domain.</p>
|
||||
<p>Upload, tag, 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">
|
||||
<table className="admin-visits-table admin-assets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>URL</th>
|
||||
<th>Tags</th>
|
||||
<th>Size</th>
|
||||
<th>Updated</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1486,9 +1496,20 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={assetTagEdits[asset.filename] ?? ''}
|
||||
onChange={e => handleAssetTagChange(asset.filename, e.target.value)}
|
||||
placeholder="comma separated tags"
|
||||
/>
|
||||
</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>
|
||||
<td className="admin-assets-actions">
|
||||
<button type="button" className="btn-admin-apply" onClick={() => handleSaveAssetTags(asset.filename)}>Save Tags</button>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1496,19 +1517,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</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' && (
|
||||
@@ -1731,6 +1740,31 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Deployment & Cache Status</h2>
|
||||
<p>Current deployment metadata and cache purge health for the live app.</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={handleExport}>Export JSON</button>
|
||||
<button type="button" className="btn-admin-reset" onClick={handleBackupNow}>Backup Now</button>
|
||||
@@ -1738,6 +1772,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
<button type="button" className="btn-admin-remove" onClick={handleClear}>Clear Analytics</button>
|
||||
</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>
|
||||
|
||||
<div className="admin-restore-row">
|
||||
<label htmlFor="restore-backup">Restore Backup</label>
|
||||
<select
|
||||
@@ -1776,7 +1816,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
{adminTab === 'content' && (
|
||||
<form
|
||||
className="admin-form"
|
||||
onSubmit={e => { e.preventDefault(); handleSave() }}
|
||||
onSubmit={e => { e.preventDefault(); handleSaveDraft() }}
|
||||
>
|
||||
<div className="admin-tabs" role="tablist" aria-label="Content editor tabs">
|
||||
<button
|
||||
@@ -1788,6 +1828,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
>
|
||||
Main Content
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={contentTab === 'resources'}
|
||||
className={`admin-tab ${contentTab === 'resources' ? 'admin-tab--active' : ''}`}
|
||||
onClick={() => handleContentTabChange('resources')}
|
||||
>
|
||||
Resources
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -1836,12 +1885,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`field-${key}`}
|
||||
type="text"
|
||||
value={form[key] as string}
|
||||
onChange={e => handleChange(key, e.target.value)}
|
||||
/>
|
||||
<>
|
||||
<input
|
||||
id={`field-${key}`}
|
||||
type="text"
|
||||
value={form[key] as string}
|
||||
onChange={e => handleChange(key, e.target.value)}
|
||||
/>
|
||||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -1852,12 +1904,84 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{contentTab === 'resources' && (
|
||||
<>
|
||||
<div className="admin-content-summary">
|
||||
<div className="admin-summary-card">
|
||||
<h3>Resource Links</h3>
|
||||
<p>{(form.customLinks ?? []).filter(link => link.placement === 'resources').length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-section-header">
|
||||
<h3>More Resources</h3>
|
||||
<p>Manage additional resources shown on the More Resources page.</p>
|
||||
</div>
|
||||
{(form.customLinks ?? []).filter(link => link.placement === 'resources').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)}
|
||||
/>
|
||||
</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))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addResource}>
|
||||
+ Add Resource
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{contentTab === 'custom' && (
|
||||
<>
|
||||
<div className="admin-content-summary">
|
||||
<div className="admin-summary-card">
|
||||
<h3>Custom Links</h3>
|
||||
<p>{(form.customLinks ?? []).length}</p>
|
||||
<p>{(form.customLinks ?? []).filter(link => link.placement !== 'resources').length}</p>
|
||||
</div>
|
||||
<div className="admin-summary-card">
|
||||
<h3>Custom Blocks</h3>
|
||||
@@ -1871,12 +1995,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
|
||||
<div className="admin-section-header">
|
||||
<h3>Custom Links</h3>
|
||||
<p>Add links to show in the platform buttons row, footer, or a dedicated "More Resources" section.</p>
|
||||
<p>Add links to show in the platform buttons row or footer navigation.</p>
|
||||
</div>
|
||||
{(form.customLinks ?? []).length === 0 && (
|
||||
{(form.customLinks ?? []).filter(link => link.placement !== 'resources').length === 0 && (
|
||||
<p className="admin-stats-note">No custom links yet.</p>
|
||||
)}
|
||||
{(form.customLinks ?? []).map(link => (
|
||||
{(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">
|
||||
@@ -1899,6 +2023,27 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
onChange={e => updateLink(link.id, 'url', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`link-image-${link.id}`}>Image URL</label>
|
||||
<input
|
||||
id={`link-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), `link-image-${link.id}-asset`)}
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`link-tags-${link.id}`}>Tags</label>
|
||||
<input
|
||||
id={`link-tags-${link.id}`}
|
||||
type="text"
|
||||
value={(link.tags ?? []).join(', ')}
|
||||
placeholder="listen, podcast, study"
|
||||
onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`link-placement-${link.id}`}>Show in</label>
|
||||
<select
|
||||
@@ -2020,6 +2165,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
placeholder="/images/titus-cover.png"
|
||||
onChange={e => updateArchivedSeries(series.id, 'imageUrl', e.target.value)}
|
||||
/>
|
||||
{renderImageAssetSelector(series.imageUrl, value => updateArchivedSeries(series.id, 'imageUrl', value), `archive-image-${series.id}-asset`)}
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-listen-${series.id}`}>Listen URL</label>
|
||||
@@ -2184,13 +2330,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
)}
|
||||
|
||||
<div className="admin-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-admin-save"
|
||||
disabled={status === 'saving'}
|
||||
>
|
||||
{status === 'saving' ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-reset"
|
||||
|
||||
Reference in New Issue
Block a user