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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 16:19:28 -04:00
1 parent 9268514219
commit d06fcba60b
5 files changed
+406 -468

No files matched your search

+211
View File
@@ -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 &quot;{activeTag}&quot;.</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>
)
}