Compare commits
2
Commits
ba35ccab19
...
ea463b5851
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea463b5851 | ||
|
|
5d5c026762 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.36.5",
|
"version": "2.38.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
loadStudySessionsFromDisk,
|
loadStudySessionsFromDisk,
|
||||||
queueStudySessionsWrite,
|
queueStudySessionsWrite,
|
||||||
loadNewsletterIssuesFromDisk,
|
loadNewsletterIssuesFromDisk,
|
||||||
|
loadNuggetsTagsFromDisk,
|
||||||
createBackupSnapshot,
|
createBackupSnapshot,
|
||||||
refreshContentCaches,
|
refreshContentCaches,
|
||||||
queueHitStatsWrite,
|
queueHitStatsWrite,
|
||||||
@@ -101,7 +102,7 @@ app.use((_req, res, next) => {
|
|||||||
"font-src 'self' https://fonts.gstatic.com",
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
"img-src 'self' data: https:",
|
"img-src 'self' data: https:",
|
||||||
"media-src 'self' https:",
|
"media-src 'self' https:",
|
||||||
"frame-src https:",
|
"frame-src 'self' https:",
|
||||||
"connect-src 'self' https:",
|
"connect-src 'self' https:",
|
||||||
].join('; '),
|
].join('; '),
|
||||||
)
|
)
|
||||||
@@ -194,6 +195,7 @@ Promise.all([
|
|||||||
loadWebhooksFromDisk(),
|
loadWebhooksFromDisk(),
|
||||||
loadStudySessionsFromDisk(),
|
loadStudySessionsFromDisk(),
|
||||||
loadNewsletterIssuesFromDisk(),
|
loadNewsletterIssuesFromDisk(),
|
||||||
|
loadNuggetsTagsFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
])
|
])
|
||||||
.catch(err => {
|
.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 WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
||||||
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.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 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
|
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||||
|
|
||||||
// Seafile integration for Nuggets episode uploads
|
// Seafile integration for Nuggets episode uploads
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
WEBHOOKS_FILE,
|
WEBHOOKS_FILE,
|
||||||
STUDY_SESSIONS_FILE,
|
STUDY_SESSIONS_FILE,
|
||||||
NEWSLETTER_ISSUES_FILE,
|
NEWSLETTER_ISSUES_FILE,
|
||||||
|
NUGGETS_TAGS_FILE,
|
||||||
EMPTY_HIT_STATS,
|
EMPTY_HIT_STATS,
|
||||||
EMPTY_VISITOR_STATS,
|
EMPTY_VISITOR_STATS,
|
||||||
DEFAULT_REPLY_TEMPLATES,
|
DEFAULT_REPLY_TEMPLATES,
|
||||||
@@ -1588,3 +1589,27 @@ export function loadNewsletterIssuesFromDisk() {
|
|||||||
state.newsletterIssues = []
|
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 = {}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -88,15 +88,22 @@ table[align="left"] { float: none !important; margin-left: auto !important; marg
|
|||||||
</style>
|
</style>
|
||||||
</head>`
|
</head>`
|
||||||
|
|
||||||
|
const topBar = `
|
||||||
|
<div style="background:#050505;border-bottom:1px solid rgba(184,151,42,0.25);padding:14px 20px;font-family:Georgia,serif">
|
||||||
|
<div style="max-width:640px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
|
<a href="${BASE}/" style="color:#e8d9b5;text-decoration:none;font-size:15px;letter-spacing:0.04em">Verse by Verse with Nate</a>
|
||||||
|
<a href="${BASE}/newsletter" style="color:#B8972A;text-decoration:none;font-size:12px;letter-spacing:0.14em;text-transform:uppercase">← All issues</a>
|
||||||
|
</div></div>`
|
||||||
|
|
||||||
const ctaBlock = `
|
const ctaBlock = `
|
||||||
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
|
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
|
||||||
<tbody><tr><td align="center" style="padding:32px 20px 48px;background-color:#080807">
|
<tbody><tr><td align="center" style="padding:40px 20px 56px;background-color:#080807;border-top:1px solid rgba(184,151,42,0.2)">
|
||||||
<table width="580" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px;width:100%">
|
<table width="580" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px;width:100%">
|
||||||
<tbody><tr><td align="center" style="padding:0">
|
<tbody><tr><td align="center" style="padding:0;font-family:Georgia,serif">
|
||||||
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:13px;color:#6b6044">
|
<p style="margin:0 0 8px;font-size:12px;letter-spacing:0.18em;text-transform:uppercase;color:#B8972A">Stay in the Word</p>
|
||||||
<a href="${BASE}/newsletter" style="color:#6b6044;text-decoration:none">← Newsletter Archive</a>
|
<p style="margin:0 0 22px;font-size:22px;line-height:1.3;color:#f0e9cc">Get the next issue in your inbox</p>
|
||||||
</p>
|
<a href="${BASE}/subscribe" style="display:inline-block;padding:13px 34px;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#0c0c08;background:#B8972A;text-decoration:none;border-radius:4px;font-weight:bold">Subscribe by Email</a>
|
||||||
<a href="${BASE}/subscribe" style="display:inline-block;padding:12px 32px;font-family:Georgia,serif;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#B8972A;border:1px solid #B8972A;text-decoration:none">Subscribe by Email</a>
|
<p style="margin:26px 0 0;font-size:13px"><a href="${BASE}/newsletter" style="color:#8a7e55;text-decoration:none">← Back to the newsletter archive</a></p>
|
||||||
</td></tr></tbody></table>
|
</td></tr></tbody></table>
|
||||||
</td></tr></tbody></table>
|
</td></tr></tbody></table>
|
||||||
</body>`
|
</body>`
|
||||||
@@ -104,6 +111,7 @@ table[align="left"] { float: none !important; margin-left: auto !important; marg
|
|||||||
let html = stripUnsubscribeLink(issue.html)
|
let html = stripUnsubscribeLink(issue.html)
|
||||||
html = html.replace(/<title>[\s\S]*?<\/title>/i, '')
|
html = html.replace(/<title>[\s\S]*?<\/title>/i, '')
|
||||||
html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, metaBlock) : html.replace(/<head[^>]*>/i, m => `${m}${metaBlock.replace('</head>', '')}`)
|
html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, metaBlock) : html.replace(/<head[^>]*>/i, m => `${m}${metaBlock.replace('</head>', '')}`)
|
||||||
|
html = html.replace(/<body[^>]*>/i, m => `${m}${topBar}`)
|
||||||
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, ctaBlock) : `${html}${ctaBlock}`
|
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, ctaBlock) : `${html}${ctaBlock}`
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { parseRssItems } from '../rss-helpers.js'
|
import { parseRssItems } from '../rss-helpers.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
import { queueNuggetsTagsWrite } from '../data.js'
|
||||||
|
|
||||||
let cache = null // { url, episodes }
|
let cache = null // { url, episodes }
|
||||||
let cacheAt = 0
|
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) {
|
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) => {
|
app.get('/api/nuggets/episodes', async (_req, res) => {
|
||||||
const configured = Boolean(feedUrl())
|
const configured = Boolean(feedUrl())
|
||||||
try {
|
try {
|
||||||
const episodes = await fetchNuggetsEpisodes()
|
const episodes = await fetchNuggetsEpisodes()
|
||||||
res.json({ episodes: episodes.slice(0, 6), configured })
|
res.json({ episodes: withTags(episodes.slice(0, 6)), configured })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[nuggets/episodes] RSS fetch error:', err.message)
|
console.error('[nuggets/episodes] RSS fetch error:', err.message)
|
||||||
res.json({ episodes: [], configured })
|
res.json({ episodes: [], configured })
|
||||||
@@ -53,7 +90,7 @@ export function register(app) {
|
|||||||
const configured = Boolean(feedUrl())
|
const configured = Boolean(feedUrl())
|
||||||
try {
|
try {
|
||||||
const episodes = await fetchNuggetsEpisodes()
|
const episodes = await fetchNuggetsEpisodes()
|
||||||
res.json({ episodes, configured })
|
res.json({ episodes: withTags(episodes), configured })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
|
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
|
||||||
res.json({ episodes: [], configured })
|
res.json({ episodes: [], configured })
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export const state = {
|
|||||||
// newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }>
|
// newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }>
|
||||||
newsletterIssues: [],
|
newsletterIssues: [],
|
||||||
newsletterIssuesWritePromise: Promise.resolve(),
|
newsletterIssuesWritePromise: Promise.resolve(),
|
||||||
|
// nuggetsEpisodeTags: { [audioUrl]: string[] } — admin-assigned tags for Nuggets RSS episodes
|
||||||
|
nuggetsEpisodeTags: {},
|
||||||
|
nuggetsEpisodeTagsWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
|
|
||||||
auditLog: [],
|
auditLog: [],
|
||||||
|
|||||||
+112
-1
@@ -740,7 +740,7 @@ type AdminView =
|
|||||||
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
||||||
| 'contact:profile' | 'contact:copy'
|
| 'contact:profile' | 'contact:copy'
|
||||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | 'podcast:season-check' | 'podcast:rss'
|
| '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'
|
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||||
| 'inbox'
|
| 'inbox'
|
||||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
| '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:announcement', label: 'Announcement' },
|
||||||
{ kind: 'leaf', view: 'nuggets:guidelines', label: 'Recording Guidelines' },
|
{ kind: 'leaf', view: 'nuggets:guidelines', label: 'Recording Guidelines' },
|
||||||
{ kind: 'leaf', view: 'nuggets:workflow', label: 'Episode Workflow' },
|
{ 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: 'nuggets:history', label: 'Upload History' },
|
||||||
]},
|
]},
|
||||||
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
|
{ 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 ─────────────────────────────────────────
|
// ── Nuggets Episode Workflow admin ─────────────────────────────────────────
|
||||||
type WorkflowStepKey = 'listen' | 'coldOpen' | 'recordColdOpen' | 'edit' | 'spotify'
|
type WorkflowStepKey = 'listen' | 'coldOpen' | 'recordColdOpen' | 'edit' | 'spotify'
|
||||||
|
|
||||||
@@ -6626,6 +6736,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
{/* NUGGETS UPLOAD HISTORY */}
|
{/* NUGGETS UPLOAD HISTORY */}
|
||||||
{adminView === 'nuggets:workflow' && <NuggetsWorkflowAdmin />}
|
{adminView === 'nuggets:workflow' && <NuggetsWorkflowAdmin />}
|
||||||
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
|
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
|
||||||
|
{adminView === 'nuggets:tags' && <NuggetsTagsAdmin />}
|
||||||
|
|
||||||
{/* STUDY COMMENTS */}
|
{/* STUDY COMMENTS */}
|
||||||
{adminView === 'study-comments' && (() => {
|
{adminView === 'study-comments' && (() => {
|
||||||
|
|||||||
+242
@@ -13908,3 +13908,245 @@ a.study-track-card:hover {
|
|||||||
.home-feature--image { background-size: cover; background-position: center; }
|
.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 h3 { text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); }
|
||||||
.home-feature--image p { color: #d9ceb0; }
|
.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; }
|
||||||
|
|
||||||
|
/* ── Episode detail ── */
|
||||||
|
.ed-hero { padding: 2.5rem 0 2rem; background: radial-gradient(ellipse at top left, rgba(201, 168, 76, 0.09), transparent 60%); border-bottom: 1px solid rgba(201, 168, 76, 0.15); }
|
||||||
|
.ed-hero-inner { }
|
||||||
|
.ed-title { font-family: var(--brand-font-heading); font-size: clamp(1.9rem, 4.5vw, 3rem); line-height: 1.12; color: #f0e9cc; margin: 0.2rem 0 0.8rem; }
|
||||||
|
.ed-summary { font-size: 1.1rem; line-height: 1.65; color: #a89a6a; margin: 0 0 1.5rem; }
|
||||||
|
.ed-player { margin-bottom: 1rem; }
|
||||||
|
.ed-listen { display: inline-block; }
|
||||||
|
.ed-body { padding: 2.5rem 0 4rem; }
|
||||||
|
.ed-body-grid { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(240px, 1fr); gap: 2.5rem; align-items: start; }
|
||||||
|
.ed-body-grid--single { grid-template-columns: minmax(0, 760px); justify-content: center; }
|
||||||
|
.ed-block { margin-bottom: 2.25rem; }
|
||||||
|
.ed-heading { font-family: var(--brand-font-heading); font-size: 1.5rem; color: #e8d9b5; margin: 0 0 0.8rem; }
|
||||||
|
.ed-text { color: #c9bf9a; line-height: 1.75; margin: 0; }
|
||||||
|
.ed-quote { margin: 0 0 1rem; padding: 0.4rem 0 0.4rem 1.1rem; border-left: 3px solid #c9a84c; color: #e8d9b5; font-style: italic; line-height: 1.6; }
|
||||||
|
.ed-questions { margin: 0; padding-left: 1.3rem; color: #c9bf9a; line-height: 1.65; display: grid; gap: 0.7rem; }
|
||||||
|
.ed-questions li::marker { color: #c9a84c; font-weight: 700; }
|
||||||
|
.ed-side { display: grid; gap: 1rem; position: sticky; top: 5rem; }
|
||||||
|
.ed-card { background: #111; border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 14px; padding: 1.1rem 1.25rem; }
|
||||||
|
.ed-side-heading { font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; color: #c9a84c; margin: 0 0 0.8rem; font-family: inherit; }
|
||||||
|
.ed-chips { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||||
|
.ed-chip { font-size: 0.85rem; color: #e8d9b5; border: 1px solid rgba(201, 168, 76, 0.35); border-radius: 999px; padding: 0.25rem 0.8rem; }
|
||||||
|
.ed-list { margin: 0; padding-left: 1.1rem; color: #c9bf9a; line-height: 1.55; display: grid; gap: 0.4rem; font-size: 0.92rem; }
|
||||||
|
@media (max-width: 800px) { .ed-body-grid { grid-template-columns: 1fr; } .ed-side { position: static; } }
|
||||||
|
|
||||||
|
/* ── Host detail ── */
|
||||||
|
.hd-hero { padding: 2.5rem 0 2rem; background: radial-gradient(ellipse at top left, rgba(201, 168, 76, 0.09), transparent 60%); border-bottom: 1px solid rgba(201, 168, 76, 0.15); }
|
||||||
|
.hd-hero-inner { display: flex; gap: 2rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
.hd-photo { width: 180px; height: 180px; object-fit: cover; border-radius: 50%; border: 3px solid rgba(201, 168, 76, 0.4); box-shadow: 0 10px 36px rgba(0, 0, 0, 0.45); flex-shrink: 0; }
|
||||||
|
.hd-hero-body { flex: 1; min-width: 240px; }
|
||||||
|
.hd-name { font-family: var(--brand-font-heading); font-size: clamp(2rem, 5vw, 3rem); color: #f0e9cc; margin: 0.2rem 0 0; line-height: 1.1; }
|
||||||
|
.hd-body { padding: 2.5rem 0 4rem; }
|
||||||
|
.hd-body-inner { }
|
||||||
|
.hd-bio { font-size: 1.08rem; line-height: 1.75; color: #c9bf9a; margin: 0 0 1.25rem; }
|
||||||
|
.hd-listen { margin-top: 2rem; padding: 1.4rem 1.5rem; background: #111; border: 1px solid rgba(201, 168, 76, 0.2); border-radius: 14px; }
|
||||||
|
.hd-listen .eyebrow { margin-bottom: 0.8rem; }
|
||||||
|
.hd-others { margin-top: 2.5rem; }
|
||||||
|
.hd-back { display: inline-block; margin-top: 2.5rem; }
|
||||||
|
@media (max-width: 640px) { .hd-photo { width: 120px; height: 120px; } }
|
||||||
|
|
||||||
|
/* ── Finished series page ── */
|
||||||
|
.fsp-hero { padding: 2.5rem 0 2.25rem; background: radial-gradient(ellipse at top left, rgba(201, 168, 76, 0.09), transparent 60%); border-bottom: 1px solid rgba(201, 168, 76, 0.15); }
|
||||||
|
.fsp-hero-inner { display: flex; gap: 2.5rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
.fsp-hero-body { flex: 1; min-width: 260px; }
|
||||||
|
.fsp-title { font-family: var(--brand-font-heading); font-size: clamp(2rem, 5vw, 3.2rem); line-height: 1.1; color: #f0e9cc; margin: 0.2rem 0 0.8rem; }
|
||||||
|
.fsp-desc { color: #a89a6a; line-height: 1.65; margin: 0 0 1.4rem; max-width: 60ch; }
|
||||||
|
.fsp-actions { display: flex; flex-wrap: wrap; gap: 0.7rem; }
|
||||||
|
.fsp-episodes { padding-top: 2rem; padding-bottom: 4rem; }
|
||||||
|
|
||||||
|
/* ── Centered utility pages (thanks, subscribe, 404, legal) ── */
|
||||||
|
.cp-main { padding: 3.5rem 1.25rem 4.5rem; background: radial-gradient(ellipse at top, rgba(201, 168, 76, 0.1), transparent 55%); display: flex; justify-content: center; }
|
||||||
|
.cp-card { width: min(640px, 100%); background: #121212; border: 1px solid rgba(201, 168, 76, 0.24); border-radius: 18px; box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5); padding: 2.5rem 2rem; text-align: center; }
|
||||||
|
.cp-icon { display: inline-grid; place-items: center; width: 56px; height: 56px; border-radius: 50%; background: rgba(201, 168, 76, 0.14); border: 1px solid rgba(201, 168, 76, 0.5); color: #c9a84c; font-size: 1.6rem; margin-bottom: 1rem; }
|
||||||
|
.cp-404 { font-family: var(--brand-font-heading); font-size: clamp(4rem, 14vw, 6.5rem); line-height: 1; margin: 0 0 0.25rem; color: #c9a84c; opacity: 0.85; }
|
||||||
|
.cp-title { font-family: var(--brand-font-heading); font-size: clamp(1.9rem, 5vw, 2.7rem); color: #f0e9cc; margin: 0.2rem 0 0.8rem; line-height: 1.15; }
|
||||||
|
.cp-text { color: #a89a6a; line-height: 1.7; margin: 0 auto 1.5rem; max-width: 46ch; }
|
||||||
|
.cp-text code { color: #e8d9b5; background: rgba(201, 168, 76, 0.1); padding: 0.1rem 0.4rem; border-radius: 4px; word-break: break-all; }
|
||||||
|
.cp-actions { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; margin: 1.5rem 0 0; }
|
||||||
|
.cp-note { font-size: 0.85rem; color: #8a7e55; margin: 1.25rem 0 0; }
|
||||||
|
.cp-note a { color: #c9a84c; }
|
||||||
|
.cp-form { display: grid; gap: 1rem; text-align: left; margin-top: 0.5rem; }
|
||||||
|
.cp-form label { display: grid; gap: 0.35rem; font-size: 0.85rem; color: #c9bf9a; }
|
||||||
|
.cp-form input { padding: 0.7rem 1rem; border-radius: 8px; border: 1px solid rgba(201, 168, 76, 0.3); background: #1a1a12; color: #f0e9cc; font-size: 1rem; outline: none; }
|
||||||
|
.cp-form input:focus { border-color: rgba(201, 168, 76, 0.7); }
|
||||||
|
.cp-form .btn-primary { justify-content: center; }
|
||||||
|
.cp-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem; }
|
||||||
|
.cp-links { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.8rem; margin-top: 1.25rem; text-align: left; }
|
||||||
|
.cp-link-card { display: grid; gap: 0.2rem; padding: 0.9rem 1rem; background: #17170f; border: 1px solid rgba(201, 168, 76, 0.2); border-radius: 12px; text-decoration: none; transition: transform 160ms ease, border-color 160ms ease; }
|
||||||
|
.cp-link-card:hover { transform: translateY(-2px); border-color: rgba(201, 168, 76, 0.5); }
|
||||||
|
.cp-link-card strong { color: #e8d9b5; }
|
||||||
|
.cp-link-card span { color: #8a7e55; font-size: 0.82rem; }
|
||||||
|
.cp-legal { max-width: 760px; padding-top: 2.5rem; padding-bottom: 4rem; }
|
||||||
|
.cp-legal p { color: #c9bf9a; line-height: 1.8; margin: 0 0 1.1rem; }
|
||||||
|
@media (max-width: 520px) { .cp-card { padding: 1.75rem 1.25rem; } .cp-form-row { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* ── Study discussion hub ── */
|
||||||
|
.sdh-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
|
||||||
|
.sdh-card { display: flex; flex-direction: column; gap: 0.6rem; padding: 1.4rem; background: #111; border: 1px solid rgba(201, 168, 76, 0.2); border-radius: 14px; text-decoration: none; transition: transform 160ms ease, border-color 160ms ease; }
|
||||||
|
.sdh-card:hover { transform: translateY(-2px); border-color: rgba(201, 168, 76, 0.5); }
|
||||||
|
.sdh-card h3 { margin: 0; font-family: var(--brand-font-heading); font-size: 1.35rem; color: #f0e9cc; }
|
||||||
|
.sdh-card p { margin: 0; color: #a89a6a; line-height: 1.55; font-size: 0.95rem; }
|
||||||
|
.sdh-badge { align-self: flex-start; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: #8a7e55; border: 1px solid rgba(201, 168, 76, 0.25); border-radius: 999px; padding: 0.2rem 0.65rem; }
|
||||||
|
.sdh-badge--on { color: #c9a84c; border-color: rgba(201, 168, 76, 0.6); }
|
||||||
|
.sdh-go { margin-top: auto; padding-top: 0.5rem; color: #c9a84c; font-weight: 600; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* ── Study overview ── */
|
||||||
|
.si-hero { padding: 2.5rem 0 2.25rem; background: radial-gradient(ellipse at top left, rgba(201, 168, 76, 0.1), transparent 60%); border-bottom: 1px solid rgba(201, 168, 76, 0.15); }
|
||||||
|
.si-hero-inner { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2.5rem; align-items: center; }
|
||||||
|
.si-title { font-family: var(--brand-font-heading); font-size: clamp(2rem, 5vw, 3.2rem); line-height: 1.1; color: #f0e9cc; margin: 0.2rem 0 0.8rem; }
|
||||||
|
.si-desc { color: #a89a6a; line-height: 1.65; margin: 0 0 1.1rem; max-width: 62ch; }
|
||||||
|
.si-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.4rem; }
|
||||||
|
.si-chips span { font-size: 0.8rem; color: #c9bf9a; border: 1px solid rgba(201, 168, 76, 0.25); border-radius: 999px; padding: 0.2rem 0.75rem; }
|
||||||
|
.si-actions { display: flex; flex-wrap: wrap; gap: 0.7rem; }
|
||||||
|
.si-progress { display: flex; align-items: center; gap: 1.1rem; padding: 1.2rem 1.5rem; background: #111; border: 1px solid rgba(201, 168, 76, 0.25); border-radius: 16px; }
|
||||||
|
.si-ring { --pct: 0%; width: 88px; height: 88px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(#c9a84c var(--pct), rgba(201, 168, 76, 0.15) 0); position: relative; }
|
||||||
|
.si-ring::before { content: ''; position: absolute; inset: 8px; border-radius: 50%; background: #111; }
|
||||||
|
.si-ring span { position: relative; font-weight: 700; color: #e8d9b5; }
|
||||||
|
.si-progress-big { margin: 0; font-family: var(--brand-font-heading); font-size: 1.6rem; color: #f0e9cc; }
|
||||||
|
.si-progress-sub { margin: 0; color: #a89a6a; font-size: 0.9rem; }
|
||||||
|
.si-notice { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: center; margin-top: 1.5rem; padding: 1rem 1.25rem; background: #17170f; border: 1px solid rgba(201, 168, 76, 0.3); border-radius: 12px; color: #a89a6a; }
|
||||||
|
.si-notice strong { color: #e8d9b5; }
|
||||||
|
.si-notice .btn-primary { margin-left: auto; }
|
||||||
|
.si-lessons { padding: 2rem 0 4rem; }
|
||||||
|
.si-chapter { margin-bottom: 1.25rem; }
|
||||||
|
.si-chapter-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; cursor: pointer; padding: 0.6rem 0; border-bottom: 1px solid rgba(201, 168, 76, 0.18); list-style: none; }
|
||||||
|
.si-chapter-head::-webkit-details-marker { display: none; }
|
||||||
|
.si-chapter-head h2 { margin: 0; font-family: var(--brand-font-heading); font-size: 1.4rem; color: #e8d9b5; }
|
||||||
|
.si-chapter-head span { font-size: 0.85rem; color: #8a7e55; }
|
||||||
|
.si-list { display: grid; gap: 0.7rem; margin-top: 1rem; }
|
||||||
|
.si-row { display: flex; gap: 1rem; align-items: center; 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; }
|
||||||
|
.si-row:hover { transform: translateY(-1px); border-color: rgba(201, 168, 76, 0.5); }
|
||||||
|
.si-row--disabled { opacity: 0.55; cursor: default; }
|
||||||
|
.si-row--disabled:hover { transform: none; border-color: rgba(201, 168, 76, 0.18); }
|
||||||
|
.si-row--done { border-color: rgba(127, 212, 138, 0.35); }
|
||||||
|
.si-num { flex: 0 0 auto; width: 40px; height: 40px; display: grid; place-items: center; border-radius: 50%; border: 1px solid rgba(201, 168, 76, 0.45); color: #c9a84c; font-weight: 700; }
|
||||||
|
.si-row--done .si-num { background: rgba(127, 212, 138, 0.15); border-color: #7fd48a; color: #7fd48a; }
|
||||||
|
.si-row-body { flex: 1; min-width: 0; }
|
||||||
|
.si-row-top { display: flex; flex-wrap: wrap; gap: 0.4rem 0.7rem; align-items: center; }
|
||||||
|
.si-row-top h3 { margin: 0; font-size: 1.1rem; color: #f0e9cc; }
|
||||||
|
.si-ref { margin: 0.15rem 0 0; font-size: 0.8rem; letter-spacing: 0.06em; color: #c9a84c; }
|
||||||
|
.si-summary { margin: 0.35rem 0 0; color: #a89a6a; font-size: 0.92rem; line-height: 1.5; }
|
||||||
|
.si-arrow { color: #c9a84c; font-size: 1.2rem; }
|
||||||
|
@media (max-width: 800px) { .si-hero-inner { grid-template-columns: 1fr; } .si-progress { justify-self: start; } }
|
||||||
|
.si-focus { margin: 0.4rem 0 0; font-size: 0.85rem; font-style: italic; color: #8a7e55; line-height: 1.45; }
|
||||||
|
.si-focus span { font-style: normal; color: #c9a84c; }
|
||||||
|
|
||||||
|
/* ── Study lesson page ── */
|
||||||
|
.section-study-classroom { padding-top: 2rem; }
|
||||||
|
.study-classroom-shell { grid-template-columns: minmax(0, 1fr) 320px; gap: 2.5rem; }
|
||||||
|
.sl-head { padding-bottom: 1.5rem; margin-bottom: 0.5rem; border-bottom: 1px solid rgba(201, 168, 76, 0.18); }
|
||||||
|
.sl-head-meta { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: center; margin: 0.4rem 0 0.6rem; }
|
||||||
|
.sl-lesson-pill { font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; color: #0c0c08; background: #c9a84c; border-radius: 999px; padding: 0.2rem 0.75rem; font-weight: 700; }
|
||||||
|
.sl-ref { font-size: 0.85rem; letter-spacing: 0.08em; color: #c9a84c; }
|
||||||
|
.sl-done { font-size: 0.8rem; color: #7fd48a; border: 1px solid rgba(127, 212, 138, 0.5); border-radius: 999px; padding: 0.15rem 0.65rem; }
|
||||||
|
.sl-title { font-family: var(--brand-font-heading); font-size: clamp(2rem, 4.5vw, 2.9rem); line-height: 1.12; color: #f0e9cc; margin: 0 0 0.7rem; }
|
||||||
|
.sl-summary { font-size: 1.1rem; line-height: 1.65; color: #a89a6a; max-width: 62ch; margin: 0 0 1.1rem; }
|
||||||
|
.sl-progress { display: grid; gap: 0.4rem; max-width: 420px; font-size: 0.8rem; color: #8a7e55; }
|
||||||
|
.sl-progress .study-lesson-progress-bar { height: 5px; margin: 0; }
|
||||||
|
.study-classroom-main .study-class-block { margin-top: 1.5rem; padding: 1.6rem 1.75rem; border-radius: 16px; background: #111; border-color: rgba(201, 168, 76, 0.2); }
|
||||||
|
.study-classroom-main .study-class-block h2 { font-size: 1.5rem; color: #e8d9b5; padding-right: 5rem; }
|
||||||
|
.study-classroom-main .study-detail-copy { font-size: 1.08rem; line-height: 1.85; color: #cfc6a4; margin-bottom: 1.1rem; max-width: 68ch; }
|
||||||
|
.study-classroom-main .study-detail-copy:last-child { margin-bottom: 0; }
|
||||||
|
.study-classroom-main .study-detail-copy--scripture { font-family: var(--brand-font-heading); font-size: 1.2rem; line-height: 1.9; color: #f0e9cc; padding-left: 1.1rem; border-left: 3px solid rgba(201, 168, 76, 0.55); }
|
||||||
|
.study-classroom-main .study-detail-list--questions { padding-left: 0; list-style: none; counter-reset: q; display: grid; gap: 1.25rem; }
|
||||||
|
.study-classroom-main .study-question-item { counter-increment: q; position: relative; padding-left: 2.6rem; }
|
||||||
|
.study-classroom-main .study-question-item::before { content: counter(q); position: absolute; left: 0; top: 0; width: 1.9rem; height: 1.9rem; display: grid; place-items: center; border-radius: 50%; border: 1px solid rgba(201, 168, 76, 0.5); color: #c9a84c; font-weight: 700; font-size: 0.85rem; }
|
||||||
|
.study-classroom-main .study-question-text { font-size: 1.02rem; color: #e8d9b5; }
|
||||||
|
.study-classroom-sidebar { top: 5rem; }
|
||||||
|
.study-class-side-block { border-radius: 14px; background: #111; border-color: rgba(201, 168, 76, 0.2); }
|
||||||
|
.study-class-side-block h3 { font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; color: #c9a84c; font-family: inherit; }
|
||||||
|
.sl-pager { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 2rem; }
|
||||||
|
.sl-pager-link { display: grid; gap: 0.25rem; padding: 1rem 1.25rem; background: #111; border: 1px solid rgba(201, 168, 76, 0.22); border-radius: 14px; text-decoration: none; transition: border-color 160ms ease, transform 160ms ease; }
|
||||||
|
a.sl-pager-link:hover { border-color: rgba(201, 168, 76, 0.6); transform: translateY(-1px); }
|
||||||
|
.sl-pager-link span { font-size: 0.78rem; letter-spacing: 0.08em; text-transform: uppercase; color: #8a7e55; }
|
||||||
|
.sl-pager-link strong { color: #e8d9b5; font-weight: 600; }
|
||||||
|
.sl-pager-link--next { text-align: right; }
|
||||||
|
.sl-pager-link--wait { opacity: 0.7; }
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.study-classroom-shell { grid-template-columns: 1fr; }
|
||||||
|
.study-classroom-sidebar { position: static; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.study-classroom-main .study-class-block { padding: 1.2rem 1.1rem; }
|
||||||
|
.sl-pager { grid-template-columns: 1fr; }
|
||||||
|
.sl-pager-link--next { text-align: left; }
|
||||||
|
}
|
||||||
|
#root .ed-heading { font-size: 1.5rem; }
|
||||||
|
.ed-hero-inner > * { max-width: 760px; }
|
||||||
|
#root .ed-side .ed-side-heading { font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; color: #c9a84c; margin: 0 0 0.8rem; font-family: inherit; font-weight: 600; line-height: 1.2; }
|
||||||
|
.auth-card--center .study-signup-form { text-align: left; }
|
||||||
|
.cp-main--bare { min-height: 100vh; align-items: center; }
|
||||||
+411
-298
@@ -891,14 +891,13 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
|
|||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Download not found">
|
<CenteredPageFrame content={content} label="Download not found">
|
||||||
<div className="thanks-card">
|
<p className="cp-404" aria-hidden="true">?</p>
|
||||||
<p className="eyebrow">Resources</p>
|
<p className="eyebrow">Resources</p>
|
||||||
<h1>Download not found</h1>
|
<h1 className="cp-title">Download not found</h1>
|
||||||
<p>The download you requested is not available right now.</p>
|
<p className="cp-text">The download you requested is not available right now.</p>
|
||||||
<Link to="/resources" className="btn-primary">Back to Resources</Link>
|
<div className="cp-actions"><Link to="/resources" className="btn-primary">Back to Resources</Link></div>
|
||||||
</div>
|
</CenteredPageFrame>
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1606,23 +1605,31 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const questions = episode.discussionQuestions ?? []
|
const questions = episode.discussionQuestions ?? []
|
||||||
|
const themes = episode.showNotesThemes ?? []
|
||||||
|
const scripture = episode.showNotesScripture ?? []
|
||||||
|
const quotes = episode.showNotesQuotes ?? []
|
||||||
|
const resources = episode.showNotesResources ?? []
|
||||||
|
const notesText = episode.showNotesSummary || (!themes.length ? episode.showNotes : '')
|
||||||
|
const hasMain = Boolean(notesText) || quotes.length > 0 || questions.length > 0
|
||||||
|
const hasSide = themes.length > 0 || scripture.length > 0 || resources.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar content={content} />
|
<SiteSearchBar content={content} />
|
||||||
<section className="section-episode-detail" aria-label={episode.title}>
|
<section className="ed-hero" aria-label={episode.title}>
|
||||||
<div className="section-inner">
|
<div className="section-inner ed-hero-inner">
|
||||||
<Link to="/episodes" className="episode-detail-back">← Back to Episodes</Link>
|
<nav className="breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<Link to="/episodes">Episodes</Link>
|
||||||
{episode.episodeNumber && (
|
<span aria-hidden="true"> › </span>
|
||||||
<p className="episode-detail-number">Episode {episode.episodeNumber}</p>
|
<span>{episode.episodeNumber ? `Episode ${episode.episodeNumber}` : episode.title}</span>
|
||||||
)}
|
</nav>
|
||||||
<h1 className="episode-detail-title">{episode.title}</h1>
|
{episode.episodeNumber && <p className="eyebrow">Episode {episode.episodeNumber}</p>}
|
||||||
{episode.summary && <p className="episode-detail-summary">{episode.summary}</p>}
|
<h1 className="ed-title">{episode.title}</h1>
|
||||||
|
{episode.summary && <p className="ed-summary">{episode.summary}</p>}
|
||||||
|
|
||||||
{(episode.embedUrl || episode.url) && (
|
{(episode.embedUrl || episode.url) && (
|
||||||
<div className="episode-detail-embed">
|
<div className="ed-player">
|
||||||
{resolvedEmbedUrl ? (
|
{resolvedEmbedUrl ? (
|
||||||
<EpisodeAudioPlayer src={resolvedEmbedUrl} title={episode.title} size="full" spotifyUrl={episode.url || content.platformSpotifyUrl} />
|
<EpisodeAudioPlayer src={resolvedEmbedUrl} title={episode.title} size="full" spotifyUrl={episode.url || content.platformSpotifyUrl} />
|
||||||
) : (
|
) : (
|
||||||
@@ -1630,67 +1637,66 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{episode.url && (
|
{episode.url && (
|
||||||
<a href={episode.url} target="_blank" rel="noreferrer" className="btn-primary episode-detail-listen-btn">
|
<a href={episode.url} target="_blank" rel="noreferrer" className="btn-secondary ed-listen">Listen on your podcast app →</a>
|
||||||
Listen on Podcast Platform →
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{(episode.showNotesSummary || (episode.showNotesThemes ?? []).length > 0 || (episode.showNotesScripture ?? []).length > 0 || (episode.showNotesQuotes ?? []).length > 0 || (episode.showNotesResources ?? []).length > 0 || episode.showNotes) && (
|
{(hasMain || hasSide) && (
|
||||||
<div className="episode-detail-show-notes">
|
<section className="ed-body" aria-label="Show notes">
|
||||||
<h2 className="episode-detail-section-heading">Show Notes</h2>
|
<div className={`section-inner ed-body-grid${hasMain && hasSide ? '' : ' ed-body-grid--single'}`}>
|
||||||
{episode.showNotesSummary
|
{hasMain && (
|
||||||
? <p>{episode.showNotesSummary}</p>
|
<div className="ed-main">
|
||||||
: episode.showNotes && !(episode.showNotesThemes ?? []).length && <p>{episode.showNotes}</p>
|
{notesText && (
|
||||||
}
|
<div className="ed-block">
|
||||||
{(episode.showNotesThemes ?? []).length > 0 && (
|
<h2 className="ed-heading">Show Notes</h2>
|
||||||
<div className="episode-detail-show-notes-section">
|
<p className="ed-text">{notesText}</p>
|
||||||
<h3 className="episode-detail-show-notes-heading">Key Themes</h3>
|
|
||||||
<ul className="episode-detail-show-notes-list">
|
|
||||||
{episode.showNotesThemes!.map((t, i) => <li key={i}>{t}</li>)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(episode.showNotesScripture ?? []).length > 0 && (
|
{quotes.length > 0 && (
|
||||||
<div className="episode-detail-show-notes-section">
|
<div className="ed-block">
|
||||||
<h3 className="episode-detail-show-notes-heading">Scripture References</h3>
|
<h2 className="ed-heading">Key Quotes</h2>
|
||||||
<ul className="episode-detail-show-notes-list">
|
{quotes.map((q, i) => <blockquote key={i} className="ed-quote">{q}</blockquote>)}
|
||||||
{episode.showNotesScripture!.map((s, i) => <li key={i}>{s}</li>)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(episode.showNotesQuotes ?? []).length > 0 && (
|
|
||||||
<div className="episode-detail-show-notes-section">
|
|
||||||
<h3 className="episode-detail-show-notes-heading">Key Quotes</h3>
|
|
||||||
{episode.showNotesQuotes!.map((q, i) => (
|
|
||||||
<blockquote key={i} className="episode-detail-show-notes-quote">{q}</blockquote>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(episode.showNotesResources ?? []).length > 0 && (
|
|
||||||
<div className="episode-detail-show-notes-section">
|
|
||||||
<h3 className="episode-detail-show-notes-heading">Resources</h3>
|
|
||||||
<ul className="episode-detail-show-notes-list">
|
|
||||||
{episode.showNotesResources!.map((r, i) => <li key={i}>{r}</li>)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{questions.length > 0 && (
|
{questions.length > 0 && (
|
||||||
<div className="episode-detail-questions">
|
<div className="ed-block">
|
||||||
<h2 className="episode-detail-section-heading">Discussion Questions</h2>
|
<h2 className="ed-heading">Discussion Questions</h2>
|
||||||
<ol className="episode-detail-questions-list">
|
<ol className="ed-questions">
|
||||||
{questions.map((q, i) => (
|
{questions.map((q, i) => <li key={i}>{q}</li>)}
|
||||||
<li key={i}>{q}</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{hasSide && (
|
||||||
|
<aside className="ed-side">
|
||||||
|
{scripture.length > 0 && (
|
||||||
|
<div className="ed-card">
|
||||||
|
<h2 className="ed-side-heading">Scripture</h2>
|
||||||
|
<div className="ed-chips">
|
||||||
|
{scripture.map((r, i) => <span key={i} className="ed-chip">{r}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{themes.length > 0 && (
|
||||||
|
<div className="ed-card">
|
||||||
|
<h2 className="ed-side-heading">Key Themes</h2>
|
||||||
|
<ul className="ed-list">{themes.map((t, i) => <li key={i}>{t}</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resources.length > 0 && (
|
||||||
|
<div className="ed-card">
|
||||||
|
<h2 className="ed-side-heading">Resources</h2>
|
||||||
|
<ul className="ed-list">{resources.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
<SiteFooter content={content} />
|
<SiteFooter content={content} />
|
||||||
<AnalyticsConsentBanner content={content} />
|
<AnalyticsConsentBanner content={content} />
|
||||||
</div>
|
</div>
|
||||||
@@ -2045,13 +2051,8 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
|||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar content={content} />
|
<SiteSearchBar content={content} />
|
||||||
<main className="page-inner">
|
<section className="fsp-hero" aria-label={book.title}>
|
||||||
<nav className="breadcrumb" aria-label="Breadcrumb">
|
<div className="section-inner fsp-hero-inner">
|
||||||
<Link to="/finished">Finished Books</Link>
|
|
||||||
<span aria-hidden="true"> › </span>
|
|
||||||
<span>{book.title}</span>
|
|
||||||
</nav>
|
|
||||||
<div className="finished-series-hero">
|
|
||||||
{book.imageUrl && (
|
{book.imageUrl && (
|
||||||
<div className="finished-series-cd" aria-hidden="true">
|
<div className="finished-series-cd" aria-hidden="true">
|
||||||
<div className="finished-series-cd-disc">
|
<div className="finished-series-cd-disc">
|
||||||
@@ -2061,32 +2062,20 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div className="fsp-hero-body">
|
||||||
<p className="finished-book-card-season">Season {book.season}</p>
|
<nav className="breadcrumb" aria-label="Breadcrumb">
|
||||||
<h1 className="finished-series-title">{book.title}</h1>
|
<Link to="/finished">Finished Books</Link>
|
||||||
{book.description && <p className="finished-series-desc">{book.description}</p>}
|
<span aria-hidden="true"> › </span>
|
||||||
<div className="finished-series-actions">
|
<span>{book.title}</span>
|
||||||
<a href="#episodes" className="finished-series-btn-stream">▶ Stream Episodes</a>
|
</nav>
|
||||||
{book.downloadUrl && (
|
<p className="eyebrow">Season {book.season}{!loading && episodes.length > 0 ? ` · ${episodes.length} episodes` : ''}</p>
|
||||||
<a
|
<h1 className="fsp-title">{book.title}</h1>
|
||||||
href={book.downloadUrl}
|
{book.description && <p className="fsp-desc">{book.description}</p>}
|
||||||
target="_blank"
|
<div className="fsp-actions">
|
||||||
rel="noopener noreferrer"
|
<a href="#episodes" className="btn-primary">▶ Stream episodes</a>
|
||||||
className="finished-series-btn-download"
|
{book.studySlug && <Link to={`/study/${book.studySlug}`} className="btn-secondary">Study this book</Link>}
|
||||||
>
|
{book.downloadUrl && <a href={book.downloadUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">Download album</a>}
|
||||||
⬇ Download Album
|
{book.guide?.title && <Link to={`/downloads/book-guide--${book.id}`} className="btn-secondary">{book.guide.title}</Link>}
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{book.studySlug && (
|
|
||||||
<Link to={`/study/${book.studySlug}`} className="finished-series-btn-download">
|
|
||||||
📖 Study This Book
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{book.guide?.title && (
|
|
||||||
<Link to={`/downloads/book-guide--${book.id}`} className="finished-series-btn-download">
|
|
||||||
📄 {book.guide.title}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{book.downloadQrUrl && book.downloadUrl && (
|
{book.downloadQrUrl && book.downloadUrl && (
|
||||||
@@ -2098,9 +2087,12 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
<main className="section-inner fsp-episodes">
|
||||||
<FinishedSeriesEpisodes highlights={book.highlights ?? []} episodes={episodes} loading={loading} />
|
<FinishedSeriesEpisodes highlights={book.highlights ?? []} episodes={episodes} loading={loading} />
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter content={content} />
|
<SiteFooter content={content} />
|
||||||
|
<AnalyticsConsentBanner content={content} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2373,6 +2365,7 @@ interface NuggetsEpisode {
|
|||||||
duration: string
|
duration: string
|
||||||
episode: string
|
episode: string
|
||||||
season: string
|
season: string
|
||||||
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function NuggetsPage({ content }: { content: SiteContent }) {
|
function NuggetsPage({ content }: { content: SiteContent }) {
|
||||||
@@ -2398,50 +2391,51 @@ function NuggetsPage({ content }: { content: SiteContent }) {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [shown, setShown] = useState(12)
|
||||||
|
const [activeTag, setActiveTag] = useState('')
|
||||||
|
|
||||||
const [latest, ...rest] = episodes
|
const [latest, ...rest] = episodes
|
||||||
const activeHosts = (nuggets.hosts ?? []).filter(h => !h.archived)
|
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 (
|
return (
|
||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar 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="nug-hero" aria-label={`About ${nuggets.title}`}>
|
||||||
<section className="section-nuggets-intro" aria-label={`About ${nuggets.title}`}>
|
<div className="section-inner nug-hero-inner">
|
||||||
<div className="section-inner nuggets-intro-layout">
|
{nuggets.podcastArtUrl && <img src={nuggets.podcastArtUrl} alt={nuggets.title} className="nug-hero-art" />}
|
||||||
{nuggets.podcastArtUrl && <img src={nuggets.podcastArtUrl} alt={nuggets.title} className="nuggets-podcast-art" />}
|
<div className="nug-hero-body">
|
||||||
<div className="nuggets-intro-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.description && <p className="nuggets-description">{nuggets.description}</p>}
|
||||||
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
|
{listenLinks}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="section-nuggets-body" aria-label={`${nuggets.title} hosts and episodes`}>
|
<section className="section-nuggets-body" aria-label={`${nuggets.title} hosts and episodes`}>
|
||||||
<div className="section-inner">
|
<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 ? (
|
{loading ? (
|
||||||
<p className="nuggets-episodes-note">Loading episodes…</p>
|
<p className="nuggets-episodes-note">Loading episodes…</p>
|
||||||
) : !configured ? (
|
) : !configured ? (
|
||||||
@@ -2450,30 +2444,81 @@ function NuggetsPage({ content }: { content: SiteContent }) {
|
|||||||
<p className="nuggets-episodes-note">No episodes published yet.</p>
|
<p className="nuggets-episodes-note">No episodes published yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="nuggets-latest-episode">
|
{latest && (
|
||||||
<p className="nuggets-latest-episode-eyebrow">Latest Episode</p>
|
<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>
|
<h2 className="nuggets-latest-episode-title">{latest.title}</h2>
|
||||||
{latest.description && <p className="nuggets-latest-episode-desc">{latest.description}</p>}
|
{latest.description && <p className="nuggets-latest-episode-desc">{latest.description}</p>}
|
||||||
{latest.audioUrl && <EpisodeAudioPlayer src={latest.audioUrl} title={latest.title} size="full" />}
|
{latest.audioUrl && <EpisodeAudioPlayer src={latest.audioUrl} title={latest.title} size="full" />}
|
||||||
</div>
|
</div>
|
||||||
{rest.length > 0 && (
|
)}
|
||||||
<ol className="finished-series-playlist nuggets-episode-list">
|
|
||||||
{rest.map((ep, i) => (
|
<div className="nug-list-head">
|
||||||
<li key={ep.audioUrl || i} className="finished-series-episode">
|
<h2 className="section-heading">{filtering ? 'Search results' : 'All Episodes'}</h2>
|
||||||
<span className="finished-series-ep-num">{ep.episode || i + 2}</span>
|
{episodes.length > 4 && (
|
||||||
<div className="finished-series-ep-body">
|
<input
|
||||||
<p className="finished-series-ep-title">{ep.title}</p>
|
type="search"
|
||||||
{ep.description && <p className="nuggets-latest-episode-desc" style={{ fontSize: '0.88rem', margin: '3px 0 6px' }}>{ep.description}</p>}
|
className="nl-search"
|
||||||
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
|
placeholder="Search episodes…"
|
||||||
{ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
|
aria-label="Search episodes"
|
||||||
|
value={query}
|
||||||
|
onChange={e => { setQuery(e.target.value); setShown(12) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -2509,35 +2554,57 @@ function NuggetsHostDetailPage({ content }: { content: SiteContent }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const otherHosts = (nuggets.hosts ?? []).filter(h => !h.archived && h.id !== host.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar content={content} />
|
<SiteSearchBar content={content} />
|
||||||
<PageBanner eyebrow={nuggets.title} title={host.name} desc={host.role} />
|
<section className="hd-hero" aria-label={`${host.name} profile`}>
|
||||||
<section className="section-nuggets-body" aria-label={`${host.name} profile`}>
|
<div className="section-inner hd-hero-inner">
|
||||||
<div className="section-inner nuggets-host-detail-layout">
|
{host.imageUrl && <img src={host.imageUrl} alt={host.name} className="hd-photo" />}
|
||||||
<div className="nuggets-host-detail-photo-col">
|
<div className="hd-hero-body">
|
||||||
{host.imageUrl && (
|
<nav className="breadcrumb" aria-label="Breadcrumb">
|
||||||
<img src={host.imageUrl} alt={host.name} className="nuggets-host-detail-photo" />
|
<Link to="/nuggets">{nuggets.title}</Link>
|
||||||
)}
|
<span aria-hidden="true"> › </span>
|
||||||
|
<span>{host.name}</span>
|
||||||
|
</nav>
|
||||||
|
<p className="eyebrow">{host.role || 'Host'}</p>
|
||||||
|
<h1 className="hd-name">{host.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="nuggets-host-detail-body">
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="hd-body">
|
||||||
|
<div className="section-inner hd-body-inner">
|
||||||
{host.bio && host.bio.split(/\n\n+/).map((para, i) => (
|
{host.bio && host.bio.split(/\n\n+/).map((para, i) => (
|
||||||
<p key={i} className="nuggets-host-detail-bio">{para.replace(/\n/g, ' ')}</p>
|
<p key={i} className="hd-bio">{para.replace(/\n/g, ' ')}</p>
|
||||||
))}
|
))}
|
||||||
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
|
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
|
||||||
<div className="nuggets-host-detail-listen">
|
<div className="hd-listen">
|
||||||
<p className="eyebrow" style={{ marginBottom: '0.75rem' }}>Listen to {nuggets.title}</p>
|
<p className="eyebrow">Listen to {nuggets.title}</p>
|
||||||
<div className="nuggets-platform-links">
|
<div className="nuggets-platform-links">
|
||||||
{nuggets.spotifyUrl && <a href={nuggets.spotifyUrl} target="_blank" rel="noopener noreferrer" className="btn-primary">Listen on Spotify</a>}
|
{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>}
|
{nuggets.appleUrl && <a href={nuggets.appleUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">Listen on Apple Podcasts</a>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ marginTop: '2rem' }}>
|
{otherHosts.length > 0 && (
|
||||||
<Link to="/nuggets" className="nuggets-host-detail-back">← Back to {nuggets.title}</Link>
|
<div className="hd-others">
|
||||||
|
<p className="eyebrow">Also on {nuggets.title}</p>
|
||||||
|
<div className="nug-hosts-grid">
|
||||||
|
{otherHosts.map(h => (
|
||||||
|
<Link key={h.id} to={`/nuggets/hosts/${h.id}`} className="nug-host-card">
|
||||||
|
{h.imageUrl && <img src={h.imageUrl} alt={h.name} className="nug-host-img" loading="lazy" />}
|
||||||
|
<div>
|
||||||
|
<span className="nug-host-name">{h.name}</span>
|
||||||
|
{h.role && <span className="nug-host-role">{h.role}</span>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<Link to="/nuggets" className="btn-secondary hd-back">← All {nuggets.title} episodes</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<SiteFooter content={content} />
|
<SiteFooter content={content} />
|
||||||
@@ -3006,19 +3073,26 @@ function StartHerePage({ content }: { content: SiteContent }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThankYouPage() {
|
function CenteredPageFrame({ content, children, label }: { content: SiteContent; children: React.ReactNode; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="site">
|
||||||
|
<SiteHeader content={content} />
|
||||||
|
<main className="cp-main" aria-label={label}>
|
||||||
|
<div className="cp-card">{children}</div>
|
||||||
|
</main>
|
||||||
|
<SiteFooter content={content} />
|
||||||
|
<AnalyticsConsentBanner content={content} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThankYouPage({ content }: { content: SiteContent }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [secondsLeft, setSecondsLeft] = useState(15)
|
const [secondsLeft, setSecondsLeft] = useState(15)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => navigate('/'), 15000)
|
||||||
navigate('/')
|
const intervalId = window.setInterval(() => setSecondsLeft(s => (s > 0 ? s - 1 : 0)), 1000)
|
||||||
}, 15000)
|
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
setSecondsLeft(s => (s > 0 ? s - 1 : 0))
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timeoutId)
|
window.clearTimeout(timeoutId)
|
||||||
window.clearInterval(intervalId)
|
window.clearInterval(intervalId)
|
||||||
@@ -3026,23 +3100,21 @@ function ThankYouPage() {
|
|||||||
}, [navigate])
|
}, [navigate])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Thank you">
|
<CenteredPageFrame content={content} label="Thank you">
|
||||||
<div className="thanks-card">
|
<span className="cp-icon" aria-hidden="true">✓</span>
|
||||||
<p className="eyebrow">Message Received</p>
|
<p className="eyebrow">Message received</p>
|
||||||
<h1>Thank You</h1>
|
<h1 className="cp-title">Thank you</h1>
|
||||||
<p>
|
<p className="cp-text">Your message was sent. We appreciate you reaching out and will get back to you soon.</p>
|
||||||
Your message was sent successfully. We appreciate you reaching out and will get back to you soon.
|
<div className="cp-actions">
|
||||||
</p>
|
<Link to="/" className="btn-primary">Back to home</Link>
|
||||||
<p className="thanks-countdown">Returning to the main site in {secondsLeft} seconds...</p>
|
<Link to="/episodes" className="btn-secondary">Browse episodes</Link>
|
||||||
<button type="button" className="btn-primary" onClick={() => navigate('/')}>
|
|
||||||
Go Back Now
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<p className="cp-note">Returning to the home page in {secondsLeft}s</p>
|
||||||
|
</CenteredPageFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscribePage() {
|
function SubscribePage({ content }: { content: SiteContent }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [firstName, setFirstName] = useState('')
|
const [firstName, setFirstName] = useState('')
|
||||||
const [lastName, setLastName] = useState('')
|
const [lastName, setLastName] = useState('')
|
||||||
@@ -3086,15 +3158,11 @@ function SubscribePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Subscribe">
|
<CenteredPageFrame content={content} label="Subscribe">
|
||||||
<div className="thanks-card subscribe-card">
|
|
||||||
<p className="eyebrow">Verse by Verse with Nate</p>
|
<p className="eyebrow">Verse by Verse with Nate</p>
|
||||||
<h1>Subscribe for Updates</h1>
|
<h1 className="cp-title">Subscribe for updates</h1>
|
||||||
<p>
|
<p className="cp-text">New episodes, study resources and ministry updates, sent to your inbox. No spam, and you can unsubscribe any time.</p>
|
||||||
Get ministry updates and new episode announcements by email.
|
<form className="cp-form" onSubmit={handleSubmit} noValidate>
|
||||||
</p>
|
|
||||||
|
|
||||||
<form className="contact-form subscribe-form" onSubmit={handleSubmit} noValidate>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="contact-honeypot"
|
className="contact-honeypot"
|
||||||
@@ -3104,79 +3172,60 @@ function SubscribePage() {
|
|||||||
value={honey}
|
value={honey}
|
||||||
onChange={e => setHoney(e.target.value)}
|
onChange={e => setHoney(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<div className="cp-form-row">
|
||||||
<label>
|
<label>
|
||||||
First Name
|
First name
|
||||||
<input
|
<input type="text" name="firstName" required autoComplete="given-name" value={firstName} onChange={e => setFirstName(e.target.value)} />
|
||||||
type="text"
|
|
||||||
name="firstName"
|
|
||||||
required
|
|
||||||
autoComplete="given-name"
|
|
||||||
value={firstName}
|
|
||||||
onChange={e => setFirstName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Last Name
|
Last name
|
||||||
<input
|
<input type="text" name="lastName" autoComplete="family-name" value={lastName} onChange={e => setLastName(e.target.value)} />
|
||||||
type="text"
|
|
||||||
name="lastName"
|
|
||||||
required
|
|
||||||
autoComplete="family-name"
|
|
||||||
value={lastName}
|
|
||||||
onChange={e => setLastName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
<label>
|
<label>
|
||||||
Email
|
Email
|
||||||
<input
|
<input type="email" name="email" required autoComplete="email" value={email} onChange={e => setEmail(e.target.value)} />
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
required
|
|
||||||
autoComplete="email"
|
|
||||||
value={email}
|
|
||||||
onChange={e => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
|
{status === 'error' && <p className="contact-error" role="alert">{errorMsg}</p>}
|
||||||
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
||||||
{status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
|
{status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
|
||||||
</button>
|
</button>
|
||||||
<Link to="/" className="btn-secondary">Back to Site</Link>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
<p className="cp-note">Prefer to browse first? <Link to="/newsletter">Read past issues</Link></p>
|
||||||
</main>
|
</CenteredPageFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscribeThankYouPage() {
|
function SubscribeThankYouPage({ content }: { content: SiteContent }) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Subscription confirmed">
|
<CenteredPageFrame content={content} label="Subscription confirmed">
|
||||||
<div className="thanks-card">
|
<span className="cp-icon" aria-hidden="true">✓</span>
|
||||||
<p className="eyebrow">Verse by Verse with Nate</p>
|
<p className="eyebrow">Verse by Verse with Nate</p>
|
||||||
<h1>You are subscribed</h1>
|
<h1 className="cp-title">You’re subscribed</h1>
|
||||||
<p>
|
<p className="cp-text">Thank you for subscribing. You’ll receive new episode announcements and ministry updates.</p>
|
||||||
Thank you for subscribing. You will receive future ministry updates and episode announcements.
|
<div className="cp-links">
|
||||||
</p>
|
<Link to="/episodes" className="cp-link-card"><strong>Listen now</strong><span>Start with the latest episode</span></Link>
|
||||||
<Link to="/" className="btn-primary">Back to Site</Link>
|
<Link to="/study" className="cp-link-card"><strong>Join a study</strong><span>Go deeper, one lesson at a time</span></Link>
|
||||||
|
<Link to="/newsletter" className="cp-link-card"><strong>Past issues</strong><span>Read the newsletter archive</span></Link>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div className="cp-actions"><Link to="/" className="btn-secondary">Back to home</Link></div>
|
||||||
|
</CenteredPageFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegalPage({ title, body }: { title: string; body: string[] }) {
|
function LegalPage({ content, title, body }: { content: SiteContent; title: string; body: string[] }) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label={title}>
|
<div className="site">
|
||||||
<div className="thanks-card">
|
<SiteHeader content={content} />
|
||||||
<p className="eyebrow">Verse by Verse with Nate</p>
|
<PageBanner eyebrow="Verse by Verse with Nate" title={title} />
|
||||||
<h1>{title}</h1>
|
<main className="section-inner cp-legal" aria-label={title}>
|
||||||
{body.map((line, idx) => (
|
{body.map((line, idx) => (
|
||||||
<p key={`${title}-${idx}`}>{line}</p>
|
<p key={`${title}-${idx}`}>{line}</p>
|
||||||
))}
|
))}
|
||||||
<Link to="/" className="btn-primary">Back to Site</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
<SiteFooter content={content} />
|
||||||
|
<AnalyticsConsentBanner content={content} />
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3186,7 +3235,7 @@ function LegacyCertificateRedirect() {
|
|||||||
return <Navigate to={`/certificate/${encodeURIComponent(token ?? '')}`} replace />
|
return <Navigate to={`/certificate/${encodeURIComponent(token ?? '')}`} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
function PublicCertificatePage() {
|
function PublicCertificatePage({ content }: { content: SiteContent }) {
|
||||||
const { token } = useParams<{ token: string }>()
|
const { token } = useParams<{ token: string }>()
|
||||||
const [cert, setCert] = useState<{ studyTitle: string; displayName: string; issuedAt: string } | null>(null)
|
const [cert, setCert] = useState<{ studyTitle: string; displayName: string; issuedAt: string } | null>(null)
|
||||||
const [status, setStatus] = useState<'loading' | 'not-found' | 'ready'>('loading')
|
const [status, setStatus] = useState<'loading' | 'not-found' | 'ready'>('loading')
|
||||||
@@ -3220,13 +3269,12 @@ function PublicCertificatePage() {
|
|||||||
}
|
}
|
||||||
if (status === 'not-found' || !cert) {
|
if (status === 'not-found' || !cert) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page">
|
<CenteredPageFrame content={content} label="Certificate not found">
|
||||||
<div className="thanks-card">
|
<p className="eyebrow">Certificate</p>
|
||||||
<h1>Certificate Not Found</h1>
|
<h1 className="cp-title">Certificate not found</h1>
|
||||||
<p>This certificate link is invalid or has been removed.</p>
|
<p className="cp-text">This certificate link is invalid or has been removed.</p>
|
||||||
<Link to="/" className="btn-primary">Back to Site</Link>
|
<div className="cp-actions"><Link to="/" className="btn-primary">Back to site</Link></div>
|
||||||
</div>
|
</CenteredPageFrame>
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3516,6 +3564,7 @@ function NewsletterArchivePage({ content }: { content: SiteContent }) {
|
|||||||
)
|
)
|
||||||
const [issues, setIssues] = useState<NewsletterIssueSummary[]>([])
|
const [issues, setIssues] = useState<NewsletterIssueSummary[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/newsletter/issues')
|
fetch('/api/newsletter/issues')
|
||||||
@@ -3525,39 +3574,96 @@ function NewsletterArchivePage({ content }: { content: SiteContent }) {
|
|||||||
.finally(() => setLoading(false))
|
.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 (
|
return (
|
||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar 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." />
|
<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="section-resources" aria-label="Newsletter archive">
|
|
||||||
<div className="section-inner">
|
<section className="nl-top" aria-label="Latest issue and sign-up">
|
||||||
{loading ? (
|
<div className="section-inner nl-top-grid">
|
||||||
<p className="episode-seo-intro">Loading issues…</p>
|
{featured ? (
|
||||||
) : issues.length === 0 ? (
|
<a href={`/newsletter/${featured.slug}`} className="nl-featured">
|
||||||
<p className="episode-seo-intro">No newsletter issues published yet.</p>
|
<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">
|
<div className="nl-featured nl-featured--empty">
|
||||||
{issues.map(issue => (
|
<span className="nl-featured-tag">Archive</span>
|
||||||
<a key={issue.id} href={`/newsletter/${issue.slug}`} className="resource-download-card">
|
<h2 className="nl-featured-title">{loading ? 'Loading issues…' : 'First issue coming soon'}</h2>
|
||||||
<div className="resource-download-header">
|
{!loading && <p className="nl-featured-desc">Subscribe and you’ll get it the moment it’s sent.</p>}
|
||||||
<div className="resource-download-meta">
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
<span className="resource-link-action">Read issue →</span>
|
<HomepageNewsletterSection embedded />
|
||||||
</div>
|
</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>
|
</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>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<CustomBlocksSection content={content} page="newsletter" />
|
<CustomBlocksSection content={content} page="newsletter" />
|
||||||
<SiteFooter content={content} />
|
<SiteFooter content={content} />
|
||||||
<AnalyticsConsentBanner content={content} />
|
<AnalyticsConsentBanner content={content} />
|
||||||
@@ -3602,11 +3708,11 @@ function PreviewPage() {
|
|||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Draft preview loading">
|
<main className="cp-main cp-main--bare" aria-label="Draft preview loading">
|
||||||
<div className="thanks-card">
|
<div className="cp-card">
|
||||||
<p className="eyebrow">Admin Preview</p>
|
<p className="eyebrow">Admin Preview</p>
|
||||||
<h1>Loading draft preview...</h1>
|
<h1 className="cp-title">Loading draft preview…</h1>
|
||||||
<p>Only signed-in admins can view this page.</p>
|
<p className="cp-text">Only signed-in admins can view this page.</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -3614,22 +3720,33 @@ function PreviewPage() {
|
|||||||
|
|
||||||
if (status === 'error' || !content) {
|
if (status === 'error' || !content) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Draft preview unavailable">
|
<main className="cp-main cp-main--bare" aria-label="Draft preview unavailable">
|
||||||
<div className="thanks-card">
|
<div className="cp-card">
|
||||||
<p className="eyebrow">Admin Preview</p>
|
<p className="eyebrow">Admin Preview</p>
|
||||||
<h1>Draft preview unavailable</h1>
|
<h1 className="cp-title">Draft preview unavailable</h1>
|
||||||
<p>Sign into admin and save a draft first.</p>
|
<p className="cp-text">Sign into admin and save a draft first.</p>
|
||||||
<Link to="/admin" className="btn-primary">Back to Admin</Link>
|
<div className="cp-actions"><Link to="/admin" className="btn-primary">Back to Admin</Link></div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previewView === 'start-here') return <StartHerePage content={content} />
|
// Admin sends views like "about:bio" or "podcast:highlights"; match on the section prefix
|
||||||
if (previewView === 'about') return <AboutPage content={content} />
|
// so every admin section previews the public page it edits.
|
||||||
if (previewView === 'contact') return <ContactPage content={content} />
|
const section = previewView.split(':')[0]
|
||||||
if (previewView === 'current-series' || previewView === 'episode-highlights') {
|
const view = previewView
|
||||||
return <EpisodesPage content={content} />
|
if (section === 'start-here') return <StartHerePage content={content} />
|
||||||
|
if (section === 'about') return <AboutPage content={content} />
|
||||||
|
if (section === 'contact') return <ContactPage content={content} />
|
||||||
|
if (view === 'podcast:finished-books' || view === 'books') return <FinishedBooksPage content={content} />
|
||||||
|
if (section === 'podcast') return <EpisodesPage content={content} />
|
||||||
|
if (section === 'nuggets') return <NuggetsPage content={content} />
|
||||||
|
if (view === 'downloads' || view === 'recommended-resources' || view === 'custom-links') return <ResourcesPage content={content} />
|
||||||
|
if (view === 'colossians-study') return <StudyRouteFrame content={content} child={<StudyLandingPage content={content} />} />
|
||||||
|
if (view === 'questions') return <QuestionsPage content={content} />
|
||||||
|
if (view === 'newsletter-archive' || view === 'broadcast') return <NewsletterArchivePage content={content} />
|
||||||
|
if (view === 'legal') {
|
||||||
|
return <LegalPage content={content} title={content.legal?.privacyTitle ?? DEFAULTS.legal.privacyTitle} body={content.legal?.privacyBody ?? DEFAULTS.legal.privacyBody} />
|
||||||
}
|
}
|
||||||
return <LandingPage content={content} />
|
return <LandingPage content={content} />
|
||||||
}
|
}
|
||||||
@@ -3785,10 +3902,10 @@ export default function App() {
|
|||||||
<Route path="/questions" element={<QuestionsPage content={content} />} />
|
<Route path="/questions" element={<QuestionsPage content={content} />} />
|
||||||
<Route path="/newsletter" element={<NewsletterArchivePage content={content} />} />
|
<Route path="/newsletter" element={<NewsletterArchivePage content={content} />} />
|
||||||
<Route path="/scripture" element={<ScriptureIndexPage content={content} />} />
|
<Route path="/scripture" element={<ScriptureIndexPage content={content} />} />
|
||||||
<Route path="/thanks" element={<ThankYouPage />} />
|
<Route path="/thanks" element={<ThankYouPage content={content} />} />
|
||||||
<Route path="/subscribe" element={<SubscribePage />} />
|
<Route path="/subscribe" element={<SubscribePage content={content} />} />
|
||||||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage content={content} />} />
|
||||||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
<Route path="/certificate/:token" element={<PublicCertificatePage content={content} />} />
|
||||||
<Route path="/host" element={<NuggetsHostPortal />} />
|
<Route path="/host" element={<NuggetsHostPortal />} />
|
||||||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
||||||
<Route path="/preview" element={<PreviewPage />} />
|
<Route path="/preview" element={<PreviewPage />} />
|
||||||
@@ -3796,6 +3913,7 @@ export default function App() {
|
|||||||
path="/privacy"
|
path="/privacy"
|
||||||
element={(
|
element={(
|
||||||
<LegalPage
|
<LegalPage
|
||||||
|
content={content}
|
||||||
title={content.legal?.privacyTitle ?? DEFAULTS.legal.privacyTitle}
|
title={content.legal?.privacyTitle ?? DEFAULTS.legal.privacyTitle}
|
||||||
body={content.legal?.privacyBody ?? DEFAULTS.legal.privacyBody}
|
body={content.legal?.privacyBody ?? DEFAULTS.legal.privacyBody}
|
||||||
/>
|
/>
|
||||||
@@ -3805,19 +3923,20 @@ export default function App() {
|
|||||||
path="/terms"
|
path="/terms"
|
||||||
element={(
|
element={(
|
||||||
<LegalPage
|
<LegalPage
|
||||||
|
content={content}
|
||||||
title={content.legal?.termsTitle ?? DEFAULTS.legal.termsTitle}
|
title={content.legal?.termsTitle ?? DEFAULTS.legal.termsTitle}
|
||||||
body={content.legal?.termsBody ?? DEFAULTS.legal.termsBody}
|
body={content.legal?.termsBody ?? DEFAULTS.legal.termsBody}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage content={content} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<BackToTopButton />
|
<BackToTopButton />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotFoundPage() {
|
function NotFoundPage({ content }: { content: SiteContent }) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
@@ -3857,24 +3976,19 @@ function NotFoundPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Page not found">
|
<CenteredPageFrame content={content} label="Page not found">
|
||||||
<div className="thanks-card">
|
<p className="cp-404" aria-hidden="true">404</p>
|
||||||
<img
|
<h1 className="cp-title">Page not found</h1>
|
||||||
src="/book_icon.png"
|
<p className="cp-text">
|
||||||
alt=""
|
<code>{location.pathname}</code> doesn’t exist. It may have moved or been removed.
|
||||||
aria-hidden="true"
|
|
||||||
style={{ width: '72px', marginBottom: '0.75rem', opacity: 0.55 }}
|
|
||||||
/>
|
|
||||||
<p className="eyebrow">404</p>
|
|
||||||
<h1>Page Not Found</h1>
|
|
||||||
<p>
|
|
||||||
The path <code style={{ fontSize: '0.95em', opacity: 0.85 }}>{location.pathname}</code>{' '}
|
|
||||||
doesn't exist — it may have moved or been removed.
|
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap', marginBottom: '2rem' }}>
|
<div className="cp-links">
|
||||||
<Link to="/" className="btn-primary">← Back to Home</Link>
|
<Link to="/episodes" className="cp-link-card"><strong>Episodes</strong><span>Listen to the podcast</span></Link>
|
||||||
<Link to="/episodes" className="btn-secondary">Browse Episodes</Link>
|
<Link to="/study" className="cp-link-card"><strong>Bible studies</strong><span>Lessons and quizzes</span></Link>
|
||||||
|
<Link to="/questions" className="cp-link-card"><strong>Q&A</strong><span>Questions and answers</span></Link>
|
||||||
|
<Link to="/resources" className="cp-link-card"><strong>Resources</strong><span>Downloads and guides</span></Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="cp-actions"><Link to="/" className="btn-primary">← Back to home</Link></div>
|
||||||
<div style={{ borderTop: '1px solid rgba(201,168,76,0.15)', paddingTop: '1.5rem' }}>
|
<div style={{ borderTop: '1px solid rgba(201,168,76,0.15)', paddingTop: '1.5rem' }}>
|
||||||
{submitStatus !== 'sent' ? (
|
{submitStatus !== 'sent' ? (
|
||||||
<>
|
<>
|
||||||
@@ -3944,8 +4058,7 @@ function NotFoundPage() {
|
|||||||
<p style={{ color: '#6fcf97' }}>Thanks! Nate will look into it.</p>
|
<p style={{ color: '#6fcf97' }}>Thanks! Nate will look into it.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CenteredPageFrame>
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+178
-147
@@ -1501,8 +1501,9 @@ export function StudyResetPasswordPage() {
|
|||||||
}, [token, newPassword, confirmPassword])
|
}, [token, newPassword, confirmPassword])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Reset password">
|
<main className="auth-page" aria-label="Reset password">
|
||||||
<div className="thanks-card study-signup-page-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Free Student Account</p>
|
<p className="eyebrow">Free Student Account</p>
|
||||||
<h1>Reset Your Password</h1>
|
<h1>Reset Your Password</h1>
|
||||||
|
|
||||||
@@ -1549,6 +1550,7 @@ export function StudyResetPasswordPage() {
|
|||||||
<Link to="/study/signup" className="btn-secondary">Back to Sign In</Link>
|
<Link to="/study/signup" className="btn-secondary">Back to Sign In</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1574,18 +1576,15 @@ export function StudyVerifyEmailPage() {
|
|||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Verify email">
|
<main className="auth-page" aria-label="Verify email">
|
||||||
<div className="thanks-card study-signup-page-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
|
{status === 'ok' && <span className="cp-icon" aria-hidden="true">✓</span>}
|
||||||
<p className="eyebrow">Free Student Account</p>
|
<p className="eyebrow">Free Student Account</p>
|
||||||
<h1>Verify Your Email</h1>
|
<h1>{status === 'ok' ? 'Email Verified' : status === 'error' ? 'Verification Problem' : 'Verifying Your Email'}</h1>
|
||||||
|
|
||||||
{status === 'checking' && <p style={{ color: '#b09e79' }}>Verifying…</p>}
|
{status === 'checking' && <p>Checking your link…</p>}
|
||||||
{status === 'ok' && (
|
{status === 'ok' && <p className="study-signup-success">Your email address is verified. You’re all set.</p>}
|
||||||
<>
|
|
||||||
<p className="study-signup-success">Your email address is verified.</p>
|
|
||||||
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>Thanks for confirming — you're all set.</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === 'error' && <p className="study-signup-error">{message}</p>}
|
{status === 'error' && <p className="study-signup-error">{message}</p>}
|
||||||
|
|
||||||
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
|
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
|
||||||
@@ -1593,6 +1592,7 @@ export function StudyVerifyEmailPage() {
|
|||||||
<Link to="/study" className="btn-secondary">Back to Studies</Link>
|
<Link to="/study" className="btn-secondary">Back to Studies</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1660,13 +1660,17 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!study) {
|
if (!study) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Study not found">
|
<main className="auth-page" aria-label="Study not found">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Study Hub</p>
|
<p className="eyebrow">Study Hub</p>
|
||||||
<h1>Study not found</h1>
|
<h1>Study not found</h1>
|
||||||
<p>The study you requested is not available yet.</p>
|
<p>The study you requested is not available yet.</p>
|
||||||
|
<div className="study-signup-actions">
|
||||||
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1707,108 +1711,89 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
|
|
||||||
const studyGuide = getStudyGuideLink(content, study)
|
const studyGuide = getStudyGuideLink(content, study)
|
||||||
|
|
||||||
|
const pct = sections.length > 0 ? Math.round((completedCount / sections.length) * 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="study-index-page" aria-label={`${study.title} study`}>
|
<main className="study-index-page" aria-label={`${study.title} study`}>
|
||||||
<section className="section-study-course-hero">
|
<section className="si-hero">
|
||||||
<div className="section-inner study-course-hero-inner">
|
<div className="section-inner si-hero-inner">
|
||||||
|
<div className="si-hero-main">
|
||||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title }]} />
|
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title }]} />
|
||||||
<p className="eyebrow">Online Bible Class</p>
|
<p className="eyebrow">Online Bible Class</p>
|
||||||
<h1>{study.title}</h1>
|
<h1 className="si-title">{study.title}</h1>
|
||||||
<p className="study-course-hero-copy">{study.description}</p>
|
<p className="si-desc">{study.description}</p>
|
||||||
<div className="study-course-meta">
|
<div className="si-chips">
|
||||||
<span>{releasedSections.length} of {sections.length} lessons available</span>
|
<span>{releasedSections.length} of {sections.length} lessons available</span>
|
||||||
<span>Text + commentary + discussion</span>
|
<span>Commentary + discussion</span>
|
||||||
<span>Student notes enabled</span>
|
<span>Personal notes</span>
|
||||||
{isSelfPaced && <span>Finished study — go at your own pace</span>}
|
{isSelfPaced && <span>Self-paced</span>}
|
||||||
</div>
|
</div>
|
||||||
{allLessonsDone && (
|
<div className="si-actions">
|
||||||
<p className="study-completed-banner" role="status">🎉 You've completed this study. Your lessons, notes and certificate are yours to keep.</p>
|
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{allLessonsDone ? 'Review lessons' : hasStarted ? 'Resume where you left off' : 'Start class'}</Link>}
|
||||||
)}
|
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign in to enroll</Link>}
|
||||||
{enrolled && !progressLoading && sections.length > 0 && (
|
|
||||||
<div className="study-progress" role="group" aria-label="Your progress">
|
|
||||||
<div className="study-progress-label">
|
|
||||||
<span>Your progress</span>
|
|
||||||
<strong>{completedCount} of {sections.length} lessons completed</strong>
|
|
||||||
</div>
|
|
||||||
<div className="study-progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={sections.length} aria-valuenow={completedCount}>
|
|
||||||
<div className="study-progress-fill" style={{ width: `${Math.round((completedCount / sections.length) * 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="study-course-hero-actions">
|
|
||||||
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{allLessonsDone ? 'Review Lessons' : hasStarted ? 'Resume Where You Left Off' : 'Start Class'}</Link>}
|
|
||||||
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign In to Enroll</Link>}
|
|
||||||
{auth.authenticated && !enrolled && (
|
{auth.authenticated && !enrolled && (
|
||||||
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
||||||
{enrolling ? 'Enrolling...' : 'Enroll in This Study'}
|
{enrolling ? 'Enrolling…' : 'Enroll in this study'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{studyGuide && (studyGuide.downloadUrl
|
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My notes</Link>}
|
||||||
? <a href={studyGuide.downloadUrl} target="_blank" rel="noopener noreferrer" download className="btn-secondary">📄 Download Study Guide</a>
|
|
||||||
: <Link to={`/downloads/${studyGuide.pageId}`} className="btn-secondary">📄 Get the Study Guide</Link>)}
|
|
||||||
{allLessonsDone && <Link to="/study/account" className="btn-secondary">🏅 My Certificate</Link>}
|
|
||||||
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>}
|
|
||||||
{enrolled && studyStatus !== 'planned' && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
|
{enrolled && studyStatus !== 'planned' && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
|
||||||
{auth.authenticated && <Link to="/study/account" className="btn-secondary">My Account</Link>}
|
{studyGuide && (studyGuide.downloadUrl
|
||||||
|
? <a href={studyGuide.downloadUrl} target="_blank" rel="noopener noreferrer" download className="btn-secondary">Study guide</a>
|
||||||
|
: <Link to={`/downloads/${studyGuide.pageId}`} className="btn-secondary">Study guide</Link>)}
|
||||||
|
{allLessonsDone && <Link to="/study/account" className="btn-secondary">My certificate</Link>}
|
||||||
</div>
|
</div>
|
||||||
{enrollMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{enrollMessage}</p>}
|
{enrollMessage && <p className="study-note-status" role="status">{enrollMessage}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enrolled && !progressLoading && sections.length > 0 && (
|
||||||
|
<aside className="si-progress" aria-label="Your progress">
|
||||||
|
<div className="si-ring" style={{ ['--pct' as string]: `${pct}%` }}>
|
||||||
|
<span>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="si-progress-big">{completedCount} of {sections.length}</p>
|
||||||
|
<p className="si-progress-sub">{allLessonsDone ? 'Study complete 🎉' : 'lessons completed'}</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{auth.checked && auth.authenticated && !enrolled && (
|
{auth.checked && auth.authenticated && !enrolled && (
|
||||||
<section className="section-study-module" aria-label="Enrollment required">
|
<section className="section-inner si-notice" aria-label="Enrollment required">
|
||||||
<div className="section-inner">
|
<strong>Enroll to open the lessons.</strong>
|
||||||
<article className="study-module-row study-module-row--disabled" aria-disabled="true">
|
<span>Enrollment is free and lets you save notes and track progress.</span>
|
||||||
<div className="study-module-row-left">
|
|
||||||
<p className="study-module-lesson">Enrollment Required</p>
|
|
||||||
<h3>Enroll before entering lessons</h3>
|
|
||||||
<p>You must enroll in this study before opening lessons or notes.</p>
|
|
||||||
<div style={{ marginTop: '0.75rem' }}>
|
|
||||||
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
||||||
{enrolling ? 'Enrolling...' : `Enroll in ${study.title}`}
|
{enrolling ? 'Enrolling…' : `Enroll in ${study.title}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sections.length === 0 && (
|
{sections.length === 0 && (
|
||||||
<section className="section-study-module">
|
<section className="section-inner si-notice">
|
||||||
<div className="section-inner">
|
<strong>Lessons are being prepared.</strong>
|
||||||
<article className="study-module-row study-module-row--disabled" aria-disabled="true">
|
<span>Check back soon.</span>
|
||||||
<div className="study-module-row-left">
|
|
||||||
<p className="study-module-lesson">Planned</p>
|
|
||||||
<h3>Lessons are being prepared</h3>
|
|
||||||
<p>Use the admin Studies editor to add section lessons and publish when ready.</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sections.length > 0 && (
|
{sections.length > 0 && (
|
||||||
<section className="section-study-module" aria-label="Course lessons">
|
<section className="si-lessons" aria-label="Course lessons">
|
||||||
<div className="section-inner">
|
<div className="section-inner">
|
||||||
<div className="study-module-header">
|
|
||||||
<p>Course Lessons</p>
|
|
||||||
<h2>{study.title}</h2>
|
|
||||||
</div>
|
|
||||||
{chaptersWithLessons.map(({ chapter }) => {
|
{chaptersWithLessons.map(({ chapter }) => {
|
||||||
const chapterSections = sections.filter(item => item.chapter === chapter)
|
const chapterSections = sections.filter(item => item.chapter === chapter)
|
||||||
const chapterDone = chapterSections.filter(item => studyProgress.completedSectionIds.includes(item.id)).length
|
const chapterDone = chapterSections.filter(item => studyProgress.completedSectionIds.includes(item.id)).length
|
||||||
return (
|
return (
|
||||||
<details className="study-chapter" key={chapter} open={chaptersWithLessons.length === 1 || chapter === resumeChapter}>
|
<details className="si-chapter" key={chapter} open={chaptersWithLessons.length === 1 || chapter === resumeChapter}>
|
||||||
<summary className="study-chapter-head">
|
<summary className="si-chapter-head">
|
||||||
<h3>Chapter {chapter}</h3>
|
<h2>Chapter {chapter}</h2>
|
||||||
<span>
|
<span>
|
||||||
{enrolled && !progressLoading
|
{enrolled && !progressLoading
|
||||||
? `${chapterDone} of ${chapterSections.length} completed`
|
? `${chapterDone} of ${chapterSections.length} completed`
|
||||||
: `${chapterSections.length} ${chapterSections.length === 1 ? 'lesson' : 'lessons'}`}
|
: `${chapterSections.length} ${chapterSections.length === 1 ? 'lesson' : 'lessons'}`}
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="study-module-list">
|
<div className="si-list">
|
||||||
{chapterSections.map(section => {
|
{chapterSections.map(section => {
|
||||||
const sectionIdx = sections.indexOf(section)
|
const sectionIdx = sections.indexOf(section)
|
||||||
const lessonNumber = sectionIdx + 1
|
const lessonNumber = sectionIdx + 1
|
||||||
@@ -1819,34 +1804,30 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
const completed = studyProgress.completedSectionIds.includes(section.id)
|
const completed = studyProgress.completedSectionIds.includes(section.id)
|
||||||
const lockedByProgress = enrolled && !isSelfPaced && !coming && sectionIdx > 0 && !prevCompleted && !completed
|
const lockedByProgress = enrolled && !isSelfPaced && !coming && sectionIdx > 0 && !prevCompleted && !completed
|
||||||
const isDisabled = coming || !enrolled || lockedByProgress
|
const isDisabled = coming || !enrolled || lockedByProgress
|
||||||
|
const done = completed && enrolled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={section.id} to={isDisabled ? '#' : `/study/${study.slug}/${section.id}`} className={`study-module-row${isDisabled ? ' study-module-row--disabled' : ''}${coming ? ' study-module-row--coming' : ''}${completed && enrolled ? ' study-module-row--done' : ''}`} aria-disabled={isDisabled || undefined} onClick={e => isDisabled && e.preventDefault()}>
|
<Link
|
||||||
<div className="study-module-row-left">
|
key={section.id}
|
||||||
<div className="study-lesson-meta">
|
to={isDisabled ? '#' : `/study/${study.slug}/${section.id}`}
|
||||||
<p className="study-module-lesson">Lesson {lessonNumber}</p>
|
className={`si-row${isDisabled ? ' si-row--disabled' : ''}${done ? ' si-row--done' : ''}`}
|
||||||
|
aria-disabled={isDisabled || undefined}
|
||||||
|
onClick={e => isDisabled && e.preventDefault()}
|
||||||
|
>
|
||||||
|
<span className="si-num" aria-hidden="true">{done ? '✓' : lessonNumber}</span>
|
||||||
|
<div className="si-row-body">
|
||||||
|
<div className="si-row-top">
|
||||||
|
<h3>{section.title}</h3>
|
||||||
{isNew && <span className="lesson-badge lesson-badge--new">New</span>}
|
{isNew && <span className="lesson-badge lesson-badge--new">New</span>}
|
||||||
{coming && <span className="lesson-badge lesson-badge--coming">Coming {getReleaseDateDisplay(section)}</span>}
|
{coming && <span className="lesson-badge lesson-badge--coming">Coming {getReleaseDateDisplay(section)}</span>}
|
||||||
{!coming && !enrolled && <span className="lesson-badge lesson-badge--enroll">Enroll to open</span>}
|
{!coming && !enrolled && <span className="lesson-badge lesson-badge--enroll">Enroll to open</span>}
|
||||||
{lockedByProgress && <span className="lesson-badge lesson-badge--locked">Finish the previous lesson first</span>}
|
{lockedByProgress && <span className="lesson-badge lesson-badge--locked">Finish the previous lesson first</span>}
|
||||||
{enrolled && !lockedByProgress && completed && (
|
|
||||||
<span className="lesson-badge lesson-badge--done">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
|
|
||||||
<circle cx="6.5" cy="6.5" r="6" stroke="currentColor" strokeWidth="1.2" />
|
|
||||||
<path d="M3.5 6.5l2 2 3.5-3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
Completed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h3>{section.title}</h3>
|
{section.reference && <p className="si-ref">{section.reference}</p>}
|
||||||
<p>{section.summary}</p>
|
{section.summary && <p className="si-summary">{section.summary}</p>}
|
||||||
</div>
|
<p className="si-focus"><span>Focus:</span> {getPrimaryQuestion(section)}</p>
|
||||||
<div className="study-module-row-right">
|
|
||||||
<p className="study-section-reference">{section.reference}</p>
|
|
||||||
<p className="study-module-focus">Focus question</p>
|
|
||||||
<p>{getPrimaryQuestion(section)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!isDisabled && <span className="si-arrow" aria-hidden="true">→</span>}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -2208,12 +2189,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!study || !section) {
|
if (!study || !section) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Section not found">
|
<main className="auth-page" aria-label="Section not found">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Study Hub</p>
|
<p className="eyebrow">Study Hub</p>
|
||||||
<h1>Section not found</h1>
|
<h1>Section not found</h1>
|
||||||
<p>The section you requested is not available yet.</p>
|
<p>The section you requested is not available yet.</p>
|
||||||
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
<div className="study-signup-actions"><Link to="/study" className="btn-primary">Back to Studies</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -2222,12 +2205,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
if (!sectionIsReleased) {
|
if (!sectionIsReleased) {
|
||||||
const releaseDateDisplay = getReleaseDateDisplay(section)
|
const releaseDateDisplay = getReleaseDateDisplay(section)
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Lesson not yet available">
|
<main className="auth-page" aria-label="Lesson not yet available">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">{study.title}</p>
|
<p className="eyebrow">{study.title}</p>
|
||||||
<h1>Lesson Coming Soon</h1>
|
<h1>Lesson Coming Soon</h1>
|
||||||
<p>{releaseDateDisplay ? `This lesson will be available on ${releaseDateDisplay}.` : 'This lesson will be available soon.'}</p>
|
<p>{releaseDateDisplay ? `This lesson will be available on ${releaseDateDisplay}.` : 'This lesson will be available soon.'}</p>
|
||||||
<Link to={`/study/${study.slug}`} className="btn-primary">Back to Lessons</Link>
|
<div className="study-signup-actions"><Link to={`/study/${study.slug}`} className="btn-primary">Back to Lessons</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -2235,11 +2220,13 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!auth.checked) {
|
if (!auth.checked) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Loading">
|
<main className="auth-page" aria-label="Loading">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">{study.title}</p>
|
<p className="eyebrow">{study.title}</p>
|
||||||
<p>Loading lesson...</p>
|
<p>Loading lesson...</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2320,17 +2307,24 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
|
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
|
||||||
<div className="section-inner study-classroom-shell">
|
<div className="section-inner study-classroom-shell">
|
||||||
<div className="study-classroom-main">
|
<div className="study-classroom-main">
|
||||||
|
<header className="sl-head">
|
||||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: `Lesson ${lessonNumber}` }]} />
|
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: `Lesson ${lessonNumber}` }]} />
|
||||||
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
|
<div className="sl-head-meta">
|
||||||
<p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p>
|
<span className="sl-lesson-pill">Lesson {lessonNumber} of {sections.length}</span>
|
||||||
|
{section.reference && <span className="sl-ref">{section.reference}</span>}
|
||||||
|
{lessonCompleted && <span className="sl-done">✓ Completed</span>}
|
||||||
|
</div>
|
||||||
|
<h1 className="sl-title">{section.title}</h1>
|
||||||
|
{section.summary && <p className="sl-summary">{section.summary}</p>}
|
||||||
{sections.length > 0 && (
|
{sections.length > 0 && (
|
||||||
<div className="study-lesson-progress-bar" role="progressbar" aria-valuenow={completedSectionIds.length} aria-valuemax={sections.length} aria-label={`${completedSectionIds.length} of ${sections.length} lessons completed`}>
|
<div className="sl-progress" aria-label={`${completedSectionIds.length} of ${sections.length} lessons completed`}>
|
||||||
|
<div className="study-lesson-progress-bar" role="progressbar" aria-valuenow={completedSectionIds.length} aria-valuemax={sections.length}>
|
||||||
<div className="study-lesson-progress-fill" style={{ width: `${Math.round((completedSectionIds.length / sections.length) * 100)}%` }} />
|
<div className="study-lesson-progress-fill" style={{ width: `${Math.round((completedSectionIds.length / sections.length) * 100)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
<span>{completedSectionIds.length} of {sections.length} lessons completed in {study.title}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<h1>{section.title}</h1>
|
</header>
|
||||||
<p className="study-detail-reference">{section.reference}</p>
|
|
||||||
<p className="study-detail-summary">{section.summary}</p>
|
|
||||||
|
|
||||||
{lessonAudioEmbedUrl && (
|
{lessonAudioEmbedUrl && (
|
||||||
<article className="study-class-block">
|
<article className="study-class-block">
|
||||||
@@ -2544,6 +2538,31 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
<StudySectionComments studySlug={currentStudySlug} sectionId={section.id} isEnrolled={isEnrolled} />
|
<StudySectionComments studySlug={currentStudySlug} sectionId={section.id} isEnrolled={isEnrolled} />
|
||||||
</article>}
|
</article>}
|
||||||
|
|
||||||
|
<nav className="sl-pager" aria-label="Lesson navigation">
|
||||||
|
{previousSection ? (
|
||||||
|
<Link to={`/study/${study.slug}/${previousSection.id}`} className="sl-pager-link">
|
||||||
|
<span>← Previous lesson</span>
|
||||||
|
<strong>{previousSection.title}</strong>
|
||||||
|
</Link>
|
||||||
|
) : <span />}
|
||||||
|
{nextSection && isSectionReleased(nextSection) && lessonCompleted ? (
|
||||||
|
<Link to={`/study/${study.slug}/${nextSection.id}`} className="sl-pager-link sl-pager-link--next">
|
||||||
|
<span>Next lesson →</span>
|
||||||
|
<strong>{nextSection.title}</strong>
|
||||||
|
</Link>
|
||||||
|
) : nextSection ? (
|
||||||
|
<div className="sl-pager-link sl-pager-link--next sl-pager-link--wait">
|
||||||
|
<span>Next lesson</span>
|
||||||
|
<strong>{!isSectionReleased(nextSection) ? `Available ${getReleaseDateDisplay(nextSection)}` : 'Complete this lesson to continue'}</strong>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link to={`/study/${study.slug}`} className="sl-pager-link sl-pager-link--next">
|
||||||
|
<span>Finished</span>
|
||||||
|
<strong>Back to all lessons →</strong>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
||||||
@@ -2760,12 +2779,14 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!study) {
|
if (!study) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Study not found">
|
<main className="auth-page" aria-label="Study not found">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Study Hub</p>
|
<p className="eyebrow">Study Hub</p>
|
||||||
<h1>Study not found</h1>
|
<h1>Study not found</h1>
|
||||||
<p>The study you requested is not available yet.</p>
|
<p>The study you requested is not available yet.</p>
|
||||||
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
<div className="study-signup-actions"><Link to="/study" className="btn-primary">Back to Studies</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -4133,12 +4154,14 @@ export function StudyQuizPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!study || !section) {
|
if (!study || !section) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Discussion questions not found">
|
<main className="auth-page" aria-label="Discussion questions not found">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Reflection Questions</p>
|
<p className="eyebrow">Reflection Questions</p>
|
||||||
<h1>Discussion questions not found</h1>
|
<h1>Discussion questions not found</h1>
|
||||||
<p>The reflection questions you requested are not available.</p>
|
<p>The reflection questions you requested are not available.</p>
|
||||||
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
<div className="study-signup-actions"><Link to="/study" className="btn-primary">Back to Studies</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -4150,20 +4173,24 @@ export function StudyQuizPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!auth.checked) {
|
if (!auth.checked) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Loading reflection questions">
|
<main className="auth-page" aria-label="Loading reflection questions">
|
||||||
<div className="thanks-card"><p>Loading reflection questions...</p></div>
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center"><p>Loading reflection questions...</p></div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth.authenticated) {
|
if (!auth.authenticated) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Sign in required">
|
<main className="auth-page" aria-label="Sign in required">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Reflection Questions</p>
|
<p className="eyebrow">Reflection Questions</p>
|
||||||
<h1>Sign In Required</h1>
|
<h1>Sign In Required</h1>
|
||||||
<p>You need to sign in before you can view and save your discussion answers.</p>
|
<p>You need to sign in before you can view and save your discussion answers.</p>
|
||||||
<Link to="/study/signup" className="btn-primary">Sign In</Link>
|
<div className="study-signup-actions"><Link to="/study/signup" className="btn-primary">Sign In</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -4171,12 +4198,14 @@ export function StudyQuizPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!isEnrolled) {
|
if (!isEnrolled) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Enrollment required">
|
<main className="auth-page" aria-label="Enrollment required">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Reflection Questions</p>
|
<p className="eyebrow">Reflection Questions</p>
|
||||||
<h1>Enroll to Access Reflection Questions</h1>
|
<h1>Enroll to Access Reflection Questions</h1>
|
||||||
<p>Please enroll in {study.title} to open the reflection questions for this lesson.</p>
|
<p>Please enroll in {study.title} to open the reflection questions for this lesson.</p>
|
||||||
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link>
|
<div className="study-signup-actions"><Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -4312,20 +4341,24 @@ export function StudyDiscussionHubPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!auth.checked) {
|
if (!auth.checked) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Loading">
|
<main className="auth-page" aria-label="Loading">
|
||||||
<div className="thanks-card"><p>Loading...</p></div>
|
<div className="auth-shell auth-shell--single"><div className="auth-card auth-card--center"><p>Loading…</p></div></div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth.authenticated) {
|
if (!auth.authenticated) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Sign in required">
|
<main className="auth-page" aria-label="Sign in required">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Study Discussion</p>
|
<p className="eyebrow">Study Discussion</p>
|
||||||
<h1>Sign In Required</h1>
|
<h1>Sign In Required</h1>
|
||||||
<p>Create a free student account to see and join the conversation with other students.</p>
|
<p>Create a free student account to see and join the conversation with other students.</p>
|
||||||
<Link to="/study/signup" className="btn-primary" style={{ marginTop: '1rem', display: 'inline-block' }}>Sign In</Link>
|
<div className="study-signup-actions">
|
||||||
|
<Link to="/study/signup" className="btn-primary">Create Account or Sign In</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -4344,22 +4377,16 @@ export function StudyDiscussionHubPage({ content }: Props) {
|
|||||||
{openDiscussionStudies.length === 0 ? (
|
{openDiscussionStudies.length === 0 ? (
|
||||||
<p className="study-detail-copy">There are no studies open for discussion yet. Check back when the next one starts.</p>
|
<p className="study-detail-copy">There are no studies open for discussion yet. Check back when the next one starts.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="study-module-list">
|
<div className="sdh-grid">
|
||||||
{openDiscussionStudies.map(study => {
|
{openDiscussionStudies.map(study => {
|
||||||
const enrolled = isEnrolledInStudy(auth, study.slug)
|
const enrolled = isEnrolledInStudy(auth, study.slug)
|
||||||
return (
|
return (
|
||||||
<article key={study.slug} className="study-module-row">
|
<Link key={study.slug} to={`/study/${study.slug}/community`} className="sdh-card">
|
||||||
<div className="study-module-row-left">
|
<span className={`sdh-badge${enrolled ? ' sdh-badge--on' : ''}`}>{enrolled ? 'Enrolled' : 'Open to all students'}</span>
|
||||||
<p className="study-module-lesson">{enrolled ? 'Enrolled' : 'Study track'}</p>
|
|
||||||
<h3>{study.title}</h3>
|
<h3>{study.title}</h3>
|
||||||
<p>{study.description || 'Join the discussion for this study track.'}</p>
|
<p>{study.description || 'Join the discussion for this study track.'}</p>
|
||||||
</div>
|
<span className="sdh-go">{enrolled ? 'Open discussion →' : 'View discussion →'}</span>
|
||||||
<div className="study-module-row-right" style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
|
||||||
<Link to={`/study/${study.slug}/community`} className="btn-primary">
|
|
||||||
{enrolled ? 'Open Discussion' : 'View Discussion'}
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -4389,20 +4416,24 @@ export function StudyCommunityPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!auth.checked) {
|
if (!auth.checked) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Loading">
|
<main className="auth-page" aria-label="Loading">
|
||||||
<div className="thanks-card"><p>Loading...</p></div>
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center"><p>Loading...</p></div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth.authenticated) {
|
if (!auth.authenticated) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Sign in required">
|
<main className="auth-page" aria-label="Sign in required">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Study Discussion</p>
|
<p className="eyebrow">Study Discussion</p>
|
||||||
<h1>Sign In Required</h1>
|
<h1>Sign In Required</h1>
|
||||||
<p>Create a free student account to see and join the conversation.</p>
|
<p>Create a free student account to see and join the conversation.</p>
|
||||||
<Link to="/study/signup" className="btn-primary" style={{ marginTop: '1rem', display: 'inline-block' }}>Sign In</Link>
|
<div className="study-signup-actions"><Link to="/study/signup" className="btn-primary">Sign In</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user