'use client'
import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useState } from 'react'
export interface Content05Item {
image: string
title: string
description: string
defaultFocus?: boolean
}
export interface Content05Props {
title?: string
description?: string
items: Content05Item[]
dimUnfocused?: boolean
descriptionOnFocus?: boolean
}
// ─── Constants ───────────────────────────────────────────────────────────────
const BREAKPOINTS = { md: 768, lg: 1024 } as const
const COLS = { sm: 1, md: 2, lg: 3 } as const
const GAP_REM = 1
// ─── Hooks ───────────────────────────────────────────────────────────────────
function useColsPerRow(): number | undefined {
const [cols, setCols] = useState<number | undefined>(undefined)
useEffect(() => {
const update = () => {
const width = window.innerWidth
if (width >= BREAKPOINTS.lg) setCols(COLS.lg)
else if (width >= BREAKPOINTS.md) setCols(COLS.md)
else setCols(COLS.sm)
}
update()
window.addEventListener('resize', update)
return () => window.removeEventListener('resize', update)
}, [])
return cols
}
// ─── Utilities ───────────────────────────────────────────────────────────────
function chunk<T>(array: T[], size: number): T[][] {
return Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
array.slice(i * size, i * size + size),
)
}
function getItemWidth(
isFocused: boolean,
rowSize: number,
hasRowFocus: boolean,
): string {
if (rowSize === 1) return '100%'
const gapTotal = (rowSize - 1) * GAP_REM
if (!hasRowFocus) return `calc((100% - ${gapTotal}rem) / ${rowSize})`
const focusedFraction = 2 / (rowSize + 1)
const otherFraction = (1 - focusedFraction) / (rowSize - 1)
const fraction = isFocused ? focusedFraction : otherFraction
return `calc((100% - ${gapTotal}rem) * ${fraction})`
}
// ─── ItemCard ─────────────────────────────────────────────────────────────────
type ItemCardProps = Readonly<{
item: Content05Item
isFocused?: boolean
dimmed?: boolean
showDescription?: boolean
width?: string
onActivate?: () => void
}>
function ItemCard({
item,
isFocused = false,
dimmed = false,
showDescription = true,
width,
onActivate,
}: ItemCardProps) {
return (
<button
type="button"
onClick={onActivate}
aria-pressed={isFocused}
className="group flex shrink-0 cursor-pointer flex-col rounded-xl text-left transition-[width,opacity] duration-500 ease-in-out focus:ring-0 focus:outline-none"
style={{
...(width !== undefined && { width }),
...(dimmed && { opacity: 0.4 }),
}}
>
<div className="relative h-64 w-full">
<img
src={item.image}
alt={item.title}
loading="lazy"
decoding="async"
className="group-focus-visible:ring-ring/50 absolute inset-0 size-full rounded-xl object-cover group-focus-visible:ring-2 group-focus-visible:outline-none"
/>
</div>
<div className="py-2">
<h3 className="line-clamp-1 truncate font-semibold">{item.title}</h3>
<AnimatePresence initial={false}>
{showDescription && (
<motion.div
key="description-wrapper"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{
height: 0,
transition: {
duration: 0.15,
ease: [0.22, 1, 0.36, 1],
},
}}
transition={{
duration: 0.35,
ease: [0.22, 1, 0.36, 1],
delay: 0.35,
}}
className="overflow-hidden"
>
<motion.p
initial={{ opacity: 0, filter: 'blur(6px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
exit={{
opacity: 0,
filter: 'blur(6px)',
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
}}
transition={{
duration: 0.35,
ease: [0.22, 1, 0.36, 1],
delay: 0.4,
}}
className="text-muted-foreground mt-1 line-clamp-1 text-sm"
>
{item.description}
</motion.p>
</motion.div>
)}
</AnimatePresence>
</div>
</button>
)
}
// ─── Content05 ────────────────────────────────────────────────────────────────
export function Content05({
title,
description,
items,
dimUnfocused = false,
descriptionOnFocus = false,
}: Readonly<Content05Props>) {
const [focusedIndex, setFocusedIndex] = useState(() =>
items.findIndex((item) => item.defaultFocus),
)
const colsPerRow = useColsPerRow()
const isMobile = colsPerRow === COLS.sm
const rows = colsPerRow === undefined ? [] : chunk(items, colsPerRow)
const hasFocusedItem = focusedIndex !== -1
const toggle = (index: number) =>
setFocusedIndex((prev) => (prev === index ? -1 : index))
if (colsPerRow === undefined) return null
if (isMobile) {
return (
<div className="grid grid-cols-1 gap-4">
{items.map((item) => (
<ItemCard key={item.title} item={item} />
))}
</div>
)
}
return (
<div className="flex flex-col gap-4">
{(title || description) && (
<div className="flex flex-col gap-1">
{title && <h2 className="text-2xl font-bold">{title}</h2>}
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
)}
<div className="flex flex-col gap-4">
{rows.map((row, rowIndex) => {
const rowStartIndex = rowIndex * colsPerRow
const hasRowFocus =
focusedIndex >= rowStartIndex &&
focusedIndex < rowStartIndex + row.length
return (
<div key={rowIndex} className="flex gap-4">
{row.map((item, colIndex) => {
const globalIndex = rowStartIndex + colIndex
const isFocused = focusedIndex === globalIndex
return (
<ItemCard
key={item.title}
item={item}
isFocused={isFocused}
dimmed={
dimUnfocused &&
hasFocusedItem &&
focusedIndex !== globalIndex
}
showDescription={descriptionOnFocus ? isFocused : true}
width={getItemWidth(isFocused, row.length, hasRowFocus)}
onActivate={() => toggle(globalIndex)}
/>
)
})}
</div>
)
})}
</div>
</div>
)
}