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
+428
-67
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.36.5",
|
||||
"version": "2.37.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
loadStudySessionsFromDisk,
|
||||
queueStudySessionsWrite,
|
||||
loadNewsletterIssuesFromDisk,
|
||||
loadNuggetsTagsFromDisk,
|
||||
createBackupSnapshot,
|
||||
refreshContentCaches,
|
||||
queueHitStatsWrite,
|
||||
@@ -194,6 +195,7 @@ Promise.all([
|
||||
loadWebhooksFromDisk(),
|
||||
loadStudySessionsFromDisk(),
|
||||
loadNewsletterIssuesFromDisk(),
|
||||
loadNuggetsTagsFromDisk(),
|
||||
refreshContentCaches(),
|
||||
])
|
||||
.catch(err => {
|
||||
|
||||
@@ -73,6 +73,7 @@ export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
|
||||
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
||||
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
|
||||
export const NEWSLETTER_ISSUES_FILE = path.join(DATA_DIR, 'newsletter-issues.json')
|
||||
export const NUGGETS_TAGS_FILE = path.join(DATA_DIR, 'nuggets-tags.json')
|
||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||
|
||||
// Seafile integration for Nuggets episode uploads
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
WEBHOOKS_FILE,
|
||||
STUDY_SESSIONS_FILE,
|
||||
NEWSLETTER_ISSUES_FILE,
|
||||
NUGGETS_TAGS_FILE,
|
||||
EMPTY_HIT_STATS,
|
||||
EMPTY_VISITOR_STATS,
|
||||
DEFAULT_REPLY_TEMPLATES,
|
||||
@@ -1588,3 +1589,27 @@ export function loadNewsletterIssuesFromDisk() {
|
||||
state.newsletterIssues = []
|
||||
})
|
||||
}
|
||||
|
||||
// ── Nuggets Episode Tags ─────────────────────────────────────────────────────
|
||||
|
||||
export function queueNuggetsTagsWrite() {
|
||||
state.nuggetsEpisodeTagsWritePromise = state.nuggetsEpisodeTagsWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(NUGGETS_TAGS_FILE, JSON.stringify({ tags: state.nuggetsEpisodeTags }, null, 2), 'utf8')
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[nuggets-tags] failed to write:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export function loadNuggetsTagsFromDisk() {
|
||||
return readFile(NUGGETS_TAGS_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
state.nuggetsEpisodeTags = parsed?.tags && typeof parsed.tags === 'object' ? parsed.tags : {}
|
||||
})
|
||||
.catch(() => {
|
||||
state.nuggetsEpisodeTags = {}
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { parseRssItems } from '../rss-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { queueNuggetsTagsWrite } from '../data.js'
|
||||
|
||||
let cache = null // { url, episodes }
|
||||
let cacheAt = 0
|
||||
@@ -37,12 +39,47 @@ export async function fetchNuggetsEpisodes() {
|
||||
}
|
||||
}
|
||||
|
||||
function withTags(episodes) {
|
||||
return episodes.map(ep => ({ ...ep, tags: state.nuggetsEpisodeTags[ep.audioUrl] ?? [] }))
|
||||
}
|
||||
|
||||
function cleanTags(input) {
|
||||
const list = Array.isArray(input) ? input : []
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const t of list) {
|
||||
const tag = String(t ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
|
||||
const key = tag.toLowerCase()
|
||||
if (tag && !seen.has(key)) { seen.add(key); out.push(tag) }
|
||||
}
|
||||
return out.slice(0, 12)
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin/nuggets/episode-tags', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
const episodes = await fetchNuggetsEpisodes()
|
||||
res.json({ episodes: withTags(episodes).map(({ title, pubDate, audioUrl, episode, tags }) => ({ title, pubDate, audioUrl, episode, tags })) })
|
||||
} catch (err) {
|
||||
res.status(502).json({ message: 'Could not load episodes from the feed.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/admin/nuggets/episode-tags', requireAdminAuth, (req, res) => {
|
||||
const audioUrl = String(req.body?.audioUrl ?? '')
|
||||
if (!audioUrl) { res.status(400).json({ message: 'audioUrl is required.' }); return }
|
||||
const tags = cleanTags(req.body?.tags)
|
||||
if (tags.length) state.nuggetsEpisodeTags[audioUrl] = tags
|
||||
else delete state.nuggetsEpisodeTags[audioUrl]
|
||||
queueNuggetsTagsWrite()
|
||||
res.json({ ok: true, tags })
|
||||
})
|
||||
|
||||
app.get('/api/nuggets/episodes', async (_req, res) => {
|
||||
const configured = Boolean(feedUrl())
|
||||
try {
|
||||
const episodes = await fetchNuggetsEpisodes()
|
||||
res.json({ episodes: episodes.slice(0, 6), configured })
|
||||
res.json({ episodes: withTags(episodes.slice(0, 6)), configured })
|
||||
} catch (err) {
|
||||
console.error('[nuggets/episodes] RSS fetch error:', err.message)
|
||||
res.json({ episodes: [], configured })
|
||||
@@ -53,7 +90,7 @@ export function register(app) {
|
||||
const configured = Boolean(feedUrl())
|
||||
try {
|
||||
const episodes = await fetchNuggetsEpisodes()
|
||||
res.json({ episodes, configured })
|
||||
res.json({ episodes: withTags(episodes), configured })
|
||||
} catch (err) {
|
||||
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
|
||||
res.json({ episodes: [], configured })
|
||||
|
||||
@@ -96,6 +96,9 @@ export const state = {
|
||||
// newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }>
|
||||
newsletterIssues: [],
|
||||
newsletterIssuesWritePromise: Promise.resolve(),
|
||||
// nuggetsEpisodeTags: { [audioUrl]: string[] } — admin-assigned tags for Nuggets RSS episodes
|
||||
nuggetsEpisodeTags: {},
|
||||
nuggetsEpisodeTagsWritePromise: Promise.resolve(),
|
||||
|
||||
|
||||
auditLog: [],
|
||||
|
||||
+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' && (() => {
|
||||
|
||||
+71
@@ -13908,3 +13908,74 @@ a.study-track-card:hover {
|
||||
.home-feature--image { background-size: cover; background-position: center; }
|
||||
.home-feature--image h3 { text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); }
|
||||
.home-feature--image p { color: #d9ceb0; }
|
||||
|
||||
/* ── Newsletter page ── */
|
||||
.nl-top { padding: 3rem 0 1rem; }
|
||||
.nl-top-grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 1.5rem; align-items: stretch; }
|
||||
.nl-top .home-connect-card--newsletter { margin: 0; }
|
||||
.nl-featured {
|
||||
display: flex; flex-direction: column; gap: 0.9rem; padding: 2rem;
|
||||
background: linear-gradient(145deg, #17170f, #0c0c08);
|
||||
border: 1px solid rgba(201, 168, 76, 0.3); border-radius: 16px;
|
||||
text-decoration: none; color: inherit;
|
||||
transition: transform 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
a.nl-featured:hover { transform: translateY(-2px); border-color: rgba(201, 168, 76, 0.6); }
|
||||
.nl-featured-tag { align-self: flex-start; font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: #c9a84c; border: 1px solid rgba(201, 168, 76, 0.4); border-radius: 999px; padding: 0.25rem 0.7rem; }
|
||||
.nl-featured-title { font-size: clamp(1.5rem, 3vw, 2.1rem); line-height: 1.2; color: #f0e9cc; margin: 0; }
|
||||
.nl-featured-desc { color: #a89a6a; margin: 0; line-height: 1.6; }
|
||||
.nl-featured-foot { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
.nl-date { font-size: 0.85rem; color: #8a7e55; }
|
||||
.nl-read { color: #c9a84c; font-weight: 600; font-size: 0.9rem; }
|
||||
.nl-archive { padding: 2rem 0 4rem; }
|
||||
.nl-archive-head { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||
.nl-archive-head .section-heading { margin: 0; }
|
||||
.nl-search { flex: 0 1 260px; padding: 0.6rem 1rem; border-radius: 999px; border: 1px solid rgba(201, 168, 76, 0.3); background: #1a1a12; color: #f0e9cc; font-size: 0.95rem; outline: none; }
|
||||
.nl-search:focus { border-color: rgba(201, 168, 76, 0.7); }
|
||||
.nl-year { margin-top: 2rem; }
|
||||
.nl-year-label { font-size: 0.85rem; letter-spacing: 0.18em; color: #c9a84c; margin: 0 0 0.9rem; padding-bottom: 0.5rem; border-bottom: 1px solid rgba(201, 168, 76, 0.18); }
|
||||
.nl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
|
||||
.nl-card {
|
||||
display: flex; flex-direction: column; gap: 0.5rem; padding: 1.2rem 1.3rem;
|
||||
background: #111; border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 14px;
|
||||
text-decoration: none; color: inherit;
|
||||
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
.nl-card:hover { transform: translateY(-2px); background: rgba(201, 168, 76, 0.06); border-color: rgba(201, 168, 76, 0.32); }
|
||||
.nl-card-title { font-size: 1.1rem; font-weight: 600; color: #f0e9cc; line-height: 1.3; }
|
||||
.nl-card-desc { margin: 0; color: #a89a6a; font-size: 0.9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.nl-card .nl-read { margin-top: auto; padding-top: 0.4rem; }
|
||||
@media (max-width: 800px) { .nl-top-grid { grid-template-columns: 1fr; } .nl-featured { padding: 1.5rem; } }
|
||||
|
||||
/* ── Nuggets page ── */
|
||||
.nug-hero { padding: 3.5rem 0 1rem; border-bottom: 1px solid rgba(201, 168, 76, 0.15); background: radial-gradient(ellipse at top left, rgba(201, 168, 76, 0.08), transparent 60%); }
|
||||
.nug-hero-inner { display: flex; gap: 2.5rem; align-items: center; flex-wrap: wrap; padding-bottom: 2.5rem; }
|
||||
.nug-hero-art { width: 220px; height: 220px; object-fit: cover; border-radius: 18px; border: 1px solid rgba(201, 168, 76, 0.3); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45); flex-shrink: 0; }
|
||||
.nug-hero-body { flex: 1; min-width: 260px; }
|
||||
.nug-hero-title { font-family: var(--brand-font-heading); font-size: clamp(2rem, 5vw, 3.2rem); color: #f0e9cc; margin: 0.2rem 0 0.4rem; line-height: 1.1; }
|
||||
.nug-hero-tagline { font-size: 1.15rem; color: #c9a84c; margin: 0 0 1rem; }
|
||||
.nug-latest { margin: 0 0 3rem; padding: 2rem; border-color: rgba(201, 168, 76, 0.4); background: linear-gradient(145deg, #17170f, #0c0c08); }
|
||||
.nug-list-head { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
|
||||
.nug-list-head .section-heading { margin: 0; }
|
||||
.nug-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
|
||||
.nug-card { display: flex; flex-direction: column; gap: 0.5rem; padding: 1.25rem 1.3rem; background: #111; border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 14px; transition: border-color 160ms ease; }
|
||||
.nug-card:hover { border-color: rgba(201, 168, 76, 0.4); }
|
||||
.nug-card-meta { margin: 0; font-size: 0.78rem; letter-spacing: 0.06em; text-transform: uppercase; color: #8a7e55; }
|
||||
.nug-card-title { margin: 0; font-size: 1.1rem; line-height: 1.3; color: #f0e9cc; }
|
||||
.nug-card-desc { margin: 0; color: #a89a6a; font-size: 0.9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.nug-card > :last-child { margin-top: auto; }
|
||||
.nug-more { text-align: center; margin-top: 1.75rem; }
|
||||
.nug-hosts { margin-top: 4rem; padding-top: 2.5rem; border-top: 1px solid rgba(201, 168, 76, 0.15); }
|
||||
.nug-hosts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1rem; margin-top: 1.25rem; }
|
||||
.nug-host-card { display: flex; align-items: center; gap: 1rem; padding: 1rem 1.2rem; background: #111; border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 14px; text-decoration: none; transition: transform 160ms ease, border-color 160ms ease; }
|
||||
.nug-host-card:hover { transform: translateY(-2px); border-color: rgba(201, 168, 76, 0.45); }
|
||||
.nug-host-img { width: 64px; height: 64px; object-fit: cover; border-radius: 50%; border: 2px solid rgba(201, 168, 76, 0.35); flex-shrink: 0; }
|
||||
.nug-host-name { display: block; font-family: var(--brand-font-heading); font-size: 1.05rem; color: #e8d9b5; }
|
||||
.nug-host-role { display: block; font-size: 0.85rem; color: #a89a6a; margin-top: 0.15rem; }
|
||||
@media (max-width: 640px) { .nug-hero-art { width: 150px; height: 150px; } .nug-latest { padding: 1.25rem; } .nug-grid { grid-template-columns: 1fr; } }
|
||||
.nug-tags { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.25rem; }
|
||||
.nug-tag { font: inherit; font-size: 0.85rem; color: #c9a84c; background: transparent; border: 1px solid rgba(201, 168, 76, 0.35); border-radius: 999px; padding: 0.3rem 0.9rem; cursor: pointer; transition: background 140ms ease, color 140ms ease; }
|
||||
.nug-tag:hover { background: rgba(201, 168, 76, 0.12); }
|
||||
.nug-tag--active, .nug-tag--active:hover { background: #c9a84c; color: #0c0c08; border-color: #c9a84c; }
|
||||
.nug-tag--small { font-size: 0.74rem; padding: 0.15rem 0.6rem; }
|
||||
.nug-card-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
+174
-63
@@ -2373,6 +2373,7 @@ interface NuggetsEpisode {
|
||||
duration: string
|
||||
episode: string
|
||||
season: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
function NuggetsPage({ content }: { content: SiteContent }) {
|
||||
@@ -2398,50 +2399,51 @@ function NuggetsPage({ content }: { content: SiteContent }) {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [shown, setShown] = useState(12)
|
||||
const [activeTag, setActiveTag] = useState('')
|
||||
|
||||
const [latest, ...rest] = episodes
|
||||
const activeHosts = (nuggets.hosts ?? []).filter(h => !h.archived)
|
||||
const q = query.trim().toLowerCase()
|
||||
const allTags = Array.from(new Set(episodes.flatMap(e => e.tags ?? []))).sort((a, b) => a.localeCompare(b))
|
||||
const filtering = q.length > 0 || activeTag !== ''
|
||||
const filtered = (filtering ? episodes : rest).filter(e =>
|
||||
(!q || `${e.title} ${e.description} ${(e.tags ?? []).join(' ')}`.toLowerCase().includes(q)) &&
|
||||
(!activeTag || (e.tags ?? []).includes(activeTag)),
|
||||
)
|
||||
const visible = filtered.slice(0, shown)
|
||||
const fmtDate = (d: string) => {
|
||||
const t = new Date(d)
|
||||
return Number.isNaN(t.getTime()) ? '' : t.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
const listenLinks = (nuggets.spotifyUrl || nuggets.appleUrl) && (
|
||||
<div className="nuggets-platform-links">
|
||||
{nuggets.spotifyUrl && <a href={nuggets.spotifyUrl} target="_blank" rel="noopener noreferrer" className="btn-primary">Listen on Spotify</a>}
|
||||
{nuggets.appleUrl && <a href={nuggets.appleUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">Listen on Apple Podcasts</a>}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<PageBanner eyebrow="Sub-Show" title={nuggets.title} desc={nuggets.tagline} />
|
||||
|
||||
{(nuggets.podcastArtUrl || nuggets.description || nuggets.spotifyUrl || nuggets.appleUrl) && (
|
||||
<section className="section-nuggets-intro" aria-label={`About ${nuggets.title}`}>
|
||||
<div className="section-inner nuggets-intro-layout">
|
||||
{nuggets.podcastArtUrl && <img src={nuggets.podcastArtUrl} alt={nuggets.title} className="nuggets-podcast-art" />}
|
||||
<div className="nuggets-intro-body">
|
||||
<section className="nug-hero" aria-label={`About ${nuggets.title}`}>
|
||||
<div className="section-inner nug-hero-inner">
|
||||
{nuggets.podcastArtUrl && <img src={nuggets.podcastArtUrl} alt={nuggets.title} className="nug-hero-art" />}
|
||||
<div className="nug-hero-body">
|
||||
<p className="eyebrow">Sub-Show</p>
|
||||
<h1 className="nug-hero-title">{nuggets.title}</h1>
|
||||
{nuggets.tagline && <p className="nug-hero-tagline">{nuggets.tagline}</p>}
|
||||
{nuggets.description && <p className="nuggets-description">{nuggets.description}</p>}
|
||||
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
|
||||
<div className="nuggets-platform-links">
|
||||
{nuggets.spotifyUrl && <a href={nuggets.spotifyUrl} target="_blank" rel="noopener noreferrer" className="btn-primary">Listen on Spotify</a>}
|
||||
{nuggets.appleUrl && <a href={nuggets.appleUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">Listen on Apple Podcasts</a>}
|
||||
</div>
|
||||
)}
|
||||
{listenLinks}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-nuggets-body" aria-label={`${nuggets.title} hosts and episodes`}>
|
||||
<div className="section-inner">
|
||||
{activeHosts.length > 0 && (
|
||||
<div className="nuggets-hosts-row-wrap">
|
||||
<p className="eyebrow" style={{ marginBottom: '1rem' }}>Meet the Hosts</p>
|
||||
<div className="nuggets-hosts-row">
|
||||
{activeHosts.map(host => (
|
||||
<Link key={host.id} to={`/nuggets/hosts/${host.id}`} className="nuggets-host-pill">
|
||||
{host.imageUrl && <img src={host.imageUrl} alt={host.name} className="nuggets-host-pill-img" loading="lazy" />}
|
||||
<span className="nuggets-host-pill-name">{host.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="nuggets-episodes-full">
|
||||
<p className="eyebrow">Episodes</p>
|
||||
{loading ? (
|
||||
<p className="nuggets-episodes-note">Loading episodes…</p>
|
||||
) : !configured ? (
|
||||
@@ -2450,30 +2452,81 @@ function NuggetsPage({ content }: { content: SiteContent }) {
|
||||
<p className="nuggets-episodes-note">No episodes published yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="nuggets-latest-episode">
|
||||
<p className="nuggets-latest-episode-eyebrow">Latest Episode</p>
|
||||
{latest && (
|
||||
<div className="nuggets-latest-episode nug-latest">
|
||||
<p className="nuggets-latest-episode-eyebrow">Latest Episode{fmtDate(latest.pubDate) ? ` · ${fmtDate(latest.pubDate)}` : ''}{latest.duration ? ` · ${latest.duration}` : ''}</p>
|
||||
<h2 className="nuggets-latest-episode-title">{latest.title}</h2>
|
||||
{latest.description && <p className="nuggets-latest-episode-desc">{latest.description}</p>}
|
||||
{latest.audioUrl && <EpisodeAudioPlayer src={latest.audioUrl} title={latest.title} size="full" />}
|
||||
</div>
|
||||
{rest.length > 0 && (
|
||||
<ol className="finished-series-playlist nuggets-episode-list">
|
||||
{rest.map((ep, i) => (
|
||||
<li key={ep.audioUrl || i} className="finished-series-episode">
|
||||
<span className="finished-series-ep-num">{ep.episode || i + 2}</span>
|
||||
<div className="finished-series-ep-body">
|
||||
<p className="finished-series-ep-title">{ep.title}</p>
|
||||
{ep.description && <p className="nuggets-latest-episode-desc" style={{ fontSize: '0.88rem', margin: '3px 0 6px' }}>{ep.description}</p>}
|
||||
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
|
||||
{ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
|
||||
)}
|
||||
|
||||
<div className="nug-list-head">
|
||||
<h2 className="section-heading">{filtering ? 'Search results' : 'All Episodes'}</h2>
|
||||
{episodes.length > 4 && (
|
||||
<input
|
||||
type="search"
|
||||
className="nl-search"
|
||||
placeholder="Search episodes…"
|
||||
aria-label="Search episodes"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setShown(12) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{allTags.length > 0 && (
|
||||
<div className="nug-tags" role="group" aria-label="Filter by tag">
|
||||
<button type="button" className={`nug-tag${activeTag === '' ? ' nug-tag--active' : ''}`} onClick={() => { setActiveTag(''); setShown(12) }}>All</button>
|
||||
{allTags.map(t => (
|
||||
<button key={t} type="button" className={`nug-tag${activeTag === t ? ' nug-tag--active' : ''}`} aria-pressed={activeTag === t} onClick={() => { setActiveTag(activeTag === t ? '' : t); setShown(12) }}>{t}</button>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="nuggets-episodes-note">{filtering ? 'No episodes match your search.' : 'More episodes coming soon.'}</p>
|
||||
) : (
|
||||
<div className="nug-grid">
|
||||
{visible.map((ep, i) => (
|
||||
<article key={ep.audioUrl || i} className="nug-card">
|
||||
<p className="nug-card-meta">
|
||||
{[ep.episode ? `Ep. ${ep.episode}` : '', fmtDate(ep.pubDate), ep.duration].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
<h3 className="nug-card-title">{ep.title}</h3>
|
||||
{ep.description && <p className="nug-card-desc">{ep.description}</p>}
|
||||
{(ep.tags ?? []).length > 0 && (
|
||||
<div className="nug-card-tags">
|
||||
{(ep.tags ?? []).map(t => <button key={t} type="button" className="nug-tag nug-tag--small" onClick={() => { setActiveTag(t); setShown(12) }}>{t}</button>)}
|
||||
</div>
|
||||
)}
|
||||
{ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{filtered.length > shown && (
|
||||
<div className="nug-more">
|
||||
<button type="button" className="btn-secondary" onClick={() => setShown(n => n + 12)}>Show more episodes</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeHosts.length > 0 && (
|
||||
<div className="nug-hosts">
|
||||
<h2 className="section-heading">Meet the Hosts</h2>
|
||||
<div className="nug-hosts-grid">
|
||||
{activeHosts.map(host => (
|
||||
<Link key={host.id} to={`/nuggets/hosts/${host.id}`} className="nug-host-card">
|
||||
{host.imageUrl && <img src={host.imageUrl} alt={host.name} className="nug-host-img" loading="lazy" />}
|
||||
<div>
|
||||
<span className="nug-host-name">{host.name}</span>
|
||||
{host.role && <span className="nug-host-role">{host.role}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3516,6 +3569,7 @@ function NewsletterArchivePage({ content }: { content: SiteContent }) {
|
||||
)
|
||||
const [issues, setIssues] = useState<NewsletterIssueSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/newsletter/issues')
|
||||
@@ -3525,39 +3579,96 @@ function NewsletterArchivePage({ content }: { content: SiteContent }) {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const issueDate = (i: NewsletterIssueSummary) => new Date(i.sentAt ?? i.publishedAt)
|
||||
const fmtDate = (i: NewsletterIssueSummary) =>
|
||||
issueDate(i).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
const searching = q.length > 0
|
||||
const matches = searching
|
||||
? issues.filter(i => `${i.title} ${i.subject} ${i.description}`.toLowerCase().includes(q))
|
||||
: issues
|
||||
const featured = !searching ? issues[0] : undefined
|
||||
const rest = featured ? matches.slice(1) : matches
|
||||
|
||||
const byYear: { year: number; items: NewsletterIssueSummary[] }[] = []
|
||||
for (const i of rest) {
|
||||
const year = issueDate(i).getFullYear()
|
||||
const group = byYear.find(g => g.year === year)
|
||||
if (group) group.items.push(i)
|
||||
else byYear.push({ year, items: [i] })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<PageBanner eyebrow="Newsletter" title="Newsletter Archive" desc="Past issues of the Verse by Verse with Nate newsletter — episode notes, study excerpts, and Greek word studies." />
|
||||
<section className="section-resources" aria-label="Newsletter archive">
|
||||
<div className="section-inner">
|
||||
{loading ? (
|
||||
<p className="episode-seo-intro">Loading issues…</p>
|
||||
) : issues.length === 0 ? (
|
||||
<p className="episode-seo-intro">No newsletter issues published yet.</p>
|
||||
<PageBanner eyebrow="Newsletter" title="The Newsletter" desc="Episode notes, study excerpts, and Greek word studies from Verse by Verse with Nate — in your inbox and in the archive." />
|
||||
|
||||
<section className="nl-top" aria-label="Latest issue and sign-up">
|
||||
<div className="section-inner nl-top-grid">
|
||||
{featured ? (
|
||||
<a href={`/newsletter/${featured.slug}`} className="nl-featured">
|
||||
<span className="nl-featured-tag">Latest issue</span>
|
||||
<h2 className="nl-featured-title">{featured.title}</h2>
|
||||
{featured.description && <p className="nl-featured-desc">{featured.description}</p>}
|
||||
<div className="nl-featured-foot">
|
||||
<span className="nl-date">{fmtDate(featured)}</span>
|
||||
<span className="nl-read">Read issue →</span>
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div className="resources-list">
|
||||
{issues.map(issue => (
|
||||
<a key={issue.id} href={`/newsletter/${issue.slug}`} className="resource-download-card">
|
||||
<div className="resource-download-header">
|
||||
<div className="resource-download-meta">
|
||||
<span className="resource-link-label">{issue.title}</span>
|
||||
{issue.description && <p className="resource-link-description">{issue.description}</p>}
|
||||
{issue.sentAt && (
|
||||
<span className="resource-link-tags">
|
||||
{new Date(issue.sentAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<div className="nl-featured nl-featured--empty">
|
||||
<span className="nl-featured-tag">Archive</span>
|
||||
<h2 className="nl-featured-title">{loading ? 'Loading issues…' : 'First issue coming soon'}</h2>
|
||||
{!loading && <p className="nl-featured-desc">Subscribe and you’ll get it the moment it’s sent.</p>}
|
||||
</div>
|
||||
)}
|
||||
<span className="resource-link-action">Read issue →</span>
|
||||
<HomepageNewsletterSection embedded />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="nl-archive" aria-label="Newsletter archive">
|
||||
<div className="section-inner">
|
||||
<div className="nl-archive-head">
|
||||
<h2 className="section-heading">Past Issues</h2>
|
||||
{issues.length > 1 && (
|
||||
<input
|
||||
type="search"
|
||||
className="nl-search"
|
||||
placeholder="Search issues…"
|
||||
aria-label="Search newsletter issues"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && searching && matches.length === 0 && (
|
||||
<p className="episode-seo-intro">No issues match “{query.trim()}”.</p>
|
||||
)}
|
||||
{!loading && !searching && issues.length <= 1 && (
|
||||
<p className="episode-seo-intro">{issues.length === 0 ? 'No newsletter issues published yet.' : 'More issues will appear here as they’re sent.'}</p>
|
||||
)}
|
||||
|
||||
{byYear.map(group => (
|
||||
<div key={group.year} className="nl-year">
|
||||
<h3 className="nl-year-label">{group.year}</h3>
|
||||
<div className="nl-grid">
|
||||
{group.items.map(issue => (
|
||||
<a key={issue.id} href={`/newsletter/${issue.slug}`} className="nl-card">
|
||||
<span className="nl-date">{fmtDate(issue)}</span>
|
||||
<span className="nl-card-title">{issue.title}</span>
|
||||
{issue.description && <p className="nl-card-desc">{issue.description}</p>}
|
||||
<span className="nl-read">Read issue →</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CustomBlocksSection content={content} page="newsletter" />
|
||||
<SiteFooter content={content} />
|
||||
<AnalyticsConsentBanner content={content} />
|
||||
|
||||
Reference in new issue
Block a user