Rebrand site for podcast, add admin CMS, social metadata, and platform links
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { SiteContent, CustomLink, CustomBlock } from './App'
|
||||
import { DEFAULTS } from './App'
|
||||
|
||||
interface Props {
|
||||
content: SiteContent
|
||||
onSave: (c: SiteContent) => void
|
||||
}
|
||||
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks'>
|
||||
|
||||
const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [
|
||||
{ key: 'eyebrow', label: 'Hero Eyebrow Text' },
|
||||
{ key: 'heroTagline', label: 'Hero Tagline' },
|
||||
{ key: 'aboutShowHeading', label: 'About Show — Heading' },
|
||||
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true },
|
||||
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true },
|
||||
{ key: 'aboutNate', label: 'About Nate', multiline: true },
|
||||
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")' },
|
||||
{ key: 'seriesTitle', label: 'Series Title' },
|
||||
{ key: 'seriesDescription', label: 'Series Description', multiline: true },
|
||||
{ key: 'studyGuideTitle', label: 'Study Guide Title' },
|
||||
{ key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true },
|
||||
{ key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)' },
|
||||
{ key: 'shareHeading', label: 'Share Section — Heading' },
|
||||
{ key: 'shareP', label: 'Share Section — Paragraph', multiline: true },
|
||||
]
|
||||
|
||||
export default function AdminPage({ content, onSave }: Props) {
|
||||
const [form, setForm] = useState<SiteContent>(content)
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
function handleChange(key: StringField, value: string) {
|
||||
setForm(f => ({ ...f, [key]: value }))
|
||||
}
|
||||
|
||||
function addLink() {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customLinks: [
|
||||
...(f.customLinks ?? []),
|
||||
{ id: Date.now().toString(36), label: '', url: '', placement: 'platforms' as const },
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function updateLink(id: string, field: keyof CustomLink, value: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customLinks: (f.customLinks ?? []).map(l => l.id === id ? { ...l, [field]: value } : l),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeLink(id: string) {
|
||||
setForm(f => ({ ...f, customLinks: (f.customLinks ?? []).filter(l => l.id !== id) }))
|
||||
}
|
||||
|
||||
function addBlock() {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customBlocks: [
|
||||
...(f.customBlocks ?? []),
|
||||
{ id: Date.now().toString(36), heading: '', body: '' },
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function updateBlock(id: string, field: keyof CustomBlock, value: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customBlocks: (f.customBlocks ?? []).map(b => b.id === id ? { ...b, [field]: value } : b),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeBlock(id: string) {
|
||||
setForm(f => ({ ...f, customBlocks: (f.customBlocks ?? []).filter(b => b.id !== id) }))
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
setStatus('idle')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-header">
|
||||
<span className="admin-ornament">✦ ✦ ✦</span>
|
||||
<h1>Site Admin</h1>
|
||||
<p className="admin-sub">Verse by Verse with Nate</p>
|
||||
<Link to="/" className="admin-back">← Back to site</Link>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-wrap">
|
||||
<form
|
||||
className="admin-form"
|
||||
onSubmit={e => { e.preventDefault(); handleSave() }}
|
||||
>
|
||||
{FIELDS.map(({ key, label, multiline }) => (
|
||||
<div className="admin-field" key={key}>
|
||||
<label htmlFor={`field-${key}`}>{label}</label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
id={`field-${key}`}
|
||||
value={form[key] as string}
|
||||
onChange={e => handleChange(key, e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`field-${key}`}
|
||||
type="text"
|
||||
value={form[key] as string}
|
||||
onChange={e => handleChange(key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Custom Links ── */}
|
||||
<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>
|
||||
</div>
|
||||
{(form.customLinks ?? []).map(link => (
|
||||
<div key={link.id} className="admin-array-row">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`link-label-${link.id}`}>Label</label>
|
||||
<input
|
||||
id={`link-label-${link.id}`}
|
||||
type="text"
|
||||
value={link.label}
|
||||
placeholder="e.g. iHeart Radio"
|
||||
onChange={e => updateLink(link.id, 'label', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`link-url-${link.id}`}>URL</label>
|
||||
<input
|
||||
id={`link-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={`link-placement-${link.id}`}>Show in</label>
|
||||
<select
|
||||
id={`link-placement-${link.id}`}
|
||||
value={link.placement}
|
||||
onChange={e => updateLink(link.id, 'placement', e.target.value)}
|
||||
>
|
||||
<option value="platforms">Platform Buttons (Listen section)</option>
|
||||
<option value="footer">Footer Nav</option>
|
||||
<option value="resources">More Resources Section</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addLink}>
|
||||
+ Add Link
|
||||
</button>
|
||||
|
||||
{/* ── Custom Blocks ── */}
|
||||
<div className="admin-section-header">
|
||||
<h3>Custom Content Blocks</h3>
|
||||
<p>Add extra text sections. They appear below the share/QR section on the site.</p>
|
||||
</div>
|
||||
{(form.customBlocks ?? []).map(block => (
|
||||
<div key={block.id} className="admin-array-row">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`block-heading-${block.id}`}>Heading</label>
|
||||
<input
|
||||
id={`block-heading-${block.id}`}
|
||||
type="text"
|
||||
value={block.heading}
|
||||
placeholder="Section heading"
|
||||
onChange={e => updateBlock(block.id, 'heading', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`block-body-${block.id}`}>Body Text</label>
|
||||
<textarea
|
||||
id={`block-body-${block.id}`}
|
||||
value={block.body}
|
||||
rows={3}
|
||||
placeholder="Write your content here…"
|
||||
onChange={e => updateBlock(block.id, 'body', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeBlock(block.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addBlock}>
|
||||
+ Add Content Block
|
||||
</button>
|
||||
|
||||
<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"
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset to Defaults
|
||||
</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>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user