Publish Container / build (push) Successful in 15m46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
227 lines
9.5 KiB
TypeScript
227 lines
9.5 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import type { ReactNode } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import type { SiteContent } from '../content'
|
|
import { StoreLink } from './StoreLink'
|
|
|
|
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 ?? []
|
|
// Ask for name and email before sending visitors to Amazon / Apple Books (on unless switched off in the admin)
|
|
const gated = content.gateStoreLinks !== false
|
|
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) && (
|
|
<StoreLink
|
|
source={`book:${book.id}`}
|
|
store="amazon"
|
|
href={book.amazonUrl || book.url}
|
|
gated={gated}
|
|
className="btn-primary res-btn"
|
|
>
|
|
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
|
|
</StoreLink>
|
|
)}
|
|
{book.appleBooksUrl && (
|
|
<StoreLink
|
|
source={`book:${book.id}`}
|
|
store="apple"
|
|
href={book.appleBooksUrl}
|
|
gated={gated}
|
|
className="btn-secondary res-btn"
|
|
>
|
|
{book.appleBooksLabel || 'Apple Books'}
|
|
</StoreLink>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|