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
@@ -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