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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9268514219
commit
d06fcba60b
5 files changed
+406
-468
No files matched your search
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.25.2",
|
||||
"version": "2.26.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+142
-202
@@ -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); }
|
||||
}
|
||||
+50
-263
@@ -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<string | null>(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 (
|
||||
<section className="section-resources section-download-library" aria-label="Download library">
|
||||
<div className="section-inner">
|
||||
{hasPrimaryGuide && (
|
||||
<Link to="/downloads/primary-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
|
||||
<div className="resource-download-header">
|
||||
<img
|
||||
src={content.seriesImageUrl || '/images/titus-cover.png'}
|
||||
alt={content.seriesTitle}
|
||||
className="resource-link-image resource-link-image--featured"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="resource-download-meta">
|
||||
<div className="featured-badge">Featured</div>
|
||||
<span className="resource-link-label">{content.studyGuideTitle}</span>
|
||||
{content.studyGuideDescription && (
|
||||
<p className="resource-link-description">{content.studyGuideDescription}</p>
|
||||
)}
|
||||
<span className="resource-link-action">Download free guide →</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{content.secondSeries?.guide?.title && (
|
||||
<Link to="/downloads/second-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
|
||||
<div className="resource-download-header">
|
||||
{content.secondSeries.imageUrl && (
|
||||
<img
|
||||
src={content.secondSeries.imageUrl}
|
||||
alt={content.secondSeries.title}
|
||||
className="resource-link-image resource-link-image--featured"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="resource-download-meta">
|
||||
<div className="featured-badge">Featured</div>
|
||||
<span className="resource-link-label">{content.secondSeries.guide.title}</span>
|
||||
{content.secondSeries.guide.description && (
|
||||
<p className="resource-link-description">{content.secondSeries.guide.description}</p>
|
||||
)}
|
||||
<span className="resource-link-action">Download free guide →</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{resources.length > 0 && (
|
||||
<>
|
||||
{allTags.length > 0 && (
|
||||
<div className="download-filter-bar">
|
||||
<div className="download-tag-filter">
|
||||
<button
|
||||
type="button"
|
||||
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
|
||||
onClick={() => setActiveTag(null)}
|
||||
>
|
||||
All topics
|
||||
</button>
|
||||
{allTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
|
||||
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{activeTag !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className="download-clear-link"
|
||||
onClick={() => setActiveTag(null)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredResources.length > 0 ? (
|
||||
<div className="resources-list">
|
||||
{filteredResources.map(resource => (
|
||||
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
|
||||
<div className="resource-download-header">
|
||||
{resource.imageUrl && (
|
||||
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" loading="lazy" />
|
||||
)}
|
||||
<div className="resource-download-meta">
|
||||
<span className="resource-link-label">{resource.label}</span>
|
||||
{resource.description && <p className="resource-link-description">{resource.description}</p>}
|
||||
{(resource.tags ?? []).length > 0 && (
|
||||
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
|
||||
)}
|
||||
<span className="resource-link-action">Open download page →</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="download-empty-state">No resources tagged "{activeTag}".</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<main className="thanks-page download-detail-page" aria-label={resource.label}>
|
||||
<div className="thanks-card download-detail-card">
|
||||
<p className="eyebrow">Resources</p>
|
||||
<h1>{resource.label}</h1>
|
||||
<p>{resource.summary}</p>
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<main className="dl-page" aria-label={resource.label}>
|
||||
<div className="section-inner">
|
||||
<Link to="/resources" className="dl-back">← Back to Resources</Link>
|
||||
<article className="dl-card">
|
||||
{resource.imageUrl && (
|
||||
<div className="dl-art">
|
||||
<img src={resource.imageUrl} alt={resource.label} />
|
||||
</div>
|
||||
)}
|
||||
<div className="dl-body">
|
||||
<p className="eyebrow">Free download</p>
|
||||
<h1>{resource.label}</h1>
|
||||
{resource.summary && <p className="dl-summary">{resource.summary}</p>}
|
||||
{resource.tags.length > 0 && (
|
||||
<div className="dl-tags">
|
||||
{resource.tags.map(tag => <span key={tag} className="dl-tag">{tag}</span>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resource.imageUrl && (
|
||||
<div className="download-detail-art">
|
||||
<img src={resource.imageUrl} alt={resource.label} className="guide-cover-img" />
|
||||
</div>
|
||||
)}
|
||||
<div className="dl-form">
|
||||
{/* The title is already on the page, so the button stays short */}
|
||||
{resource.isPrimaryGuide
|
||||
? <StudyDownloadForm buttonText="Download now" />
|
||||
: <ResourceDownloadForm resourceId={resource.resourceId} buttonText="Download now" />}
|
||||
</div>
|
||||
|
||||
{resource.tags.length > 0 && (
|
||||
<div className="resource-link-tags">{resource.tags.join(', ')}</div>
|
||||
)}
|
||||
|
||||
<div className="download-detail-form-wrap">
|
||||
{resource.isPrimaryGuide
|
||||
? <StudyDownloadForm buttonText={resource.buttonText} />
|
||||
: <ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />}
|
||||
{(resource.amazonUrl || resource.appleBooksUrl) && (
|
||||
<div className="dl-actions">
|
||||
<span>Or get it from:</span>
|
||||
{resource.amazonUrl && (
|
||||
<a href={resource.amazonUrl} target="_blank" rel="noreferrer" className="btn-secondary">
|
||||
{resource.amazonLabel || 'Get it on Amazon'}
|
||||
</a>
|
||||
)}
|
||||
{resource.appleBooksUrl && (
|
||||
<a href={resource.appleBooksUrl} target="_blank" rel="noreferrer" className="btn-secondary">
|
||||
{resource.appleBooksLabel || 'Get it on Apple Books'}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="download-detail-actions">
|
||||
{resource.amazonUrl && (
|
||||
<a href={resource.amazonUrl} target="_blank" rel="noreferrer" className="btn-secondary">
|
||||
{resource.amazonLabel || 'Get it on Amazon'}
|
||||
</a>
|
||||
)}
|
||||
{resource.appleBooksUrl && (
|
||||
<a href={resource.appleBooksUrl} target="_blank" rel="noreferrer" className="btn-secondary">
|
||||
{resource.appleBooksLabel || 'Get it on Apple Books'}
|
||||
</a>
|
||||
)}
|
||||
<Link to="/resources" className="btn-secondary">Back to Resources</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
<SiteFooter content={content} />
|
||||
<AnalyticsConsentBanner content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <TruthForLifeWidget type="resourcead" />
|
||||
|
||||
const tab = showTabs ? activeTab : (hasBooks ? 'books' : 'recommended')
|
||||
|
||||
return (
|
||||
<section className="section-resources section-books-resources" aria-label="Books and recommended resources">
|
||||
<div className="section-inner">
|
||||
{showTabs ? (
|
||||
<div className="books-resources-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`books-resources-tab${tab === 'books' ? ' books-resources-tab--active' : ''}`}
|
||||
onClick={() => setActiveTab('books')}
|
||||
>
|
||||
Reading List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`books-resources-tab${tab === 'recommended' ? ' books-resources-tab--active' : ''}`}
|
||||
onClick={() => setActiveTab('recommended')}
|
||||
>
|
||||
Recommended Resources
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="download-library-head">
|
||||
<p className="eyebrow">{tab === 'books' ? 'Reading List' : 'Recommended'}</p>
|
||||
<h2 className="section-heading">{tab === 'books' ? 'Books worth your time.' : 'Resources worth your time.'}</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'books' && (
|
||||
<>
|
||||
{showTabs && (
|
||||
<div className="download-library-head" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2 className="section-heading">Books worth your time.</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="books-list">
|
||||
{books.map(book => (
|
||||
<div key={book.id} className="book-card">
|
||||
{book.imageUrl && (
|
||||
<img src={book.imageUrl} alt={book.label} className="book-card-cover" loading="lazy" />
|
||||
)}
|
||||
<div className="book-card-meta">
|
||||
<span className="book-card-title">{book.label}</span>
|
||||
{book.description && <p className="book-card-description">{book.description}</p>}
|
||||
{(book.amazonUrl || book.url) && (
|
||||
<a href={book.amazonUrl || book.url} target="_blank" rel="noopener noreferrer" className="btn-primary book-card-btn">
|
||||
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
|
||||
</a>
|
||||
)}
|
||||
{book.appleBooksUrl && (
|
||||
<a href={book.appleBooksUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary book-card-btn">
|
||||
{book.appleBooksLabel || 'Get it on Apple Books'}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'recommended' && (
|
||||
<>
|
||||
{showTabs && (
|
||||
<div className="download-library-head" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2 className="section-heading">Recommended Resources</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="recommended-resources-list">
|
||||
{recommended.map(item => (
|
||||
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="recommended-resource-card">
|
||||
{item.imageUrl && (
|
||||
<img src={item.imageUrl} alt={item.label} className="recommended-resource-image" loading="lazy" />
|
||||
)}
|
||||
<div className="recommended-resource-meta">
|
||||
<span className="recommended-resource-title">{item.label}</span>
|
||||
{item.description && <p className="recommended-resource-description">{item.description}</p>}
|
||||
<span className="recommended-resource-action">Learn more →</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ResourcesPage({ content }: { content: SiteContent }) {
|
||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||
usePageMeta(
|
||||
@@ -2453,9 +2241,8 @@ function ResourcesPage({ content }: { content: SiteContent }) {
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<PageBanner eyebrow="Resources" title="Study Guides & Resources" desc="Free guides, worksheets, and curated resources to help you go deeper in Scripture." />
|
||||
<DownloadLibrarySection content={content} />
|
||||
<ResourcesHub content={content} downloadPath={id => `/downloads/${buildCustomDownloadPageId(id)}`} fallback={<TruthForLifeWidget type="resourcead" />} />
|
||||
<CustomBlocksSection content={content} page="downloads" />
|
||||
<BooksAndResourcesSection content={content} />
|
||||
<SiteFooter content={content} />
|
||||
<AnalyticsConsentBanner content={content} />
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<p
|
||||
className={`${className}${long && !open ? ' res-clamped' : ''}`}
|
||||
style={long && !open ? { WebkitLineClamp: lines } : undefined}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{long && (
|
||||
<button type="button" className="res-more" onClick={() => setOpen(o => !o)} aria-expanded={open}>
|
||||
{open ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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<TabKey, number> = {
|
||||
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<TabKey | null>(fromHash)
|
||||
const [activeTag, setActiveTag] = useState<string | null>(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<TabKey, string> = { downloads: 'Downloads', books: 'Reading List', recommended: 'Recommended' }
|
||||
|
||||
return (
|
||||
<section className="res-hub" aria-label="Resources">
|
||||
<div className="section-inner">
|
||||
{available.length > 1 && (
|
||||
<div className="res-tabs" role="tablist" aria-label="Resource types">
|
||||
{available.map(k => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`res-tab-${k}`}
|
||||
aria-selected={tab === k}
|
||||
aria-controls="res-panel"
|
||||
className={`res-tab${tab === k ? ' res-tab--on' : ''}`}
|
||||
onClick={() => choose(k)}
|
||||
>
|
||||
{labels[k]} <span>{counts[k]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div id="res-panel" role="tabpanel" aria-labelledby={`res-tab-${tab}`}>
|
||||
{tab === 'downloads' && (
|
||||
<>
|
||||
{featured.length > 0 && (
|
||||
<div className="res-featured">
|
||||
{featured.map(f => (
|
||||
<Link key={f.key} to={f.to} className="res-feature">
|
||||
{f.image && <img src={f.image} alt={f.alt} className="res-feature-img" loading="lazy" />}
|
||||
<div className="res-feature-body">
|
||||
<span className="res-badge">Featured</span>
|
||||
<span className="res-title">{f.title}</span>
|
||||
{f.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 2 }}>{f.description}</p>}
|
||||
<span className="res-action">Download free guide →</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="res-tags" role="group" aria-label="Filter by topic">
|
||||
<button type="button" className={`res-chip${activeTag === null ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(null)}>All topics</button>
|
||||
{tags.map(t => (
|
||||
<button key={t} type="button" className={`res-chip${activeTag === t ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(prev => (prev === t ? null : t))}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shownResources.length > 0 ? (
|
||||
<div className="res-grid">
|
||||
{shownResources.map(r => (
|
||||
<Link key={r.id} to={downloadPath(r.id)} className="res-card">
|
||||
{r.imageUrl && <img src={r.imageUrl} alt={r.label} className="res-card-img" loading="lazy" />}
|
||||
<div className="res-card-body">
|
||||
<span className="res-title">{r.label}</span>
|
||||
{r.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{r.description}</p>}
|
||||
{(r.tags ?? []).length > 0 && <span className="res-card-tags">{(r.tags ?? []).join(' · ')}</span>}
|
||||
<span className="res-action">Open download →</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
resources.length > 0 && <p className="res-empty">No downloads tagged "{activeTag}".</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'books' && (
|
||||
<div className="res-grid">
|
||||
{books.map(book => (
|
||||
<div key={book.id} className="res-book">
|
||||
{book.imageUrl && <img src={book.imageUrl} alt={book.label} className="res-book-cover" loading="lazy" />}
|
||||
<div className="res-book-body">
|
||||
<span className="res-title">{book.label}</span>
|
||||
{book.description && <ClampedText text={book.description} className="res-desc" lines={3} />}
|
||||
<div className="res-book-actions">
|
||||
{(book.amazonUrl || book.url) && (
|
||||
<a href={book.amazonUrl || book.url} target="_blank" rel="noopener noreferrer" className="btn-primary res-btn">
|
||||
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
|
||||
</a>
|
||||
)}
|
||||
{book.appleBooksUrl && (
|
||||
<a href={book.appleBooksUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary res-btn">
|
||||
{book.appleBooksLabel || 'Apple Books'}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'recommended' && (
|
||||
<div className="res-grid">
|
||||
{recommended.map(item => (
|
||||
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="res-card">
|
||||
{item.imageUrl && <img src={item.imageUrl} alt={item.label} className="res-card-img" loading="lazy" />}
|
||||
<div className="res-card-body">
|
||||
<span className="res-title">{item.label}</span>
|
||||
{item.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{item.description}</p>}
|
||||
<span className="res-action">Visit →</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user