Apply master-detail pattern to Ep. Highlights and Download Library; v1.1.52
Publish Container / docker (push) Successful in 13m55s

Episode Highlights and the Downloads Library section now use the same compact
list → click to edit pattern as Studies. Both sections show a scannable row
list (title, key meta, edit/delete buttons) and open a focused editor for a
single item. Testimonials and Finished Books remain as collapsible cards since
they have few fields and grow slowly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-03 09:31:43 -04:00
1 parent 14665d06b5
commit 0bf2d92b1f
2 files changed
+180 -115

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.1.51", "version": "1.1.52",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+179 -114
View File
@@ -1111,6 +1111,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [rssEpisodes, setRssEpisodes] = useState<Array<{ title: string; audioUrl: string; duration: string; episode: string }>>([]) const [rssEpisodes, setRssEpisodes] = useState<Array<{ title: string; audioUrl: string; duration: string; episode: string }>>([])
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null) const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null)
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null) const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null)
const [selectedHighlightId, setSelectedHighlightId] = useState<string | null>(null)
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null)
const [dashboardNow, setDashboardNow] = useState(() => new Date()) const [dashboardNow, setDashboardNow] = useState(() => new Date())
const [manualQuestion, setManualQuestion] = useState({ const [manualQuestion, setManualQuestion] = useState({
@@ -3335,68 +3337,99 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)} )}
{/* PODCAST — EPISODE HIGHLIGHTS */} {/* PODCAST — EPISODE HIGHLIGHTS */}
{adminView === 'podcast:highlights' && ( {adminView === 'podcast:highlights' && (() => {
<section className="admin-panel-section" aria-label="Episode highlights"> const activeHighlight = selectedHighlightId ? (form.podcastFeaturedLinks ?? []).find(h => h.id === selectedHighlightId) ?? null : null
<div className="admin-panel-head"> return (
<h2>Episode Highlights</h2> <section className="admin-panel-section" aria-label="Episode highlights">
<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> {/* List view */}
{(form.podcastFeaturedLinks ?? []).length === 0 && ( {!activeHighlight && (
<p className="admin-stats-note">No episode highlights yet. Add one below.</p> <>
)} <div className="admin-panel-head">
{(form.podcastFeaturedLinks ?? []).map(item => ( <h2>Episode Highlights</h2>
<AdminCollapsibleCard <p>Featured episodes shown on the Episodes page. Each highlight gets its own detail page at <code>/episodes/[id]</code>.</p>
key={item.id} </div>
title={item.title || 'Untitled episode highlight'} {(form.podcastFeaturedLinks ?? []).length === 0 && <p className="admin-stats-note">No episode highlights yet. Add one below.</p>}
subtitle={item.episodeNumber ? `Episode ${item.episodeNumber}` : 'No episode number yet'} <div className="study-lesson-list-header">
> <span style={{ flex: '0 0 70px' }}>Ep #</span>
<div className="admin-array-row"> <span style={{ flex: 1 }}>Title</span>
<div className="admin-array-fields"> <span style={{ flex: '0 0 80px' }}>Audio</span>
<div className="admin-field"> <span style={{ width: 160 }} />
<label htmlFor={`podcast-ep-${item.id}`}>Episode Number</label> </div>
<input id={`podcast-ep-${item.id}`} type="text" placeholder="e.g. 42" value={item.episodeNumber ?? ''} onChange={e => updatePodcastLink(item.id, 'episodeNumber', e.target.value)} /> {(form.podcastFeaturedLinks ?? []).map(item => (
</div> <div key={item.id} className="study-lesson-row">
<div className="admin-field"> <span className="study-lesson-ref" style={{ width: 70 }}>{item.episodeNumber ? `Ep. ${item.episodeNumber}` : '—'}</span>
<label htmlFor={`podcast-title-${item.id}`}>Title</label> <span className="study-lesson-title">{item.title || <em style={{ opacity: 0.5 }}>Untitled</em>}</span>
<input id={`podcast-title-${item.id}`} type="text" value={item.title} onChange={e => updatePodcastLink(item.id, 'title', e.target.value)} /> <span className={`study-lesson-pill ${item.embedUrl ? 'live' : 'draft'}`} style={{ width: 68 }}>{item.embedUrl ? 'Audio' : 'No audio'}</span>
</div> <div className="study-lesson-actions">
<div className="admin-field"> <button type="button" className="btn-admin-ghost" onClick={() => setSelectedHighlightId(item.id)}>Edit</button>
<label htmlFor={`podcast-summary-${item.id}`}>Summary</label> {item.id && <a href={`/episodes/${item.id}`} target="_blank" rel="noopener noreferrer" className="btn-admin-ghost">Preview</a>}
<textarea id={`podcast-summary-${item.id}`} rows={2} value={item.summary} onChange={e => updatePodcastLink(item.id, 'summary', e.target.value)} /> <button type="button" className="btn-admin-remove" style={{ fontSize: '0.8rem', padding: '0.2rem 0.5rem' }} onClick={() => removePodcastLink(item.id)}>✕</button>
</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}`}>Episode Audio (optional — enables in-page player)</label>
<select id={`podcast-embed-${item.id}`} value={item.embedUrl ?? ''} onChange={e => updatePodcastLink(item.id, 'embedUrl', e.target.value)}>
<option value="">— No audio —</option>
{rssEpisodes.map(ep => (
<option key={ep.audioUrl} value={ep.audioUrl}>{ep.title}</option>
))}
{item.embedUrl && !rssEpisodes.some(ep => ep.audioUrl === item.embedUrl) && (
<option value={item.embedUrl}>{item.embedUrl}</option>
)}
</select>
</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>
</div> </div>
<button type="button" className="btn-admin-remove" onClick={() => removePodcastLink(item.id)}>Remove</button> ))}
<button type="button" className="btn-admin-add" style={{ marginTop: '0.75rem' }} onClick={addPodcastLink}>+ Add Episode Highlight</button>
{renderSaveStatus()}
</>
)}
{/* Detail view */}
{activeHighlight && (
<>
<div className="study-drill-header">
<button type="button" className="study-back-btn" onClick={() => setSelectedHighlightId(null)}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 12L6 8l4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
Episode Highlights
</button>
{activeHighlight.id && <a href={`/episodes/${activeHighlight.id}`} target="_blank" rel="noopener noreferrer" className="btn-admin-ghost">Preview ↗</a>}
</div> </div>
</AdminCollapsibleCard> <div className="admin-panel-subhead" style={{ marginBottom: '1rem' }}>
))} <h3>{activeHighlight.title || 'Untitled Highlight'}{activeHighlight.episodeNumber ? ` · Ep. ${activeHighlight.episodeNumber}` : ''}</h3>
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button> </div>
{renderSaveStatus()} <div className="admin-array-fields">
</section> <div className="admin-field">
)} <label htmlFor={`podcast-ep-${activeHighlight.id}`}>Episode Number</label>
<input id={`podcast-ep-${activeHighlight.id}`} type="text" placeholder="e.g. 42" value={activeHighlight.episodeNumber ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'episodeNumber', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-title-${activeHighlight.id}`}>Title</label>
<input id={`podcast-title-${activeHighlight.id}`} type="text" value={activeHighlight.title} onChange={e => updatePodcastLink(activeHighlight.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-summary-${activeHighlight.id}`}>Summary</label>
<textarea id={`podcast-summary-${activeHighlight.id}`} rows={2} value={activeHighlight.summary} onChange={e => updatePodcastLink(activeHighlight.id, 'summary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-url-${activeHighlight.id}`}>Platform URL (external link)</label>
<input id={`podcast-url-${activeHighlight.id}`} type="text" placeholder="https://open.spotify.com/..." value={activeHighlight.url} onChange={e => updatePodcastLink(activeHighlight.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-embed-${activeHighlight.id}`}>Episode Audio (optional — enables in-page player)</label>
<select id={`podcast-embed-${activeHighlight.id}`} value={activeHighlight.embedUrl ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'embedUrl', e.target.value)}>
<option value="">— No audio —</option>
{rssEpisodes.map(ep => <option key={ep.audioUrl} value={ep.audioUrl}>{ep.title}</option>)}
{activeHighlight.embedUrl && !rssEpisodes.some(ep => ep.audioUrl === activeHighlight.embedUrl) && (
<option value={activeHighlight.embedUrl}>{activeHighlight.embedUrl}</option>
)}
</select>
</div>
<div className="admin-field">
<label htmlFor={`podcast-notes-${activeHighlight.id}`}>Show Notes</label>
<textarea id={`podcast-notes-${activeHighlight.id}`} rows={4} placeholder="Key points, scripture references, timestamps..." value={activeHighlight.showNotes ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'showNotes', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-dq-${activeHighlight.id}`}>Discussion Questions (one per line)</label>
<textarea id={`podcast-dq-${activeHighlight.id}`} rows={5} placeholder="What stood out to you in this passage?" value={(activeHighlight.discussionQuestions ?? []).join('\n')} onChange={e => updatePodcastLinkQuestions(activeHighlight.id, e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" style={{ marginTop: '1rem' }} onClick={() => { removePodcastLink(activeHighlight.id); setSelectedHighlightId(null) }}>Remove Highlight</button>
{renderSaveStatus()}
</>
)}
</section>
)
})()}
{/* PODCAST — FINISHED BOOKS */} {/* PODCAST — FINISHED BOOKS */}
{adminView === 'podcast:finished-books' && ( {adminView === 'podcast:finished-books' && (
@@ -3500,62 +3533,94 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</div> </div>
</div> </div>
<div className="admin-section-header" id="downloads-library"> {/* Download Library — master-detail */}
<h3>Download Library</h3> {(() => {
<p>Manage the current downloads shown below the featured guide.</p> const activeRes = selectedResourceId ? resourceLinks.find(l => l.id === selectedResourceId) ?? null : null
</div> return (
{resourceLinks.length === 0 && <p className="admin-stats-note">No resources yet.</p>} <>
{resourceLinks.map(link => ( <div className="admin-section-header" id="downloads-library" style={activeRes ? { display: 'none' } : {}}>
<AdminCollapsibleCard <h3>Download Library</h3>
key={link.id} <p>Manage the current downloads shown below the featured guide.</p>
title={link.label || 'Untitled download'}
subtitle={link.description || 'Current download item'}
>
<div 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-description-${link.id}`}>Description</label>
<textarea id={`resource-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateLink(link.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-amazon-url-${link.id}`}>Amazon URL (optional)</label>
<input id={`resource-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateLink(link.id, 'amazonUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-amazon-label-${link.id}`}>Amazon Button Label</label>
<input id={`resource-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateLink(link.id, 'amazonLabel', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-apple-books-url-${link.id}`}>Apple Books URL (optional)</label>
<input id={`resource-apple-books-url-${link.id}`} type="url" value={link.appleBooksUrl ?? ''} placeholder="https://books.apple.com/..." onChange={e => updateLink(link.id, 'appleBooksUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-apple-books-label-${link.id}`}>Apple Books Button Label</label>
<input id={`resource-apple-books-label-${link.id}`} type="text" value={link.appleBooksLabel ?? ''} placeholder="Get it on Apple Books" onChange={e => updateLink(link.id, 'appleBooksLabel', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-image-${link.id}`}>Image URL</label>
<input id={`resource-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `resource-image-${link.id}-asset`)}
</div>
<div className="admin-field">
<label htmlFor={`resource-tags-${link.id}`}>Tags</label>
<input id={`resource-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="sermon, bible study, faith" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
</div> </div>
</AdminCollapsibleCard>
))} {!activeRes && (
<button type="button" className="btn-admin-add" onClick={addResource}>+ Add Download</button> <>
{resourceLinks.length === 0 && <p className="admin-stats-note">No resources yet. Add one below.</p>}
<div className="study-lesson-list-header">
<span style={{ flex: 1 }}>Label</span>
<span style={{ flex: '0 0 140px' }}>Tags</span>
<span style={{ width: 130 }} />
</div>
{resourceLinks.map(link => (
<div key={link.id} className="study-lesson-row">
<span className="study-lesson-title">{link.label || <em style={{ opacity: 0.5 }}>Untitled</em>}</span>
<span className="study-lesson-ref" style={{ width: 140, fontSize: '0.78rem' }}>{(link.tags ?? []).slice(0, 2).join(', ') || '—'}</span>
<div className="study-lesson-actions">
<button type="button" className="btn-admin-ghost" onClick={() => setSelectedResourceId(link.id)}>Edit</button>
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.8rem', padding: '0.2rem 0.5rem' }} onClick={() => removeLink(link.id)}>✕</button>
</div>
</div>
))}
<button type="button" className="btn-admin-add" style={{ marginTop: '0.75rem' }} onClick={addResource}>+ Add Download</button>
</>
)}
{activeRes && (
<>
<div className="study-drill-header">
<button type="button" className="study-back-btn" onClick={() => setSelectedResourceId(null)}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 12L6 8l4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
Download Library
</button>
</div>
<div className="admin-panel-subhead" style={{ marginBottom: '1rem' }}>
<h3>{activeRes.label || 'Untitled Download'}</h3>
</div>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`resource-label-${activeRes.id}`}>Label</label>
<input id={`resource-label-${activeRes.id}`} type="text" value={activeRes.label} placeholder="Resource title" onChange={e => updateLink(activeRes.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-url-${activeRes.id}`}>URL</label>
<input id={`resource-url-${activeRes.id}`} type="url" value={activeRes.url} placeholder="https://..." onChange={e => updateLink(activeRes.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-description-${activeRes.id}`}>Description</label>
<textarea id={`resource-description-${activeRes.id}`} rows={3} value={activeRes.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateLink(activeRes.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-amazon-url-${activeRes.id}`}>Amazon URL (optional)</label>
<input id={`resource-amazon-url-${activeRes.id}`} type="url" value={activeRes.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateLink(activeRes.id, 'amazonUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-amazon-label-${activeRes.id}`}>Amazon Button Label</label>
<input id={`resource-amazon-label-${activeRes.id}`} type="text" value={activeRes.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateLink(activeRes.id, 'amazonLabel', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-apple-books-url-${activeRes.id}`}>Apple Books URL (optional)</label>
<input id={`resource-apple-books-url-${activeRes.id}`} type="url" value={activeRes.appleBooksUrl ?? ''} placeholder="https://books.apple.com/..." onChange={e => updateLink(activeRes.id, 'appleBooksUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-apple-books-label-${activeRes.id}`}>Apple Books Button Label</label>
<input id={`resource-apple-books-label-${activeRes.id}`} type="text" value={activeRes.appleBooksLabel ?? ''} placeholder="Get it on Apple Books" onChange={e => updateLink(activeRes.id, 'appleBooksLabel', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resource-image-${activeRes.id}`}>Image URL</label>
<input id={`resource-image-${activeRes.id}`} type="text" value={activeRes.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(activeRes.id, 'imageUrl', e.target.value)} />
{renderImageAssetSelector(activeRes.imageUrl, value => updateLink(activeRes.id, 'imageUrl', value), `resource-image-${activeRes.id}-asset`)}
</div>
<div className="admin-field">
<label htmlFor={`resource-tags-${activeRes.id}`}>Tags</label>
<input id={`resource-tags-${activeRes.id}`} type="text" value={(activeRes.tags ?? []).join(', ')} placeholder="sermon, bible study, faith" onChange={e => updateLink(activeRes.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
</div>
</div>
<button type="button" className="btn-admin-remove" style={{ marginTop: '1rem' }} onClick={() => { removeLink(activeRes.id); setSelectedResourceId(null) }}>Remove Download</button>
</>
)}
</>
)
})()}
{renderSaveStatus()} {renderSaveStatus()}
</section> </section>