showcase 2in 1

This commit is contained in:
Mackie 2026-06-06 07:52:01 +08:00
parent 507a1b6d01
commit 3149b881ff

View file

@ -1,15 +1,17 @@
'use client' 'use client'
import React from 'react' import React, { useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { Container } from '@/components/ui/Container' import { Container } from '@/components/ui/Container'
import type { Media as MediaType } from '@/payload-types' import type { Media as MediaType } from '@/payload-types'
// Updated type to include category for the 15-project Lab structure
type ShowcaseItem = { type ShowcaseItem = {
image?: MediaType | null image?: MediaType | null
imageUrl?: string imageUrl?: string
title: string title: string
description?: string description?: string
category: 'engineering' | 'design'
tags?: { tag: string }[] tags?: { tag: string }[]
links?: { label: string; url: string; newTab?: boolean }[] links?: { label: string; url: string; newTab?: boolean }[]
} }
@ -20,125 +22,119 @@ type ShowcaseBlockProps = {
items?: ShowcaseItem[] items?: ShowcaseItem[]
} }
function ShowcaseImage(props: { item: ShowcaseItem }): React.ReactElement { function ShowcaseImage({ item }: { item: ShowcaseItem }): React.ReactElement {
const item = props.item
const image = item.image const image = item.image
const imageUrl = image != null && typeof image === 'object' && 'url' in image ? image.url : null const imageUrl = image != null && typeof image === 'object' && 'url' in image ? image.url : null
const imageAlt = const imageAlt =
image != null && typeof image === 'object' && 'alt' in image ? image.alt : item.title image != null && typeof image === 'object' && 'alt' in image ? image.alt : item.title
if (imageUrl != null) { const imgContent = (
const imgEl = ( <div className="relative w-full aspect-video overflow-hidden">
<div className="relative w-full aspect-video overflow-hidden"> {imageUrl ? (
<Image src={imageUrl} alt={imageAlt ?? item.title} fill className="object-cover" /> <Image src={imageUrl} alt={imageAlt ?? item.title} fill className="object-cover" />
{item.imageUrl && ( ) : (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center gap-2">
<i className="ti ti-photo-off text-foreground/15 text-2xl" aria-hidden="true" />
<span className="text-xs text-foreground/20">Coming soon</span>
</div>
)}
{imageUrl && (
<>
<div className="absolute inset-0 bg-background/0 group-hover:bg-background/20 transition-colors duration-200" /> <div className="absolute inset-0 bg-background/0 group-hover:bg-background/20 transition-colors duration-200" />
)}
{item.imageUrl && (
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"> <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<i <i className="ti ti-external-link text-foreground/80 text-xl" aria-hidden="true" />
className="ti ti-external-link text-foreground/80"
style={{ fontSize: 20 }}
aria-hidden="true"
/>
</div> </div>
)} </>
</div> )}
)
if (item.imageUrl) {
return (
<a href={item.imageUrl} target="_blank" rel="noopener noreferrer" className="block group">
{imgEl}
</a>
)
}
return imgEl
}
return (
<div className="relative w-full aspect-video bg-muted/30 flex flex-col items-center justify-center gap-2 border-b border-foreground/8">
<i
className="ti ti-photo-off text-foreground/15"
style={{ fontSize: 28 }}
aria-hidden="true"
/>
<span className="text-xs text-foreground/20">Coming soon</span>
</div> </div>
) )
return item.imageUrl ? (
<a href={item.imageUrl} target="_blank" rel="noopener noreferrer" className="block group">
{imgContent}
</a>
) : (
imgContent
)
} }
export function ShowcaseBlock(props: ShowcaseBlockProps): React.ReactElement | null { export function ShowcaseBlock(props: ShowcaseBlockProps): React.ReactElement | null {
const { heading, subheading, items } = props const { heading, subheading, items } = props
const [filter, setFilter] = useState<'all' | 'engineering' | 'design'>('all')
if (!Array.isArray(items) || items.length === 0) return null if (!Array.isArray(items) || items.length === 0) return null
const filteredItems = filter === 'all' ? items : items.filter((item) => item.category === filter)
return ( return (
// Replaced hardcoded max-w-5xl and section with the unified Container
<Container className="py-12"> <Container className="py-12">
{(heading || subheading) && ( <div className="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-4">
<div className="mb-8"> <div>
{heading && <h2 className="text-2xl font-medium text-foreground mb-2">{heading}</h2>} {heading && <h2 className="text-2xl font-medium text-foreground mb-2">{heading}</h2>}
{subheading && <p className="text-sm text-foreground/40">{subheading}</p>} {subheading && <p className="text-sm text-foreground/40">{subheading}</p>}
</div> </div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> {/* Filter Navigation */}
{items.map(function (item, i) { <div className="flex gap-2">
const isFeatured = i === 0 && items.length > 1 {(['all', 'engineering', 'design'] as const).map((f) => (
return ( <button
<div key={f}
key={i} onClick={() => setFilter(f)}
className={ className={`px-3 py-1 text-xs rounded-full border transition-colors ${
'bg-muted/50 border border-foreground/8 rounded-xl overflow-hidden flex flex-col' + filter === f
(isFeatured ? ' sm:col-span-2 lg:col-span-2' : '') ? 'bg-foreground text-background border-foreground'
} : 'bg-transparent border-foreground/8 text-foreground/60 hover:border-foreground/30'
}`}
> >
<ShowcaseImage item={item} /> {f.charAt(0).toUpperCase() + f.slice(1)}
<div className="p-4 flex flex-col gap-3 flex-1"> </button>
<h3 className="text-sm font-medium text-foreground/85">{item.title}</h3> ))}
{item.description && ( </div>
<p className="text-xs text-foreground/40 leading-relaxed">{item.description}</p> </div>
)}
{Array.isArray(item.tags) && item.tags.length > 0 && ( <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div className="flex flex-wrap gap-1.5"> {filteredItems.map((item, i) => (
{item.tags.map(function (t, j) { <div
return ( key={`${item.title}-${i}`}
<span className="bg-muted/50 border border-foreground/8 rounded-xl overflow-hidden flex flex-col"
key={j} >
className="text-xs text-foreground/40 bg-background/50 border border-foreground/8 rounded-full px-2.5 py-0.5" <ShowcaseImage item={item} />
> <div className="p-4 flex flex-col gap-3 flex-1">
{t.tag} <h3 className="text-sm font-medium text-foreground/85">{item.title}</h3>
</span> {item.description && (
) <p className="text-xs text-foreground/40 leading-relaxed">{item.description}</p>
})} )}
</div> {item.tags && item.tags.length > 0 && (
)} <div className="flex flex-wrap gap-1.5">
{Array.isArray(item.links) && item.links.length > 0 && ( {item.tags.map((t, j) => (
<div className="flex flex-wrap gap-3 mt-auto pt-3 border-t border-foreground/8"> <span
{item.links.map(function (link, k) { key={j}
return ( className="text-xs text-foreground/40 bg-background/50 border border-foreground/8 rounded-full px-2.5 py-0.5"
<a >
key={k} {t.tag}
href={link.url} </span>
target={link.newTab ? '_blank' : '_self'} ))}
rel="noopener noreferrer" </div>
className="flex items-center gap-1.5 text-xs text-foreground/40 hover:text-foreground/70 transition-colors" )}
> {item.links && item.links.length > 0 && (
<i <div className="flex flex-wrap gap-3 mt-auto pt-3 border-t border-foreground/8">
className="ti ti-external-link" {item.links.map((link, k) => (
style={{ fontSize: 12 }} <a
aria-hidden="true" key={k}
/> href={link.url}
{link.label} target={link.newTab ? '_blank' : '_self'}
</a> rel="noopener noreferrer"
) className="flex items-center gap-1.5 text-xs text-foreground/40 hover:text-foreground/70 transition-colors"
})} >
</div> <i className="ti ti-external-link text-[12px]" aria-hidden="true" />
)} {link.label}
</div> </a>
))}
</div>
)}
</div> </div>
) </div>
})} ))}
</div> </div>
</Container> </Container>
) )