This commit is contained in:
Mackie 2026-06-03 12:26:04 +08:00
parent a30184d5a9
commit 7108096ad7
6 changed files with 270 additions and 3 deletions

View file

@ -31,9 +31,6 @@ export function KanbanColorBlock({ columns }: Props) {
<div className="flex items-center gap-2 mb-1">
<span className={`w-2 h-2 rounded-full shrink-0 ${dot}`} />
<span className="text-sm font-medium text-foreground/80">{col.title}</span>
<span className="ml-auto text-xs text-foreground/30 border border-foreground/10 rounded-full px-2 py-0.5">
{col.cards?.length ?? 0}
</span>
</div>
{Array.isArray(col.cards) &&
col.cards.map((card, j) => (

View file

@ -11,6 +11,7 @@ import { SkillsBlock } from '@/blocks/Skills/Component'
import { SkillsMarqueeBlock } from '@/blocks/SkillsMarquee/Component'
import { KanbanColorBlock } from '@/blocks/KanbanColor/Component'
import { KanbanHoriBlock } from '@/blocks/KanbanHori/Component'
import { ShowcaseBlock } from '@/blocks/Showcase/Component'
const blockComponents = {
archive: ArchiveBlock,
@ -22,6 +23,7 @@ const blockComponents = {
skillsMarquee: SkillsMarqueeBlock,
kanbanColor: KanbanColorBlock,
kanbanHori: KanbanHoriBlock,
showcase: ShowcaseBlock,
}
export const RenderBlocks: React.FC<{

View file

@ -0,0 +1,136 @@
import React from 'react'
import Image from 'next/image'
type MediaObject = {
url?: string
alt?: string
width?: number
height?: number
}
type ShowcaseItem = {
image?: MediaObject | null
imageUrl?: string
title: string
description?: string
tags?: { tag: string }[]
links?: { label: string; url: string; newTab?: boolean }[]
}
type ShowcaseBlockProps = {
heading?: string
subheading?: string
items?: ShowcaseItem[]
}
const COMING_SOON_BG = 'bg-muted/50'
export function ShowcaseBlock({ heading, subheading, items }: ShowcaseBlockProps) {
if (!Array.isArray(items) || items.length === 0) return null
return (
<section className="w-full max-w-5xl mx-auto px-6 py-16">
{(heading || subheading) && (
<div className="text-center mb-12">
{heading && (
<h2 className="text-3xl font-semibold tracking-tight text-foreground mb-3">
{heading}
</h2>
)}
{subheading && (
<p className="text-sm text-foreground/50 max-w-xl mx-auto">{subheading}</p>
)}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((item, i) => {
const imageUrl =
item.image && typeof item.image === 'object' ? item.image.url : null
const imageAlt =
item.image && typeof item.image === 'object' ? item.image.alt : item.title
const imageContent = imageUrl ? (
<Image
src={imageUrl}
alt={imageAlt ?? item.title}
fill
className="object-cover"
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<i className="ti ti-photo-off text-foreground/15" style={{ fontSize: 32 }} aria-hidden="true" />
<span className="text-xs text-foreground/20">Coming soon</span>
</div>
)
const imageWrapper = item.imageUrl ? (
href={item.imageUrl}
target="_blank"
rel="noopener noreferrer"
className="block relative w-full aspect-video overflow-hidden rounded-t-xl group"
>
{imageContent}
<div className="absolute inset-0 bg-background/0 group-hover:bg-background/20 transition-colors duration-200" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<i className="ti ti-external-link text-foreground/80" style={{ fontSize: 20 }} aria-hidden="true" />
</div>
</a>
) : (
<div className={`relative w-full aspect-video overflow-hidden rounded-t-xl ${!imageUrl ? COMING_SOON_BG : ''}`}>
{imageContent}
</div>
)
return (
<div
key={i}
className="bg-muted/50 border-0 rounded-xl overflow-hidden flex flex-col"
>
{imageWrapper}
<div className="p-4 flex flex-col gap-3 flex-1">
<h3 className="text-sm font-medium text-foreground/85">{item.title}</h3>
{item.description && (
<p className="text-xs text-foreground/50 leading-relaxed">{item.description}</p>
)}
{Array.isArray(item.tags) && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{item.tags.map(({ tag }, j) => (
<span
key={j}
className="text-xs text-foreground/50 bg-background/50 border border-foreground/10 rounded-full px-2.5 py-0.5"
>
{tag}
</span>
))}
</div>
)}
{Array.isArray(item.links) && item.links.length > 0 && (
<div className="flex flex-wrap gap-3 mt-auto pt-2 border-t border-foreground/8">
{item.links.map((link, k) => (
key={k}
href={link.url}
target={link.newTab ? '_blank' : '_self'}
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-foreground/50 hover:text-foreground/80 transition-colors"
>
<i className="ti ti-external-link" style={{ fontSize: 13 }} aria-hidden="true" />
{link.label}
</a>
))}
</div>
)}
</div>
</div>
)
})}
</div>
</section>
)
}

View file

@ -0,0 +1,69 @@
import type { Block } from 'payload'
export const ShowcaseBlock: Block = {
slug: 'showcase',
labels: { singular: 'Showcase', plural: 'Showcases' },
fields: [
{
name: 'heading',
type: 'text',
label: 'Heading',
},
{
name: 'subheading',
type: 'text',
label: 'Subheading',
},
{
name: 'items',
type: 'array',
label: 'Items',
minRows: 1,
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'media',
label: 'Image (optional)',
},
{
name: 'imageUrl',
type: 'text',
label: 'Image click URL (optional)',
},
{
name: 'title',
type: 'text',
label: 'Title',
required: true,
},
{
name: 'description',
type: 'textarea',
label: 'Description',
},
{
name: 'tags',
type: 'array',
label: 'Tags',
fields: [{ name: 'tag', type: 'text', label: 'Tag', required: true }],
},
{
name: 'links',
type: 'array',
label: 'Links',
fields: [
{ name: 'label', type: 'text', label: 'Label', required: true },
{ name: 'url', type: 'text', label: 'URL', required: true },
{
name: 'newTab',
type: 'checkbox',
label: 'Open in new tab',
defaultValue: true,
},
],
},
],
},
],
}

View file

@ -11,6 +11,7 @@ import { SkillsBlock } from '../../blocks/Skills/config'
import { SkillsMarqueeBlock } from '../../blocks/SkillsMarquee/config'
import { KanbanColorBlock } from '../../blocks/KanbanColor/config'
import { KanbanHoriBlock } from '../../blocks/KanbanHori/config'
import { ShowcaseBlock } from '../../blocks/Showcase/config'
import { hero } from '@/heros/config'
import { slugField } from 'payload'
import { populatePublishedAt } from '../../hooks/populatePublishedAt'
@ -83,6 +84,7 @@ export const Pages: CollectionConfig<'pages'> = {
SkillsMarqueeBlock,
KanbanColorBlock,
KanbanHoriBlock,
ShowcaseBlock,
],
required: true,
admin: {

View file

@ -299,6 +299,36 @@ export interface Page {
blockName?: string | null;
blockType: 'kanbanHori';
}
| {
heading?: string | null;
subheading?: string | null;
items?:
| {
image?: (string | null) | Media;
imageUrl?: string | null;
title: string;
description?: string | null;
tags?:
| {
tag: string;
id?: string | null;
}[]
| null;
links?:
| {
label: string;
url: string;
newTab?: boolean | null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'showcase';
}
)[];
meta?: {
title?: string | null;
@ -1262,6 +1292,37 @@ export interface PagesSelect<T extends boolean = true> {
id?: T;
blockName?: T;
};
showcase?:
| T
| {
heading?: T;
subheading?: T;
items?:
| T
| {
image?: T;
imageUrl?: T;
title?: T;
description?: T;
tags?:
| T
| {
tag?: T;
id?: T;
};
links?:
| T
| {
label?: T;
url?: T;
newTab?: T;
id?: T;
};
id?: T;
};
id?: T;
blockName?: T;
};
};
meta?:
| T