Add Season Check page and dashboard card: per-lane readiness (season, study, lessons, highlights, feed); v2.20.0
Publish Container / build (push) Has been cancelled
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
77bce2ae0c
commit
50b0e83c2b
8 files changed
+282
-5
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"version": "2.19.0",
|
||||
"version": "2.20.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "siteforge",
|
||||
"version": "2.19.0",
|
||||
"version": "2.20.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1135.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.19.0",
|
||||
"version": "2.20.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -67,6 +67,7 @@ import { register as registerNuggets } from './server/routes/nuggets.js'
|
||||
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
|
||||
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
|
||||
import { register as registerPodcastWorkflow } from './server/routes/podcast-workflow.js'
|
||||
import { register as registerSeasonCheck } from './server/season-check.js'
|
||||
import { warmFeedSnapshot } from './server/feed-release.js'
|
||||
import { preferIpv4ForSeafile } from './server/seafile-dns.js'
|
||||
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
||||
@@ -129,6 +130,7 @@ registerNuggets(app)
|
||||
registerNuggetsHostUpload(app)
|
||||
registerNuggetsWorkflow(app)
|
||||
registerPodcastWorkflow(app)
|
||||
registerSeasonCheck(app)
|
||||
registerFeed(app)
|
||||
registerQrCodes(app)
|
||||
registerBiblePassage(app)
|
||||
|
||||
@@ -22,7 +22,7 @@ function refreshSnapshot() {
|
||||
return inflight
|
||||
}
|
||||
|
||||
async function getFeedIndex() {
|
||||
export async function getFeedIndex() {
|
||||
if (!snapshot.at) await refreshSnapshot()
|
||||
else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Resend } from 'resend'
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { buildSeasonCheck } from '../season-check.js'
|
||||
import { state } from '../state.js'
|
||||
import { getStorageStatus, loadUserProgress } from '../data.js'
|
||||
import { getStudyCatalog } from '../study-helpers.js'
|
||||
@@ -219,7 +220,16 @@ export function register(app) {
|
||||
buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(),
|
||||
])
|
||||
const comments = state.studyComments ?? []
|
||||
const seasonCheck = await buildSeasonCheck()
|
||||
.then(c => ({
|
||||
total: c.total,
|
||||
errors: c.errors,
|
||||
warnings: c.warnings,
|
||||
lanes: c.lanes.map(l => ({ lane: l.lane, title: l.title, season: l.season, errors: l.errors, warnings: l.warnings })),
|
||||
}))
|
||||
.catch(() => null)
|
||||
res.json({
|
||||
seasonCheck,
|
||||
trends: buildTrends(),
|
||||
traffic: buildTopTraffic(),
|
||||
reengagement: buildReengagement(),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { state } from './state.js'
|
||||
import { requireAdminAuth } from './auth.js'
|
||||
import { scriptKey } from './episode-script-keys.js'
|
||||
import { getFeedIndex } from './feed-release.js'
|
||||
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
|
||||
|
||||
// One report per lane (A = primary series, B = second series) listing what is set up, what is
|
||||
// missing, and an episode-by-episode grid. Reads the working draft so problems show up before
|
||||
// publishing. Read-only: it never changes anything.
|
||||
|
||||
const err = (message, view) => ({ severity: 'error', message, view })
|
||||
const warn = (message, view) => ({ severity: 'warn', message, view })
|
||||
|
||||
function laneConfig(content, lane) {
|
||||
if (lane === 'A') {
|
||||
return {
|
||||
title: String(content.seriesTitle ?? '').trim(),
|
||||
studySlug: String(content.seriesStudySlug ?? '').trim(),
|
||||
highlightLane: 'primary',
|
||||
}
|
||||
}
|
||||
const second = content.secondSeries ?? {}
|
||||
return {
|
||||
title: String(second.title ?? '').trim(),
|
||||
studySlug: String(second.studySlug ?? '').trim(),
|
||||
highlightLane: 'second',
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildSeasonCheck() {
|
||||
const content = state.cachedDraftSiteContent ?? state.cachedSiteContent ?? {}
|
||||
const [wf, index] = await Promise.all([loadPodcastWorkflow(), getFeedIndex()])
|
||||
const seasons = { A: laneSeasonOf('A', wf, content), B: laneSeasonOf('B', wf, content) }
|
||||
|
||||
const studies = Array.isArray(content.studies) ? content.studies : []
|
||||
const highlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||
const planned = new Set(wf.planned.map(p => scriptKey(p.season, p.episodeNumber)))
|
||||
|
||||
const lanes = []
|
||||
for (const lane of ['A', 'B']) {
|
||||
const cfg = laneConfig(content, lane)
|
||||
// Lane B only exists once a second series has been set up
|
||||
if (lane === 'B' && !cfg.title && !cfg.studySlug && !seasons.B) continue
|
||||
|
||||
const season = seasons[lane]
|
||||
const problems = []
|
||||
if (!cfg.title) problems.push(warn(`Lane ${lane} has no series title.`, 'podcast:current-series'))
|
||||
if (!season) problems.push(err(`Lane ${lane} has no season number. Highlights and lesson audio can't match the feed without it.`, 'podcast:workflow'))
|
||||
if (season && lane === 'B' && season === seasons.A) problems.push(err(`Lanes A and B both use season ${season}.`, 'podcast:workflow'))
|
||||
|
||||
const study = studies.find(s => s?.lane === lane) ?? studies.find(s => s?.slug && s.slug === cfg.studySlug)
|
||||
if (!study) problems.push(warn(`No study is linked to Lane ${lane}. Set the study's lane, or link it in Current Series.`, 'colossians-study'))
|
||||
else if (study.lane !== lane) problems.push(warn(`"${study.title}" is linked to Lane ${lane} in Current Series but the study's own lane isn't set to ${lane}.`, 'colossians-study'))
|
||||
|
||||
const sections = Array.isArray(study?.sections) ? study.sections : []
|
||||
const laneHighlights = highlights.filter(h => (h.lane ?? 'primary') === cfg.highlightLane)
|
||||
|
||||
const noNumber = sections.filter(s => !String(s.episodeNumber ?? '').trim())
|
||||
if (study && noNumber.length > 0) {
|
||||
problems.push(warn(`${noNumber.length} lesson${noNumber.length === 1 ? '' : 's'} in "${study.title}" ${noNumber.length === 1 ? 'has' : 'have'} no episode number, so ${noNumber.length === 1 ? 'it' : 'they'} can't pick up audio.`, 'colossians-study'))
|
||||
}
|
||||
const noDate = sections.filter(s => String(s.episodeNumber ?? '').trim() && !s.releasedAt)
|
||||
if (study && noDate.length > 0) {
|
||||
problems.push(warn(`${noDate.length} lesson${noDate.length === 1 ? '' : 's'} with an episode number ${noDate.length === 1 ? 'has' : 'have'} no release date.`, 'colossians-study'))
|
||||
}
|
||||
const noHighlightNumber = laneHighlights.filter(h => !String(h.episodeNumber ?? '').trim())
|
||||
if (noHighlightNumber.length > 0) {
|
||||
problems.push(warn(`${noHighlightNumber.length} highlight${noHighlightNumber.length === 1 ? '' : 's'} in Lane ${lane} ${noHighlightNumber.length === 1 ? 'has' : 'have'} no episode number.`, 'podcast:highlights'))
|
||||
}
|
||||
|
||||
// Episode numbers to show: every number any part of the season mentions, plus 1..lesson count
|
||||
const numbers = new Set()
|
||||
for (let n = 1; n <= sections.length; n++) numbers.add(n)
|
||||
const addNumber = v => { const n = Number(v); if (Number.isInteger(n) && n > 0) numbers.add(n) }
|
||||
sections.forEach(s => addNumber(s.episodeNumber))
|
||||
laneHighlights.forEach(h => addNumber(h.episodeNumber))
|
||||
for (const [key, item] of index) if (season && key.startsWith(`${season}-`)) addNumber(item.episode)
|
||||
for (const p of wf.planned) if (season && String(p.season) === season) addNumber(p.episodeNumber)
|
||||
for (const key of Object.keys(state.episodeScripts ?? {})) if (season && key.startsWith(`${season}-`)) addNumber(key.split('-')[1])
|
||||
|
||||
const episodes = [...numbers].sort((a, b) => a - b).map(n => {
|
||||
const num = String(n)
|
||||
const feedItem = season ? index.get(`${season}-${num}`) : undefined
|
||||
const released = Boolean(feedItem)
|
||||
const lessons = sections.filter(s => String(s.episodeNumber ?? '').trim() === num)
|
||||
const hl = laneHighlights.filter(h => String(h.episodeNumber ?? '').trim() === num)
|
||||
const lessonHasAudio = lessons.some(s => s.audioEmbedUrl?.trim() || (released && feedItem?.audioUrl))
|
||||
|
||||
if (lessons.length > 1) problems.push(warn(`Episode ${num}: ${lessons.length} lessons share this episode number.`, 'colossians-study'))
|
||||
if (hl.length > 1) problems.push(warn(`Episode ${num}: ${hl.length} highlights share this episode number.`, 'podcast:highlights'))
|
||||
if (season && !released && hl.some(h => !h.holdUntilReleased && h.lane !== 'general')) {
|
||||
problems.push(warn(`Episode ${num}: the highlight will show on the site before the episode is out. Turn on "Publish when the episode is released".`, 'podcast:highlights'))
|
||||
}
|
||||
|
||||
return {
|
||||
number: n,
|
||||
released,
|
||||
releaseDate: feedItem?.pubDate ? new Date(feedItem.pubDate).toISOString().slice(0, 10) : '',
|
||||
workflowStarted: season ? planned.has(scriptKey(season, num)) : false,
|
||||
script: season ? Boolean(state.episodeScripts?.[scriptKey(season, num)]) : false,
|
||||
lesson: lessons.length > 0,
|
||||
lessonAudio: lessons.length > 0 ? lessonHasAudio : null,
|
||||
highlight: hl.length > 0,
|
||||
highlightHeld: hl.length > 0 ? hl.every(h => h.holdUntilReleased === true) : null,
|
||||
}
|
||||
})
|
||||
|
||||
lanes.push({
|
||||
lane,
|
||||
title: cfg.title,
|
||||
season,
|
||||
studyTitle: study?.title ?? '',
|
||||
problems,
|
||||
errors: problems.filter(p => p.severity === 'error').length,
|
||||
warnings: problems.filter(p => p.severity === 'warn').length,
|
||||
episodes,
|
||||
})
|
||||
}
|
||||
|
||||
const total = lanes.reduce((n, l) => n + l.problems.length, 0)
|
||||
return { lanes, total, errors: lanes.reduce((n, l) => n + l.errors, 0), warnings: lanes.reduce((n, l) => n + l.warnings, 0) }
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin/season-check', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
res.json(await buildSeasonCheck())
|
||||
} catch (err) {
|
||||
console.error('[season-check] failed:', err)
|
||||
res.status(500).json({ message: 'Could not build the season check.' })
|
||||
}
|
||||
})
|
||||
}
|
||||
+110
-1
@@ -730,7 +730,7 @@ type AdminView =
|
||||
| 'start-here'
|
||||
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
||||
| 'contact:profile' | 'contact:copy'
|
||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | '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'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||
| 'inbox'
|
||||
@@ -778,6 +778,7 @@ const ADMIN_NAV: NavGroup[] = [
|
||||
group: 'Content',
|
||||
items: [
|
||||
{ kind: 'parent', id: 'podcast', label: 'Podcast Hub', children: [
|
||||
{ kind: 'leaf', view: 'podcast:season-check', label: 'Season Check' },
|
||||
{ kind: 'leaf', view: 'podcast:current-series', label: 'Current Series' },
|
||||
{ kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' },
|
||||
{ kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' },
|
||||
@@ -2122,6 +2123,113 @@ function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; lane
|
||||
)
|
||||
}
|
||||
|
||||
// ── Season Check (everything a season needs, per lane) ─────────────────────
|
||||
interface SeasonCheckProblem { severity: 'error' | 'warn'; message: string; view: string }
|
||||
interface SeasonCheckEpisode {
|
||||
number: number
|
||||
released: boolean
|
||||
releaseDate: string
|
||||
workflowStarted: boolean
|
||||
script: boolean
|
||||
lesson: boolean
|
||||
lessonAudio: boolean | null
|
||||
highlight: boolean
|
||||
highlightHeld: boolean | null
|
||||
}
|
||||
interface SeasonCheckLane {
|
||||
lane: 'A' | 'B'
|
||||
title: string
|
||||
season: string
|
||||
studyTitle: string
|
||||
problems: SeasonCheckProblem[]
|
||||
errors: number
|
||||
warnings: number
|
||||
episodes: SeasonCheckEpisode[]
|
||||
}
|
||||
|
||||
function SeasonCheckAdmin({ onNavigate }: { onNavigate: (view: AdminView) => void }) {
|
||||
const [report, setReport] = useState<{ lanes: SeasonCheckLane[]; total: number } | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchReport = () =>
|
||||
fetch('/api/admin/season-check')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('failed'))))
|
||||
.then(d => { setReport(d); setError('') })
|
||||
.catch(() => setError('Could not run the season check.'))
|
||||
.finally(() => setLoading(false))
|
||||
useEffect(() => { fetchReport() }, [])
|
||||
|
||||
const yes = <span style={{ color: '#4a9c5d' }} aria-label="yes">✓</span>
|
||||
const no = <span style={{ opacity: 0.35 }} aria-label="no">–</span>
|
||||
const cell = { padding: '4px 8px', textAlign: 'center' as const }
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="Season check">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Podcast — Season Check</h2>
|
||||
<p>Everything a season needs, per lane. This reads your saved draft, so it catches gaps before you publish. Nothing here changes anything.</p>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => { setLoading(true); fetchReport() }} disabled={loading}>{loading ? 'Checking…' : 'Check again'}</button>
|
||||
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
|
||||
{report && report.lanes.length === 0 && <p className="admin-stats-note">No lanes are set up yet.</p>}
|
||||
{report?.lanes.map(l => (
|
||||
<div key={l.lane} style={{ marginTop: 20 }}>
|
||||
<h3 style={{ marginBottom: 4 }}>
|
||||
Lane {l.lane}{l.title ? ` — ${l.title}` : ''}
|
||||
</h3>
|
||||
<p className="admin-stats-note" style={{ marginTop: 0 }}>
|
||||
{l.season ? `Season ${l.season}` : 'No season number'}{l.studyTitle ? ` · Study: ${l.studyTitle}` : ' · No study linked'}
|
||||
</p>
|
||||
{l.problems.length === 0 ? (
|
||||
<p style={{ color: '#4a9c5d' }}>✓ Nothing to fix.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '8px 0 12px' }}>
|
||||
{l.problems.map((p, i) => (
|
||||
<li key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '5px 0' }}>
|
||||
<span style={{ color: p.severity === 'error' ? '#c0392b' : '#b8860b' }}>{p.severity === 'error' ? '●' : '▲'}</span>
|
||||
<span style={{ flex: 1 }}>{p.message}</span>
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate(p.view as AdminView)}>Fix →</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{l.episodes.length > 0 && (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
|
||||
<th style={{ ...cell, textAlign: 'left' }}>Ep</th>
|
||||
<th style={cell}>Workflow</th>
|
||||
<th style={cell}>Script</th>
|
||||
<th style={cell}>Lesson</th>
|
||||
<th style={cell}>Lesson audio</th>
|
||||
<th style={cell}>Highlight</th>
|
||||
<th style={cell}>Live on Spotify</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{l.episodes.map(e => (
|
||||
<tr key={e.number} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
|
||||
<td style={{ ...cell, textAlign: 'left' }}><strong>{e.number}</strong></td>
|
||||
<td style={cell}>{e.released ? 'Released' : e.workflowStarted ? 'In progress' : no}</td>
|
||||
<td style={cell}>{e.script ? yes : no}</td>
|
||||
<td style={cell}>{e.lesson ? yes : no}</td>
|
||||
<td style={cell}>{e.lessonAudio === null ? no : e.lessonAudio ? yes : no}</td>
|
||||
<td style={cell}>{e.highlight ? (e.highlightHeld ? 'Waits for release' : yes) : no}</td>
|
||||
<td style={cell}>{e.released ? <>{yes} {e.releaseDate}</> : no}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const normalizedContent = normalizeSiteContentForAdmin(content)
|
||||
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
||||
@@ -6334,6 +6442,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
)}
|
||||
|
||||
{/* EPISODE SCRIPTS */}
|
||||
{adminView === 'podcast:season-check' && <SeasonCheckAdmin onNavigate={setAdminView} />}
|
||||
{adminView === 'podcast:workflow' && <PodcastWorkflowAdmin laneA={{ title: form.seriesTitle }} laneB={{ title: form.secondSeries?.title ?? '' }} />}
|
||||
{adminView === 'podcast:scripts' && (
|
||||
<section className="admin-panel-section">
|
||||
|
||||
@@ -4,6 +4,7 @@ interface Trend { thisWeek: number; lastWeek: number; series: number[] }
|
||||
interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null }
|
||||
|
||||
export interface DashboardData {
|
||||
seasonCheck: { total: number; errors: number; warnings: number; lanes: { lane: string; title: string; season: string; errors: number; warnings: number }[] } | null
|
||||
trends: { visits: Trend; subscribers: Trend; studySignups: Trend }
|
||||
traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] }
|
||||
reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number }
|
||||
@@ -90,6 +91,7 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
|
||||
|
||||
const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = []
|
||||
if (data) {
|
||||
if (data.seasonCheck && data.seasonCheck.total > 0) attention.push({ key: 'season-check', label: `Season check: ${data.seasonCheck.total} thing${data.seasonCheck.total === 1 ? '' : 's'} to fix`, tone: 'alert', action: () => onNavigate('podcast:season-check'), actionLabel: 'Open Season Check' })
|
||||
if (data.comments.pending > 0) attention.push({ key: 'comments', label: `${data.comments.pending} study comment${data.comments.pending === 1 ? '' : 's'} awaiting approval`, tone: 'alert', action: () => onNavigate('study-comments'), actionLabel: 'Review' })
|
||||
if (data.nuggets.uploadsThisWeek > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.uploadsThisWeek} Nuggets upload${data.nuggets.uploadsThisWeek === 1 ? '' : 's'} in the last 7 days`, tone: 'alert', action: () => onNavigate('nuggets:history'), actionLabel: 'View uploads' })
|
||||
if (data.latestEpisode.available && data.latestEpisode.stale) attention.push({ key: 'feed', label: `Podcast feed has no new episode in ${data.latestEpisode.daysAgo} days`, tone: 'alert', action: () => onNavigate('podcast:rss'), actionLabel: 'Check feed' })
|
||||
@@ -137,6 +139,27 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.seasonCheck && data.seasonCheck.lanes.length > 0 && (
|
||||
<>
|
||||
<h3 className="admin-dashboard-section-title">Season check</h3>
|
||||
<div className="admin-dashboard-grid">
|
||||
{data.seasonCheck.lanes.map(l => {
|
||||
const issues = l.errors + l.warnings
|
||||
return (
|
||||
<div key={l.lane} className="admin-dashboard-card">
|
||||
<div className="admin-dashboard-card-value">{issues === 0 ? '✓' : issues}</div>
|
||||
<div className="admin-dashboard-card-label">Lane {l.lane}{l.title ? ` — ${l.title}` : ''}</div>
|
||||
<div className="admin-dashboard-card-sub">
|
||||
{l.season ? `Season ${l.season}` : 'No season number set'} · {issues === 0 ? 'all set' : `${issues} to fix`}
|
||||
</div>
|
||||
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('podcast:season-check')}>Season Check →</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className="admin-dashboard-section-title">Trends</h3>
|
||||
<div className="admin-dashboard-grid">
|
||||
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />
|
||||
|
||||
Reference in new issue
Block a user