Redo /newsletter and /nuggets pages; add Nuggets episode tags with admin editor; v2.37.0
Publish Container / build (push) Successful in 16m20s
Publish Container / build (push) Successful in 16m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba35ccab19
commit
5d5c026762
9 files changed
+446
-85
No files matched your search
+112
-1
@@ -740,7 +740,7 @@ type AdminView =
|
||||
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
||||
| 'contact:profile' | 'contact:copy'
|
||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | 'podcast:season-check' | 'podcast:rss'
|
||||
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow'
|
||||
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow' | 'nuggets:tags'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||
| 'inbox'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||
@@ -802,6 +802,7 @@ const ADMIN_NAV: NavGroup[] = [
|
||||
{ kind: 'leaf', view: 'nuggets:announcement', label: 'Announcement' },
|
||||
{ kind: 'leaf', view: 'nuggets:guidelines', label: 'Recording Guidelines' },
|
||||
{ kind: 'leaf', view: 'nuggets:workflow', label: 'Episode Workflow' },
|
||||
{ kind: 'leaf', view: 'nuggets:tags', label: 'Episode Tags' },
|
||||
{ kind: 'leaf', view: 'nuggets:history', label: 'Upload History' },
|
||||
]},
|
||||
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
|
||||
@@ -1627,6 +1628,115 @@ function NuggetsHistoryAdmin() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Nuggets Episode Tags admin ─────────────────────────────────────────────
|
||||
interface NuggetsTagEpisode {
|
||||
title: string
|
||||
pubDate: string
|
||||
audioUrl: string
|
||||
episode: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
function NuggetsTagsAdmin() {
|
||||
const [episodes, setEpisodes] = useState<NuggetsTagEpisode[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({})
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/nuggets/episode-tags')
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||
.then(d => setEpisodes(Array.isArray(d.episodes) ? d.episodes : []))
|
||||
.catch(() => setError('Could not load episodes. Check the RSS feed URL in Show Settings.'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const allTags = Array.from(new Set(episodes.flatMap(e => e.tags))).sort((a, b) => a.localeCompare(b))
|
||||
|
||||
async function saveTags(audioUrl: string, tags: string[]) {
|
||||
const prev = episodes
|
||||
setEpisodes(eps => eps.map(e => e.audioUrl === audioUrl ? { ...e, tags } : e))
|
||||
try {
|
||||
const r = await fetch('/api/admin/nuggets/episode-tags', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ audioUrl, tags }),
|
||||
})
|
||||
if (!r.ok) throw new Error()
|
||||
const d = await r.json()
|
||||
setEpisodes(eps => eps.map(e => e.audioUrl === audioUrl ? { ...e, tags: d.tags ?? tags } : e))
|
||||
} catch {
|
||||
setEpisodes(prev)
|
||||
setError('Could not save tags. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
function addTags(ep: NuggetsTagEpisode) {
|
||||
const raw = (drafts[ep.audioUrl] ?? '').split(',').map(t => t.trim()).filter(Boolean)
|
||||
if (!raw.length) return
|
||||
setDrafts(d => ({ ...d, [ep.audioUrl]: '' }))
|
||||
saveTags(ep.audioUrl, [...ep.tags, ...raw.filter(t => !ep.tags.some(x => x.toLowerCase() === t.toLowerCase()))])
|
||||
}
|
||||
|
||||
const visible = filter.trim()
|
||||
? episodes.filter(e => `${e.title} ${e.tags.join(' ')}`.toLowerCase().includes(filter.toLowerCase()))
|
||||
: episodes
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="Nuggets episode tags">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Nuggets — Episode Tags</h2>
|
||||
<p>Tag episodes by topic or book. Visitors can filter the Nuggets page by these tags. Type a tag and press Enter (or separate several with commas).</p>
|
||||
</div>
|
||||
{error && <p className="admin-stats-note" role="alert">{error}</p>}
|
||||
{loading ? <p className="admin-stats-note">Loading…</p> : (
|
||||
<>
|
||||
<datalist id="nuggets-tag-suggestions">
|
||||
{allTags.map(t => <option key={t} value={t} />)}
|
||||
</datalist>
|
||||
{episodes.length > 0 && (
|
||||
<div className="admin-field" style={{ maxWidth: 320, marginBottom: 12 }}>
|
||||
<input type="text" placeholder="Filter by title or tag…" value={filter} onChange={e => setFilter(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
{visible.length === 0 ? (
|
||||
<p className="admin-stats-note">{episodes.length === 0 ? 'No episodes found in the feed yet.' : 'No results match that filter.'}</p>
|
||||
) : visible.map(ep => (
|
||||
<div key={ep.audioUrl} style={{ padding: '10px 0', borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>
|
||||
{ep.episode ? `Ep. ${ep.episode} — ` : ''}{ep.title}
|
||||
<span style={{ fontWeight: 400, opacity: 0.6, marginLeft: 8, fontSize: '0.8rem' }}>
|
||||
{Number.isNaN(new Date(ep.pubDate).getTime()) ? '' : new Date(ep.pubDate).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||
{ep.tags.map(t => (
|
||||
<span key={t} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: 'rgba(201,168,76,0.15)', borderRadius: 999, padding: '2px 10px', fontSize: '0.82rem' }}>
|
||||
{t}
|
||||
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => saveTags(ep.audioUrl, ep.tags.filter(x => x !== t))} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 0, fontSize: '1rem', lineHeight: 1 }}>×</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
list="nuggets-tag-suggestions"
|
||||
placeholder="Add tag…"
|
||||
aria-label={`Add tag to ${ep.title}`}
|
||||
value={drafts[ep.audioUrl] ?? ''}
|
||||
onChange={e => setDrafts(d => ({ ...d, [ep.audioUrl]: e.target.value }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTags(ep) } }}
|
||||
onBlur={() => addTags(ep)}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Nuggets Episode Workflow admin ─────────────────────────────────────────
|
||||
type WorkflowStepKey = 'listen' | 'coldOpen' | 'recordColdOpen' | 'edit' | 'spotify'
|
||||
|
||||
@@ -6626,6 +6736,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
{/* NUGGETS UPLOAD HISTORY */}
|
||||
{adminView === 'nuggets:workflow' && <NuggetsWorkflowAdmin />}
|
||||
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
|
||||
{adminView === 'nuggets:tags' && <NuggetsTagsAdmin />}
|
||||
|
||||
{/* STUDY COMMENTS */}
|
||||
{adminView === 'study-comments' && (() => {
|
||||
|
||||
Reference in new issue
Block a user