newformat
This commit is contained in:
parent
8df66d3245
commit
04c62332b1
16 changed files with 820 additions and 90 deletions
97
src/blocks/BentoRow/Component.tsx
Normal file
97
src/blocks/BentoRow/Component.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
src/blocks/BentoRow/config.ts
Normal file
50
src/blocks/BentoRow/config.ts
Normal 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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
84
src/blocks/Contact/Component.tsx
Normal file
84
src/blocks/Contact/Component.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/blocks/Contact/config.ts
Normal file
37
src/blocks/Contact/config.ts
Normal 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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
31
src/blocks/StatsStrip/Component.tsx
Normal file
31
src/blocks/StatsStrip/Component.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/blocks/StatsStrip/config.ts
Normal file
19
src/blocks/StatsStrip/config.ts
Normal 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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
30
src/blocks/ToolStack/Component.tsx
Normal file
30
src/blocks/ToolStack/Component.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
src/blocks/ToolStack/config.ts
Normal file
21
src/blocks/ToolStack/config.ts
Normal 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 }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
99
src/heros/SplitHero/index.tsx
Normal file
99
src/heros/SplitHero/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'] => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue