newformat

This commit is contained in:
Mackie 2026-06-03 15:48:31 +08:00
parent 8df66d3245
commit 04c62332b1
16 changed files with 820 additions and 90 deletions

View file

@ -0,0 +1,97 @@
import React from 'react'
type Skill = {
icon: string
title: string
tags: string
}
type BentoRowBlockProps = {
aboutHeading?: string
aboutText?: string
aboutCta?: { label?: string; url?: string; newTab?: boolean }
skillsHeading?: string
skills?: Skill[]
}
export function BentoRowBlock({
aboutHeading,
aboutText,
aboutCta,
skillsHeading,
skills,
}: BentoRowBlockProps) {
return (
<section className="w-full max-w-5xl mx-auto px-6 py-16">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-muted/50 border border-foreground/8 rounded-xl p-6 flex flex-col justify-between gap-6">
<div>
{aboutHeading && (
<p className="text-xs tracking-widest uppercase text-foreground/30 mb-4">
{aboutHeading}
</p>
)}
{aboutText && (
<p className="text-sm text-foreground/50 leading-relaxed whitespace-pre-line">
{aboutText}
</p>
)}
</div>
{aboutCta?.label && aboutCta?.url && (
href={aboutCta.url}
target={aboutCta.newTab ? '_blank' : '_self'}
rel="noopener noreferrer"
className="self-start flex items-center gap-2 text-xs text-foreground/50 border border-foreground/10 rounded-lg px-4 py-2 hover:text-foreground/80 hover:bg-muted transition-colors"
>
<i className="ti ti-download" style={{ fontSize: 13 }} aria-hidden="true" />
{aboutCta.label}
</a>
)}
</div>
<div className="bg-muted/50 border border-foreground/8 rounded-xl p-6">
{skillsHeading && (
<p className="text-xs tracking-widest uppercase text-foreground/30 mb-4">
{skillsHeading}
</p>
)}
{Array.isArray(skills) && skills.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{skills.map((skill, i) => {
const tags = skill.tags
? skill.tags.split(',').map((t) => t.trim()).filter(Boolean)
: []
return (
<div
key={i}
className="bg-background/50 border border-foreground/8 rounded-lg p-3 flex flex-col gap-2"
>
<i
className={`ti ${skill.icon} text-foreground/30`}
style={{ fontSize: 16 }}
aria-hidden="true"
/>
<p className="text-xs font-medium text-foreground/70">{skill.title}</p>
<div className="flex flex-wrap gap-1">
{tags.map((tag, j) => (
<span
key={j}
className="text-xs text-foreground/30 border border-foreground/8 rounded-full px-2 py-0.5"
>
{tag}
</span>
))}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,50 @@
import type { Block } from 'payload'
export const BentoRowBlock: Block = {
slug: 'bentoRow',
labels: { singular: 'Bento Row (About + Skills)', plural: 'Bento Rows' },
fields: [
{
name: 'aboutHeading',
type: 'text',
label: 'About heading',
},
{
name: 'aboutText',
type: 'textarea',
label: 'About text',
},
{
name: 'aboutCta',
type: 'group',
label: 'About CTA button',
fields: [
{ name: 'label', type: 'text', label: 'Label', defaultValue: 'Download CV' },
{ name: 'url', type: 'text', label: 'URL' },
{ name: 'newTab', type: 'checkbox', label: 'Open in new tab', defaultValue: true },
],
},
{
name: 'skillsHeading',
type: 'text',
label: 'Skills heading',
defaultValue: 'Expertise',
},
{
name: 'skills',
type: 'array',
label: 'Skill cards',
maxRows: 6,
fields: [
{
name: 'icon',
type: 'text',
label: 'Tabler icon name (e.g. ti-components)',
required: true,
},
{ name: 'title', type: 'text', label: 'Title', required: true },
{ name: 'tags', type: 'text', label: 'Tags (comma separated)', required: true },
],
},
],
}

View file

@ -0,0 +1,84 @@
import React from 'react'
type ContactLink = {
label: string
sublabel?: string
url: string
icon?: string
newTab?: boolean
}
type ContactBlockProps = {
heading?: string
subtext?: string
email?: string
links?: ContactLink[]
}
export function ContactBlock({
heading,
subtext,
email,
links,
}: ContactBlockProps) {
return (
<section className="w-full max-w-5xl mx-auto px-6 py-16">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-muted/50 border border-foreground/8 rounded-xl p-6 flex flex-col justify-between gap-6">
<div>
{heading && (
<h2 className="text-xl font-medium text-foreground mb-3">{heading}</h2>
)}
{subtext && (
<p className="text-sm text-foreground/50 leading-relaxed">{subtext}</p>
)}
</div>
{email && (
href={`mailto:${email}`}
className="self-start flex items-center gap-2 text-sm font-medium text-foreground/70 bg-background/50 border border-foreground/10 rounded-lg px-5 py-2.5 hover:text-foreground hover:bg-muted transition-colors"
>
<i className="ti ti-mail" style={{ fontSize: 14 }} aria-hidden="true" />
Get in touch
</a>
)}
</div>
<div className="flex flex-col gap-2">
{Array.isArray(links) && links.map((link, i) => (
key={i}
href={link.url}
target={link.newTab ? '_blank' : '_self'}
rel="noopener noreferrer"
className="flex items-center gap-3 bg-muted/50 border border-foreground/8 rounded-xl px-5 py-4 hover:bg-muted/70 transition-colors group"
>
{link.icon && (
<i
className={`ti ${link.icon} text-foreground/40 group-hover:text-foreground/60 transition-colors`}
style={{ fontSize: 18 }}
aria-hidden="true"
/>
)}
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground/70 group-hover:text-foreground/90 transition-colors">
{link.label}
</span>
{link.sublabel && (
<span className="text-xs text-foreground/30">{link.sublabel}</span>
)}
</div>
<i
className="ti ti-arrow-right text-foreground/20 group-hover:text-foreground/40 transition-colors ml-auto"
style={{ fontSize: 14 }}
aria-hidden="true"
/>
</a>
))}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,37 @@
import type { Block } from 'payload'
export const ContactBlock: Block = {
slug: 'contact',
labels: { singular: 'Contact', plural: 'Contacts' },
fields: [
{
name: 'heading',
type: 'text',
label: 'Heading',
defaultValue: "Let's work together",
},
{
name: 'subtext',
type: 'textarea',
label: 'Subtext',
},
{
name: 'email',
type: 'text',
label: 'Email address',
},
{
name: 'links',
type: 'array',
label: 'Links',
maxRows: 4,
fields: [
{ name: 'label', type: 'text', label: 'Label', required: true },
{ name: 'sublabel', type: 'text', label: 'Sub label' },
{ name: 'url', type: 'text', label: 'URL', required: true },
{ name: 'icon', type: 'text', label: 'Tabler icon (e.g. ti-brand-github)' },
{ name: 'newTab', type: 'checkbox', label: 'Open in new tab', defaultValue: true },
],
},
],
}

View file

@ -13,6 +13,10 @@ import { KanbanColorBlock } from '@/blocks/KanbanColor/Component'
import { KanbanHoriBlock } from '@/blocks/KanbanHori/Component'
import { ShowcaseBlock } from '@/blocks/Showcase/Component'
import { AboutProfileBlock } from '@/blocks/AboutProfile/Component'
import { StatsStripBlock } from '@/blocks/StatsStrip/Component'
import { BentoRowBlock } from '@/blocks/BentoRow/Component'
import { ContactBlock } from '@/blocks/Contact/Component'
import { ToolStackBlock } from '@/blocks/ToolStack/Component'
const blockComponents = {
archive: ArchiveBlock,
@ -26,6 +30,10 @@ const blockComponents = {
kanbanHori: KanbanHoriBlock,
showcase: ShowcaseBlock,
aboutProfile: AboutProfileBlock,
statsStrip: StatsStripBlock,
bentoRow: BentoRowBlock,
contact: ContactBlock,
toolStack: ToolStackBlock,
}
export const RenderBlocks: React.FC<{

View file

@ -1,6 +1,5 @@
import React from 'react'
import Image from 'next/image'
// Import the generated Media type from your Payload config
import type { Media as MediaType } from '@/payload-types'
type ShowcaseItem = {
@ -18,47 +17,40 @@ type ShowcaseBlockProps = {
items?: ShowcaseItem[]
}
function ShowcaseImage({ item }: { item: ShowcaseItem }) {
// Payload 3.0 returns the object directly if depth is configured
function ShowcaseImage(props: { item: ShowcaseItem }): React.ReactElement {
const item = props.item
const image = item.image
const imageUrl = image != null && typeof image === 'object' && 'url' in image ? image.url : null
const imageAlt = image != null && typeof image === 'object' && 'alt' in image ? image.alt : item.title
// Use the image object if available, otherwise fallback
if (image && typeof image === 'object' && 'url' in image) {
const content = (
<div className="relative w-full aspect-video overflow-hidden rounded-t-xl group">
<Image src={image.url!} alt={image.alt ?? item.title} fill className="object-cover" />
{/* Hover Overlay */}
if (imageUrl != null && item.imageUrl != null) {
return (
href={item.imageUrl}
target="_blank"
rel="noopener noreferrer"
className="block relative w-full aspect-video overflow-hidden group"
>
<Image src={imageUrl} alt={imageAlt ?? item.title} fill className="object-cover" />
<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">
<i
className="ti ti-external-link text-foreground/80"
style={{ fontSize: 20 }}
aria-hidden="true"
/>
</div>
)}
</div>
<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>
)
}
if (imageUrl != null) {
return (
<div className="relative w-full aspect-video overflow-hidden">
<Image src={imageUrl} alt={imageAlt ?? item.title} fill className="object-cover" />
</div>
)
if (item.imageUrl) {
return (
<a href={item.imageUrl} target="_blank" rel="noopener noreferrer" className="block">
{content}
</a>
)
}
return content
}
// Placeholder if no image
return (
<div className="relative w-full aspect-video overflow-hidden rounded-t-xl bg-muted/50 flex flex-col items-center justify-center gap-2">
<i
className="ti ti-photo-off text-foreground/15"
style={{ fontSize: 32 }}
aria-hidden="true"
/>
<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>
)
@ -72,67 +64,77 @@ export function ShowcaseBlock(props: ShowcaseBlockProps): React.ReactElement | n
return (
<section className="w-full max-w-5xl mx-auto px-6 py-16">
{(heading || subheading) && (
<div className="text-center mb-12">
<div className="mb-8">
{heading && (
<h2 className="text-3xl font-semibold tracking-tight text-foreground mb-3">
{heading}
</h2>
<h2 className="text-2xl font-medium text-foreground mb-2">{heading}</h2>
)}
{subheading && (
<p className="text-sm text-foreground/50 max-w-xl mx-auto">{subheading}</p>
<p className="text-sm text-foreground/40">{subheading}</p>
)}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((item, i) => (
<div key={i} className="bg-muted/50 border-0 rounded-xl overflow-hidden flex flex-col">
<ShowcaseImage item={item} />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{items.map(function (item, i) {
const isFeatured = i === 0 && items.length > 1
return (
<div
key={i}
className={`bg-muted/50 border border-foreground/8 rounded-xl overflow-hidden flex flex-col ${isFeatured ? 'sm:col-span-2 lg:col-span-2' : ''}`}
>
<ShowcaseImage item={item} />
<div className="p-4 flex flex-col gap-3 flex-1">
<h3 className="text-sm font-medium text-foreground/85">{item.title}</h3>
<div className="p-4 flex flex-col gap-3 flex-1">
{item.description && (
<span className="text-xs text-foreground/30 border border-foreground/8 rounded-full px-2.5 py-0.5 self-start">
{item.description.length > 40 ? item.description.slice(0, 40) + '...' : item.description}
</span>
)}
{item.description && (
<p className="text-xs text-foreground/50 leading-relaxed">{item.description}</p>
)}
<h3 className="text-sm font-medium text-foreground/85">{item.title}</h3>
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{item.tags.map((t, 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"
>
{t.tag}
</span>
))}
</div>
)}
{item.description && item.description.length > 40 && (
<p className="text-xs text-foreground/40 leading-relaxed">{item.description}</p>
)}
{item.links && item.links.length > 0 && (
<div className="flex flex-wrap gap-3 mt-auto pt-2 border-t border-foreground/10">
{item.links.map((link, k) => (
<a
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>
)}
{Array.isArray(item.tags) && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{item.tags.map(function (t, j) {
return (
<span
key={j}
className="text-xs text-foreground/40 bg-background/50 border border-foreground/8 rounded-full px-2.5 py-0.5"
>
{t.tag}
</span>
)
})}
</div>
)}
{Array.isArray(item.links) && item.links.length > 0 && (
<div className="flex flex-wrap gap-3 mt-auto pt-3 border-t border-foreground/8">
{item.links.map(function (link, k) {
return (
key={k}
href={link.url}
target={link.newTab ? '_blank' : '_self'}
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-foreground/40 hover:text-foreground/70 transition-colors"
>
<i className="ti ti-external-link" style={{ fontSize: 12 }} aria-hidden="true" />
{link.label}
</a>
)
})}
</div>
)}
</div>
</div>
</div>
))}
)
})}
</div>
</section>
)
}
}

View file

@ -0,0 +1,31 @@
import React from 'react'
type Stat = {
value: string
label: string
}
type StatsStripProps = {
stats?: Stat[]
}
export function StatsStripBlock({ stats }: StatsStripProps) {
if (!Array.isArray(stats) || stats.length === 0) return null
return (
<div
className="grid border-b border-foreground/8"
style={{ gridTemplateColumns: `repeat(${stats.length}, 1fr)` }}
>
{stats.map((stat, i) => (
<div
key={i}
className={`px-6 py-5 ${i < stats.length - 1 ? 'border-r border-foreground/8' : ''}`}
>
<p className="text-2xl font-medium text-foreground">{stat.value}</p>
<p className="text-xs text-foreground/30 mt-1">{stat.label}</p>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,19 @@
import type { Block } from 'payload'
export const StatsStripBlock: Block = {
slug: 'statsStrip',
labels: { singular: 'Stats Strip', plural: 'Stats Strips' },
fields: [
{
name: 'stats',
type: 'array',
label: 'Stats',
minRows: 1,
maxRows: 6,
fields: [
{ name: 'value', type: 'text', label: 'Value', required: true },
{ name: 'label', type: 'text', label: 'Label', required: true },
],
},
],
}

View file

@ -0,0 +1,30 @@
import React from 'react'
type ToolStackBlockProps = {
heading?: string
tools?: { name: string }[]
}
export function ToolStackBlock({ heading, tools }: ToolStackBlockProps) {
if (!Array.isArray(tools) || tools.length === 0) return null
return (
<section className="w-full max-w-5xl mx-auto px-6 py-16">
<div className="bg-muted/50 border border-foreground/8 rounded-xl p-6">
{heading && (
<p className="text-xs tracking-widest uppercase text-foreground/30 mb-4">{heading}</p>
)}
<div className="flex flex-wrap gap-2">
{tools.map((tool, i) => (
<span
key={i}
className="text-xs text-foreground/50 bg-background/50 border border-foreground/8 rounded-full px-3 py-1.5 hover:text-foreground/70 hover:border-foreground/15 transition-colors"
>
{tool.name}
</span>
))}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,21 @@
import type { Block } from 'payload'
export const ToolStackBlock: Block = {
slug: 'toolStack',
labels: { singular: 'Tool Stack', plural: 'Tool Stacks' },
fields: [
{
name: 'heading',
type: 'text',
label: 'Heading',
defaultValue: 'Tool stack',
},
{
name: 'tools',
type: 'array',
label: 'Tools',
minRows: 1,
fields: [{ name: 'name', type: 'text', label: 'Tool name', required: true }],
},
],
}

View file

@ -13,6 +13,10 @@ import { KanbanColorBlock } from '../../blocks/KanbanColor/config'
import { KanbanHoriBlock } from '../../blocks/KanbanHori/config'
import { ShowcaseBlock } from '../../blocks/Showcase/config'
import { AboutProfileBlock } from '../../blocks/AboutProfile/config'
import { StatsStripBlock } from '../../blocks/StatsStrip/config'
import { BentoRowBlock } from '../../blocks/BentoRow/config'
import { ContactBlock } from '../../blocks/Contact/config'
import { ToolStackBlock } from '../../blocks/ToolStack/config'
import { hero } from '@/heros/config'
import { slugField } from 'payload'
import { populatePublishedAt } from '../../hooks/populatePublishedAt'
@ -87,6 +91,10 @@ export const Pages: CollectionConfig<'pages'> = {
KanbanHoriBlock,
ShowcaseBlock,
AboutProfileBlock,
StatsStripBlock,
BentoRowBlock,
ContactBlock,
ToolStackBlock,
],
required: true,
admin: {

View file

@ -1,15 +1,16 @@
import React from 'react'
import type { Page } from '@/payload-types'
import { HighImpactHero } from '@/heros/HighImpact'
import { LowImpactHero } from '@/heros/LowImpact'
import { MediumImpactHero } from '@/heros/MediumImpact'
import { SplitHero } from '@/heros/SplitHero'
const heroes = {
highImpact: HighImpactHero,
lowImpact: LowImpactHero,
mediumImpact: MediumImpactHero,
splitHero: SplitHero,
}
export const RenderHero: React.FC<Page['hero']> = (props) => {

View file

@ -0,0 +1,99 @@
import React from 'react'
import Image from 'next/image'
import type { Page } from '@/payload-types'
import type { Media as MediaType } from '@/payload-types'
type SplitHeroProps = Page['hero'] & {
eyebrow?: string
heading?: string
subtext?: string
tags?: { tag: string }[]
primaryCta?: { label?: string; url?: string }
secondaryCta?: { label?: string; url?: string }
splitImage?: MediaType | null
}
export const SplitHero: React.FC<SplitHeroProps> = ({
eyebrow,
heading,
subtext,
tags,
primaryCta,
secondaryCta,
splitImage,
}) => {
const hasImage = splitImage && typeof splitImage === 'object' && splitImage.url
return (
<div className="grid grid-cols-1 md:grid-cols-2 border-b border-foreground/8">
<div className="flex flex-col justify-between gap-8 px-8 py-14 md:border-r border-foreground/8">
<div>
{eyebrow && (
<p className="text-xs tracking-widest uppercase text-foreground/30 mb-5">
{eyebrow}
</p>
)}
{heading && (
<h1 className="text-4xl font-medium text-foreground leading-[1.1] mb-4 whitespace-pre-line">
{heading}
</h1>
)}
{subtext && (
<p className="text-sm text-foreground/50 leading-relaxed max-w-sm mb-6">
{subtext}
</p>
)}
{Array.isArray(tags) && tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{tags.map(({ tag }, i) => (
<span
key={i}
className="text-xs text-foreground/40 border border-foreground/10 rounded-full px-3 py-1"
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="flex gap-3">
{primaryCta?.label && primaryCta?.url && (
href={primaryCta.url}
className="flex items-center gap-2 text-sm font-medium text-foreground bg-muted/50 border border-foreground/10 rounded-lg px-5 py-2.5 hover:bg-muted transition-colors"
>
<i className="ti ti-briefcase" style={{ fontSize: 14 }} aria-hidden="true" />
{primaryCta.label}
</a>
)}
{secondaryCta?.label && secondaryCta?.url && (
href={secondaryCta.url}
className="flex items-center gap-2 text-sm text-foreground/50 border border-foreground/10 rounded-lg px-5 py-2.5 hover:text-foreground/80 hover:bg-muted/30 transition-colors"
>
<i className="ti ti-mail" style={{ fontSize: 14 }} aria-hidden="true" />
{secondaryCta.label}
</a>
)}
</div>
</div>
<div className="bg-muted/30 flex items-center justify-center min-h-[260px]">
{hasImage ? (
<div className="relative w-full h-full min-h-[260px]">
<Image
src={splitImage.url!}
alt={splitImage.alt ?? ''}
fill
className="object-cover"
/>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-2 opacity-20">
<i className="ti ti-user" style={{ fontSize: 48 }} aria-hidden="true" />
</div>
)}
</div>
</div>
)
}

View file

@ -23,6 +23,7 @@ export const hero: Field = {
{ label: 'High Impact', value: 'highImpact' },
{ label: 'Medium Impact', value: 'mediumImpact' },
{ label: 'Low Impact', value: 'lowImpact' },
{ label: 'Split (Portfolio)', value: 'splitHero' },
],
required: true,
},
@ -92,6 +93,73 @@ export const hero: Field = {
relationTo: 'media',
required: true,
},
{
name: 'eyebrow',
type: 'text',
label: 'Eyebrow text',
admin: {
condition: (_, { type } = {}) => type === 'splitHero',
description: 'Small text above the heading e.g. "Creative technologist · Singapore"',
},
},
{
name: 'heading',
type: 'text',
label: 'Heading',
admin: {
condition: (_, { type } = {}) => type === 'splitHero',
},
},
{
name: 'subtext',
type: 'textarea',
label: 'Subtext',
admin: {
condition: (_, { type } = {}) => type === 'splitHero',
},
},
{
name: 'tags',
type: 'array',
label: 'Tags',
admin: {
condition: (_, { type } = {}) => type === 'splitHero',
},
fields: [{ name: 'tag', type: 'text', label: 'Tag', required: true }],
},
{
name: 'primaryCta',
type: 'group',
label: 'Primary CTA',
admin: {
condition: (_, { type } = {}) => type === 'splitHero',
},
fields: [
{ name: 'label', type: 'text', label: 'Label', defaultValue: 'View my work' },
{ name: 'url', type: 'text', label: 'URL', defaultValue: '#work' },
],
},
{
name: 'secondaryCta',
type: 'group',
label: 'Secondary CTA',
admin: {
condition: (_, { type } = {}) => type === 'splitHero',
},
fields: [
{ name: 'label', type: 'text', label: 'Label', defaultValue: 'Get in touch' },
{ name: 'url', type: 'text', label: 'URL', defaultValue: '#contact' },
],
},
{
name: 'splitImage',
type: 'upload',
relationTo: 'media',
label: 'Photo (right side)',
admin: {
condition: (_, { type } = {}) => type === 'splitHero',
},
},
],
label: false,
}

View file

@ -159,7 +159,7 @@ export interface Page {
id: string;
title: string;
hero: {
type: 'none' | 'highImpact' | 'mediumImpact' | 'lowImpact';
type: 'none' | 'highImpact' | 'mediumImpact' | 'lowImpact' | 'splitHero';
theme?: ('default' | 'muted' | 'card' | 'secondary' | 'image') | null;
/**
* Background image for the hero section
@ -209,6 +209,27 @@ export interface Page {
}[]
| null;
media?: (string | null) | Media;
/**
* Small text above the heading e.g. "Creative technologist · Singapore"
*/
eyebrow?: string | null;
heading?: string | null;
subtext?: string | null;
tags?:
| {
tag: string;
id?: string | null;
}[]
| null;
primaryCta?: {
label?: string | null;
url?: string | null;
};
secondaryCta?: {
label?: string | null;
url?: string | null;
};
splitImage?: (string | null) | Media;
};
layout: (
| CallToActionBlock
@ -348,6 +369,69 @@ export interface Page {
blockName?: string | null;
blockType: 'aboutProfile';
}
| {
stats?:
| {
value: string;
label: string;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'statsStrip';
}
| {
aboutHeading?: string | null;
aboutText?: string | null;
aboutCta?: {
label?: string | null;
url?: string | null;
newTab?: boolean | null;
};
skillsHeading?: string | null;
skills?:
| {
icon: string;
title: string;
tags: string;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'bentoRow';
}
| {
heading?: string | null;
subtext?: string | null;
email?: string | null;
links?:
| {
label: string;
sublabel?: string | null;
url: string;
icon?: string | null;
newTab?: boolean | null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'contact';
}
| {
heading?: string | null;
tools?:
| {
name: string;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'toolStack';
}
)[];
meta?: {
title?: string | null;
@ -1230,6 +1314,28 @@ export interface PagesSelect<T extends boolean = true> {
id?: T;
};
media?: T;
eyebrow?: T;
heading?: T;
subtext?: T;
tags?:
| T
| {
tag?: T;
id?: T;
};
primaryCta?:
| T
| {
label?: T;
url?: T;
};
secondaryCta?:
| T
| {
label?: T;
url?: T;
};
splitImage?: T;
};
layout?:
| T
@ -1366,6 +1472,75 @@ export interface PagesSelect<T extends boolean = true> {
id?: T;
blockName?: T;
};
statsStrip?:
| T
| {
stats?:
| T
| {
value?: T;
label?: T;
id?: T;
};
id?: T;
blockName?: T;
};
bentoRow?:
| T
| {
aboutHeading?: T;
aboutText?: T;
aboutCta?:
| T
| {
label?: T;
url?: T;
newTab?: T;
};
skillsHeading?: T;
skills?:
| T
| {
icon?: T;
title?: T;
tags?: T;
id?: T;
};
id?: T;
blockName?: T;
};
contact?:
| T
| {
heading?: T;
subtext?: T;
email?: T;
links?:
| T
| {
label?: T;
sublabel?: T;
url?: T;
icon?: T;
newTab?: T;
id?: T;
};
id?: T;
blockName?: T;
};
toolStack?:
| T
| {
heading?: T;
tools?:
| T
| {
name?: T;
id?: T;
};
id?: T;
blockName?: T;
};
};
meta?:
| T

View file

@ -3,14 +3,14 @@ import { getServerSideURL } from './getURL'
const defaultOpenGraph: Metadata['openGraph'] = {
type: 'website',
description: 'An open-source website built with Payload and Next.js.',
description: "Full stack developer with a designer's eye.",
images: [
{
url: `${getServerSideURL()}/website-template-OG.webp`,
url: `${getServerSideURL()}/og-image.webp`,
},
],
siteName: 'Payload Website Template',
title: 'Payload Website Template',
siteName: 'ByMackie',
title: 'ByMackie',
}
export const mergeOpenGraph = (og?: Metadata['openGraph']): Metadata['openGraph'] => {