From d06fcba60be2234e3b52d723f3badeaabcee22f6 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 12 Oct 2026 16:19:28 -0400 Subject: [PATCH] Resources page redesign: tabs + compact grids; download page redesign with site header, two-column card and a mobile-safe form; v2.26.0 Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 4 +- package.json | 2 +- src/App.css | 344 +++++++++++++------------------- src/App.tsx | 313 +++++------------------------ src/components/ResourcesHub.tsx | 211 ++++++++++++++++++++ 5 files changed, 406 insertions(+), 468 deletions(-) create mode 100644 src/components/ResourcesHub.tsx diff --git a/package-lock.json b/package-lock.json index ee12edc..c823d1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.25.2", + "version": "2.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.25.2", + "version": "2.26.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 1941818..298e32f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.25.2", + "version": "2.26.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.css b/src/App.css index 7b51d10..cbd85fb 100644 --- a/src/App.css +++ b/src/App.css @@ -5768,11 +5768,7 @@ a.study-track-card:hover { opacity: 0.9; } - .section-download-library { - background: - radial-gradient(circle at top center, rgba(201, 168, 76, 0.1), transparent 26%), - var(--brand-black); - } + .download-library-head { max-width: 760px; @@ -5788,74 +5784,21 @@ a.study-track-card:hover { margin: 0.85rem auto 0; } - .download-tag-filter { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - justify-content: center; - margin: 0 auto 2rem; - max-width: 760px; - } + - .download-tag-chip { - background: transparent; - border: 1px solid rgba(201, 168, 76, 0.3); - border-radius: 999px; - color: rgba(255, 255, 255, 0.65); - cursor: pointer; - font-family: var(--brand-font-body); - font-size: 0.88rem; - font-weight: 600; - letter-spacing: 0.03em; - padding: 0.35rem 1rem; - transition: background 140ms ease, border-color 140ms ease, color 140ms ease; - } + - .download-tag-chip:hover { - background: rgba(201, 168, 76, 0.1); - border-color: rgba(201, 168, 76, 0.55); - color: var(--brand-warm-white); - } + - .download-tag-chip--active { - background: rgba(201, 168, 76, 0.15); - border-color: var(--brand-gold); - color: var(--brand-gold); - } + - .download-clear-link { - background: none; - border: none; - cursor: pointer; - font-family: var(--brand-font-body); - font-size: 0.82rem; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--brand-muted); - padding: 0.35rem 0.5rem; - transition: color 140ms ease; - text-decoration: underline; - text-underline-offset: 3px; - } + - .download-clear-link:hover { - color: var(--brand-warm-white); - } + - .download-empty-state { - text-align: center; - color: var(--brand-muted); - font-family: var(--brand-font-body); - padding: 2rem 0; - } + - .download-filter-bar { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 2rem; - } + /* Related episodes on download detail pages */ .download-related-episodes { @@ -5987,118 +5930,36 @@ a.study-track-card:hover { padding: 4rem 0; } -.books-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 2rem; - margin-top: 2rem; -} -.book-card { - display: flex; - gap: 1.25rem; - background: rgba(255,255,255,0.03); - border: 1px solid rgba(201, 168, 76, 0.1); - border-radius: 8px; - padding: 1.25rem; -} -.book-card-cover { - width: 80px; - height: 110px; - object-fit: cover; - border-radius: 4px; - flex-shrink: 0; -} -.book-card-meta { - display: flex; - flex-direction: column; - gap: 0.5rem; -} -.book-card-title { - font-family: var(--brand-font-heading); - font-size: 1rem; - font-weight: 700; - color: var(--brand-warm-white); - line-height: 1.3; -} -.book-card-description { - font-family: var(--brand-font-body); - font-size: 0.875rem; - font-weight: 300; - color: var(--brand-muted); - line-height: 1.6; - margin: 0; -} -.book-card-btn { - align-self: flex-start; - font-size: 0.8rem; - padding: 0.4rem 0.9rem; -} + + + + + + + /* ── Recommended Resources section ── */ -.recommended-resources-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 1.25rem; - margin-top: 1rem; -} -.recommended-resource-card { - display: flex; - gap: 1.25rem; - align-items: flex-start; - text-decoration: none; - background: rgba(255,255,255,0.03); - border: 1px solid rgba(201, 168, 76, 0.1); - border-radius: 8px; - padding: 1.25rem; - transition: border-color 0.2s; -} -.recommended-resource-card:hover { - border-color: rgba(201, 168, 76, 0.35); -} -.recommended-resource-image { - width: 80px; - height: 80px; - object-fit: cover; - border-radius: 6px; - flex-shrink: 0; -} -.recommended-resource-meta { - display: flex; - flex-direction: column; - gap: 0.4rem; -} -.recommended-resource-title { - font-family: var(--brand-font-heading); - font-size: 1rem; - font-weight: 700; - color: var(--brand-warm-white); -} -.recommended-resource-description { - font-family: var(--brand-font-body); - font-size: 0.875rem; - font-weight: 300; - color: var(--brand-muted); - line-height: 1.6; - margin: 0; -} -.recommended-resource-action { - font-size: 0.85rem; - color: var(--brand-gold); - font-weight: 600; -} + + + + + + + + /* ── Interior page banner ── */ .section-page-banner { @@ -6132,18 +5993,9 @@ a.study-track-card:hover { } /* ── Featured download card ── */ -.resource-download-card--featured { - margin-bottom: 1.5rem; - border-color: rgba(201, 168, 76, 0.35); - background: rgba(201, 168, 76, 0.04); -} -.resource-link-image--featured { - width: 80px !important; - height: 110px !important; - border-radius: 6px !important; - object-fit: cover; -} + + .featured-badge { display: inline-block; @@ -6161,37 +6013,13 @@ a.study-track-card:hover { } /* ── Books + Recommended Resources tabs ── */ -.books-resources-tabs { - display: flex; - gap: 0.25rem; - margin-bottom: 2rem; - border-bottom: 1px solid rgba(201, 168, 76, 0.15); - padding-bottom: 0; -} -.books-resources-tab { - background: none; - border: none; - border-bottom: 2px solid transparent; - padding: 0.6rem 1.1rem; - margin-bottom: -1px; - font-family: var(--brand-font-body); - font-size: 0.92rem; - font-weight: 600; - color: rgba(255, 255, 255, 0.5); - cursor: pointer; - transition: color 160ms ease, border-color 160ms ease; - letter-spacing: 0.02em; -} -.books-resources-tab:hover { - color: rgba(255, 255, 255, 0.85); -} -.books-resources-tab--active { - color: var(--brand-gold); - border-bottom-color: var(--brand-gold); -} + + + + /* ── Study Guide section ── */ .section-guide { @@ -13834,3 +13662,115 @@ a.study-track-card:hover { font-size: 1.0625rem; } } + +/* ── Resources page (tabs + compact grids) ─────────────────────────────── */ +.res-hub { padding: 2rem 0 3.5rem; } +.res-tabs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.res-tab { + font: inherit; font-size: 0.95rem; cursor: pointer; padding: 0.5rem 1.1rem; border-radius: 999px; + color: var(--brand-muted); background: transparent; border: 1px solid var(--brand-border); + transition: border-color 150ms, color 150ms, background 150ms; +} +.res-tab span { opacity: 0.6; margin-left: 0.25rem; font-size: 0.85em; } +.res-tab:hover { border-color: var(--brand-gold-dark); color: var(--brand-warm-white); } +.res-tab--on, .res-tab--on:hover { background: var(--brand-gold); border-color: var(--brand-gold); color: var(--brand-black); } + +.res-featured { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-bottom: 1.75rem; } +.res-feature { + display: flex; gap: 1rem; align-items: center; padding: 1rem; text-decoration: none; color: inherit; + background: linear-gradient(135deg, rgba(201, 168, 76, 0.12), rgba(201, 168, 76, 0.03)); + border: 1px solid var(--brand-gold-dark); border-radius: 14px; transition: transform 150ms, border-color 150ms; +} +.res-feature:hover { transform: translateY(-2px); border-color: var(--brand-gold); } +.res-feature-img { width: 84px; height: 84px; flex-shrink: 0; object-fit: cover; border-radius: 10px; } +.res-feature-body { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; } +.res-badge { + align-self: flex-start; font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 700; + color: var(--brand-black); background: var(--brand-gold); padding: 0.1rem 0.55rem; border-radius: 999px; +} + +.res-tags { display: flex; gap: 0.45rem; flex-wrap: wrap; margin-bottom: 1.25rem; } +.res-chip { + font: inherit; font-size: 0.82rem; cursor: pointer; padding: 0.25rem 0.8rem; border-radius: 999px; + color: var(--brand-muted); background: transparent; border: 1px solid var(--brand-border); +} +.res-chip:hover { border-color: var(--brand-gold-dark); color: var(--brand-warm-white); } +.res-chip--on { background: rgba(201, 168, 76, 0.18); border-color: var(--brand-gold-dark); color: var(--brand-gold-light); } +.res-empty { color: var(--brand-muted); padding: 1rem 0; } + +.res-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; } +.res-card, .res-book { + display: flex; gap: 0.9rem; align-items: flex-start; padding: 0.95rem; text-decoration: none; color: inherit; + background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 12px; + transition: border-color 150ms, transform 150ms; +} +.res-card:hover { border-color: var(--brand-gold-dark); transform: translateY(-2px); } +.res-card-img { width: 64px; height: 64px; flex-shrink: 0; object-fit: cover; border-radius: 8px; } +.res-book-cover { width: 76px; flex-shrink: 0; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } +.res-card-body, .res-book-body { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; flex: 1; } + +.res-title { font-family: var(--brand-font-heading); font-size: 1.25rem; font-weight: 600; line-height: 1.25; color: var(--brand-warm-white); overflow-wrap: anywhere; } +.res-desc { margin: 0; font-size: 0.92rem; line-height: 1.55; color: var(--brand-muted); } +.res-clamped { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; } +.res-card-tags { font-size: 0.75rem; color: var(--brand-gold-dark); letter-spacing: 0.03em; } +.res-action { margin-top: 0.15rem; font-size: 0.85rem; color: var(--brand-gold); } +.res-more { align-self: flex-start; font: inherit; font-size: 0.82rem; padding: 0; background: none; border: 0; cursor: pointer; color: var(--brand-gold); text-decoration: underline; } +.res-book-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.4rem; } +.res-btn { font-size: 0.78rem !important; padding: 0.45rem 0.95rem !important; } + +@media (max-width: 600px) { + .res-hub { padding: 1.25rem 0 2.5rem; } + .res-tabs { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin-left: -0.25rem; padding: 0 0.25rem 0.25rem; } + .res-tab { flex-shrink: 0; } + .res-grid { grid-template-columns: 1fr; gap: 0.75rem; } + .res-feature { align-items: flex-start; } + .res-feature-img { width: 64px; height: 64px; } + .res-title { font-size: 1.1rem; } + .res-card, .res-book { padding: 0.8rem; gap: 0.75rem; } + .res-card-img { width: 52px; height: 52px; } + .res-book-cover { width: 64px; } + /* Shorter previews on phones; the tag chips above already cover tags */ + .res-card .res-clamped, .res-feature .res-clamped { -webkit-line-clamp: 2 !important; } + .res-card-tags { display: none; } +} + +/* ── Download page ─────────────────────────────────────────────────────── */ +.dl-page { padding: 1.5rem 0 3.5rem; } +.dl-back { display: inline-block; margin-bottom: 1rem; font-size: 0.9rem; color: var(--brand-muted); text-decoration: none; } +.dl-back:hover { color: var(--brand-gold); } +.dl-card { + display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 2rem; align-items: start; + background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 16px; padding: 1.75rem; +} +.dl-art img { width: 100%; max-height: 320px; object-fit: contain; border-radius: 10px; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); } +.dl-body { min-width: 0; } +.dl-body .eyebrow { margin: 0 0 0.35rem; } +#root > :not(.admin-shell) .dl-card h1 { font-size: clamp(1.7rem, 3.2vw, 2.25rem); line-height: 1.15; margin: 0 0 0.75rem; } +.dl-summary { margin: 0 0 0.9rem; font-size: 1rem; line-height: 1.65; color: var(--brand-muted); white-space: pre-line; } +.dl-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1.1rem; } +.dl-tag { font-size: 0.78rem; padding: 0.15rem 0.7rem; border-radius: 999px; border: 1px solid var(--brand-gold-dark); color: var(--brand-gold-light); } +.dl-form .study-download-form { margin-top: 0.4rem; } +.dl-form .study-download-grid label:nth-child(3) { grid-column: 1 / -1; } +/* The consent line is a sentence, not a field label */ +.dl-form .study-download-form label.contact-consent { + flex-direction: row; align-items: flex-start; gap: 0.6rem; + text-transform: none; letter-spacing: normal; font-size: 0.9rem; font-weight: 400; color: var(--brand-muted); +} +.dl-form .study-download-form label.contact-consent input { margin-top: 0.25rem; flex-shrink: 0; } +.dl-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-top: 1.1rem; font-size: 0.88rem; color: var(--brand-muted); } +.dl-actions .btn-secondary { padding: 0.5rem 1.1rem; font-size: 0.8rem; } + +@media (max-width: 700px) { + .dl-page { padding: 1rem 0 2.5rem; } + .dl-card { grid-template-columns: minmax(0, 1fr); gap: 1rem; padding: 1.1rem; } + .dl-art { display: flex; justify-content: center; } + .dl-art img { width: auto; max-width: 150px; max-height: 200px; } + .dl-form .study-download-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0.7rem; } + .dl-form .study-download-form { padding: 0.85rem; } + .dl-form .study-download-form input[type='text'], + .dl-form .study-download-form input[type='email'] { min-width: 0; width: 100%; } +} + +@media (max-width: 480px) { + .dl-form .study-download-grid { grid-template-columns: minmax(0, 1fr); } +} diff --git a/src/App.tsx b/src/App.tsx index 919bec2..6a0370d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { GlobalSearch } from './components/GlobalSearch' import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer' import { NuggetsHostPortal } from './components/NuggetsHostPortal' import { HeaderAccount } from './components/HeaderAccount' +import { ResourcesHub } from './components/ResourcesHub' import { BIBLE_BOOKS } from '../shared/bible-books.js' import './App.css' import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking, checkAnalyticsConsentResponse } from './analytics' @@ -854,131 +855,6 @@ function PageBanner({ eyebrow, title, desc }: { eyebrow: string; title: string; ) } -function DownloadLibrarySection({ content }: { content: SiteContent }) { - const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources') - const [activeTag, setActiveTag] = useState(null) - - const hasPrimaryGuide = !!(content.studyGuideTitle) - const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean) - - const filteredResources = resources.filter(r => { - return !activeTag || (r.tags ?? []).includes(activeTag) - }) - - if (!hasPrimaryGuide && !content.secondSeries?.guide?.title && resources.length === 0) return null - - return ( -
-
- {hasPrimaryGuide && ( - -
- {content.seriesTitle} -
-
Featured
- {content.studyGuideTitle} - {content.studyGuideDescription && ( -

{content.studyGuideDescription}

- )} - Download free guide → -
-
- - )} - - {content.secondSeries?.guide?.title && ( - -
- {content.secondSeries.imageUrl && ( - {content.secondSeries.title} - )} -
-
Featured
- {content.secondSeries.guide.title} - {content.secondSeries.guide.description && ( -

{content.secondSeries.guide.description}

- )} - Download free guide → -
-
- - )} - - {resources.length > 0 && ( - <> - {allTags.length > 0 && ( -
-
- - {allTags.map(tag => ( - - ))} - {activeTag !== null && ( - - )} -
-
- )} - - {filteredResources.length > 0 ? ( -
- {filteredResources.map(resource => ( - -
- {resource.imageUrl && ( - {resource.label} - )} -
- {resource.label} - {resource.description &&

{resource.description}

} - {(resource.tags ?? []).length > 0 && ( -
{(resource.tags ?? []).join(', ')}
- )} - Open download page → -
-
- - ))} -
- ) : ( -

No resources tagged "{activeTag}".

- )} - - )} -
-
- ) -} - function DownloadDetailPage({ content }: { content: SiteContent }) { const { id } = useParams<{ id: string }>() const resource = resolveDownloadPageResource(content, id) @@ -1002,44 +878,57 @@ function DownloadDetailPage({ content }: { content: SiteContent }) { } return ( -
-
-

Resources

-

{resource.label}

-

{resource.summary}

+
+ + +
+
+ ← Back to Resources +
+ {resource.imageUrl && ( +
+ {resource.label} +
+ )} +
+

Free download

+

{resource.label}

+ {resource.summary &&

{resource.summary}

} + {resource.tags.length > 0 && ( +
+ {resource.tags.map(tag => {tag})} +
+ )} - {resource.imageUrl && ( -
- {resource.label} -
- )} +
+ {/* The title is already on the page, so the button stays short */} + {resource.isPrimaryGuide + ? + : } +
- {resource.tags.length > 0 && ( -
{resource.tags.join(', ')}
- )} - -
- {resource.isPrimaryGuide - ? - : } + {(resource.amazonUrl || resource.appleBooksUrl) && ( +
+ Or get it from: + {resource.amazonUrl && ( + + {resource.amazonLabel || 'Get it on Amazon'} + + )} + {resource.appleBooksUrl && ( + + {resource.appleBooksLabel || 'Get it on Apple Books'} + + )} +
+ )} +
+
- -
- {resource.amazonUrl && ( - - {resource.amazonLabel || 'Get it on Amazon'} - - )} - {resource.appleBooksUrl && ( - - {resource.appleBooksLabel || 'Get it on Apple Books'} - - )} - Back to Resources -
- -
-
+ + + + ) } @@ -2341,107 +2230,6 @@ function NuggetsHostDetailPage({ content }: { content: SiteContent }) { ) } -function BooksAndResourcesSection({ content }: { content: SiteContent }) { - const books = (content.customLinks ?? []).filter(link => link.placement === 'books') - const recommended = (content.customLinks ?? []).filter(link => link.placement === 'recommended') - const [activeTab, setActiveTab] = useState<'books' | 'recommended'>('books') - - const hasBooks = books.length > 0 - const hasRecommended = recommended.length > 0 - const showTabs = hasBooks && hasRecommended - - if (!hasBooks && !hasRecommended) return - - const tab = showTabs ? activeTab : (hasBooks ? 'books' : 'recommended') - - return ( -
-
- {showTabs ? ( -
- - -
- ) : ( -
-

{tab === 'books' ? 'Reading List' : 'Recommended'}

-

{tab === 'books' ? 'Books worth your time.' : 'Resources worth your time.'}

-
- )} - - {tab === 'books' && ( - <> - {showTabs && ( -
-

Books worth your time.

-
- )} -
- {books.map(book => ( -
- {book.imageUrl && ( - {book.label} - )} -
- {book.label} - {book.description &&

{book.description}

} - {(book.amazonUrl || book.url) && ( - - {book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')} - - )} - {book.appleBooksUrl && ( - - {book.appleBooksLabel || 'Get it on Apple Books'} - - )} -
-
- ))} -
- - )} - - {tab === 'recommended' && ( - <> - {showTabs && ( -
-

Recommended Resources

-
- )} - - - )} -
-
- ) -} - function ResourcesPage({ content }: { content: SiteContent }) { const siteTitle = content.seo?.title || DEFAULTS.seo.title usePageMeta( @@ -2453,9 +2241,8 @@ function ResourcesPage({ content }: { content: SiteContent }) { - + `/downloads/${buildCustomDownloadPageId(id)}`} fallback={} /> - diff --git a/src/components/ResourcesHub.tsx b/src/components/ResourcesHub.tsx new file mode 100644 index 0000000..9daaaeb --- /dev/null +++ b/src/components/ResourcesHub.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { Link } from 'react-router-dom' +import type { SiteContent } from '../content' + +type TabKey = 'downloads' | 'books' | 'recommended' + +interface Props { + content: SiteContent + downloadPath: (resourceId: string) => string + // Shown when there are no downloads, books or recommended resources at all + fallback: ReactNode +} + +// A description that stays short in the grid; long ones get a Read more toggle +function ClampedText({ text, className, lines = 3, threshold = 150 }: { text: string; className: string; lines?: number; threshold?: number }) { + const [open, setOpen] = useState(false) + const long = text.length > threshold + return ( + <> +

+ {text} +

+ {long && ( + + )} + + ) +} + +export function ResourcesHub({ content, downloadPath, fallback }: Props) { + const links = content.customLinks ?? [] + const resources = links.filter(l => l.placement === 'resources') + const books = links.filter(l => l.placement === 'books') + const recommended = links.filter(l => l.placement === 'recommended') + + const featured: { key: string; to: string; title: string; description: string; image: string; alt: string }[] = [] + if (content.studyGuideTitle) { + featured.push({ + key: 'primary', + to: '/downloads/primary-guide', + title: content.studyGuideTitle, + description: content.studyGuideDescription ?? '', + image: content.seriesImageUrl || '/images/titus-cover.png', + alt: content.seriesTitle, + }) + } + if (content.secondSeries?.guide?.title) { + featured.push({ + key: 'second', + to: '/downloads/second-guide', + title: content.secondSeries.guide.title, + description: content.secondSeries.guide.description ?? '', + image: content.secondSeries.imageUrl ?? '', + alt: content.secondSeries.title, + }) + } + + const counts: Record = { + downloads: featured.length + resources.length, + books: books.length, + recommended: recommended.length, + } + const available = (Object.keys(counts) as TabKey[]).filter(k => counts[k] > 0) + + const fromHash = (): TabKey | null => { + const h = typeof window !== 'undefined' ? window.location.hash.replace('#', '') : '' + return h === 'downloads' || h === 'books' || h === 'recommended' ? h : null + } + const [picked, setPicked] = useState(fromHash) + const [activeTag, setActiveTag] = useState(null) + + // Links like /resources#books, and the browser's back/forward buttons, switch the tab too + useEffect(() => { + const onHash = () => setPicked(fromHash()) + window.addEventListener('hashchange', onHash) + return () => window.removeEventListener('hashchange', onHash) + }, []) + + if (available.length === 0) return <>{fallback} + + const tab: TabKey = picked && counts[picked] > 0 ? picked : available[0] + const choose = (k: TabKey) => { + setPicked(k) + try { window.history.replaceState(null, '', `#${k}`) } catch { /* ignore */ } + } + + const tags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean) + const shownResources = resources.filter(r => !activeTag || (r.tags ?? []).includes(activeTag)) + const labels: Record = { downloads: 'Downloads', books: 'Reading List', recommended: 'Recommended' } + + return ( +
+
+ {available.length > 1 && ( +
+ {available.map(k => ( + + ))} +
+ )} + +
+ {tab === 'downloads' && ( + <> + {featured.length > 0 && ( +
+ {featured.map(f => ( + + {f.image && {f.alt}} +
+ Featured + {f.title} + {f.description &&

{f.description}

} + Download free guide → +
+ + ))} +
+ )} + + {tags.length > 0 && ( +
+ + {tags.map(t => ( + + ))} +
+ )} + + {shownResources.length > 0 ? ( +
+ {shownResources.map(r => ( + + {r.imageUrl && {r.label}} +
+ {r.label} + {r.description &&

{r.description}

} + {(r.tags ?? []).length > 0 && {(r.tags ?? []).join(' · ')}} + Open download → +
+ + ))} +
+ ) : ( + resources.length > 0 &&

No downloads tagged "{activeTag}".

+ )} + + )} + + {tab === 'books' && ( +
+ {books.map(book => ( +
+ {book.imageUrl && {book.label}} +
+ {book.label} + {book.description && } + +
+
+ ))} +
+ )} + + {tab === 'recommended' && ( + + )} +
+
+
+ ) +}