refactor: migrate to pnpm monorepo with Payload CMS backend and Astro frontend to support scalable website development and AI-assisted workflows

This commit is contained in:
2025-09-25 03:36:26 +08:00
parent 4efabd168c
commit 74677acf77
243 changed files with 28435 additions and 102 deletions

View File

@@ -0,0 +1,46 @@
'use client'
import { useHeaderTheme } from '@/providers/HeaderTheme'
import React, { useEffect } from 'react'
import type { Page } from '@/payload-types'
import { CMSLink } from '@/components/Link'
import { Media } from '@/components/Media'
import RichText from '@/components/RichText'
export const HighImpactHero: React.FC<Page['hero']> = ({ links, media, richText }) => {
const { setHeaderTheme } = useHeaderTheme()
useEffect(() => {
setHeaderTheme('dark')
})
return (
<div
className="relative -mt-[10.4rem] flex items-center justify-center text-white"
data-theme="dark"
>
<div className="container mb-8 z-10 relative flex items-center justify-center">
<div className="max-w-[36.5rem] md:text-center">
{richText && <RichText className="mb-6" data={richText} enableGutter={false} />}
{Array.isArray(links) && links.length > 0 && (
<ul className="flex md:justify-center gap-4">
{links.map(({ link }, i) => {
return (
<li key={i}>
<CMSLink {...link} />
</li>
)
})}
</ul>
)}
</div>
</div>
<div className="min-h-[80vh] select-none">
{media && typeof media === 'object' && (
<Media fill imgClassName="-z-10 object-cover" priority resource={media} />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
import type { Page } from '@/payload-types'
import RichText from '@/components/RichText'
type LowImpactHeroType =
| {
children?: React.ReactNode
richText?: never
}
| (Omit<Page['hero'], 'richText'> & {
children?: never
richText?: Page['hero']['richText']
})
export const LowImpactHero: React.FC<LowImpactHeroType> = ({ children, richText }) => {
return (
<div className="container mt-16">
<div className="max-w-[48rem]">
{children || (richText && <RichText data={richText} enableGutter={false} />)}
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import React from 'react'
import type { Page } from '@/payload-types'
import { CMSLink } from '@/components/Link'
import { Media } from '@/components/Media'
import RichText from '@/components/RichText'
export const MediumImpactHero: React.FC<Page['hero']> = ({ links, media, richText }) => {
return (
<div className="">
<div className="container mb-8">
{richText && <RichText className="mb-6" data={richText} enableGutter={false} />}
{Array.isArray(links) && links.length > 0 && (
<ul className="flex gap-4">
{links.map(({ link }, i) => {
return (
<li key={i}>
<CMSLink {...link} />
</li>
)
})}
</ul>
)}
</div>
<div className="container ">
{media && typeof media === 'object' && (
<div>
<Media
className="-mx-4 md:-mx-8 2xl:-mx-16"
imgClassName=""
priority
resource={media}
/>
{media?.caption && (
<div className="mt-3">
<RichText data={media.caption} enableGutter={false} />
</div>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { formatDateTime } from 'src/utilities/formatDateTime'
import React from 'react'
import type { Post } from '@/payload-types'
import { Media } from '@/components/Media'
import { formatAuthors } from '@/utilities/formatAuthors'
export const PostHero: React.FC<{
post: Post
}> = ({ post }) => {
const { categories, heroImage, populatedAuthors, publishedAt, title } = post
const hasAuthors =
populatedAuthors && populatedAuthors.length > 0 && formatAuthors(populatedAuthors) !== ''
return (
<div className="relative -mt-[10.4rem] flex items-end">
<div className="container z-10 relative lg:grid lg:grid-cols-[1fr_48rem_1fr] text-white pb-8">
<div className="col-start-1 col-span-1 md:col-start-2 md:col-span-2">
<div className="uppercase text-sm mb-6">
{categories?.map((category, index) => {
if (typeof category === 'object' && category !== null) {
const { title: categoryTitle } = category
const titleToUse = categoryTitle || 'Untitled category'
const isLast = index === categories.length - 1
return (
<React.Fragment key={index}>
{titleToUse}
{!isLast && <React.Fragment>, &nbsp;</React.Fragment>}
</React.Fragment>
)
}
return null
})}
</div>
<div className="">
<h1 className="mb-6 text-3xl md:text-5xl lg:text-6xl">{title}</h1>
</div>
<div className="flex flex-col md:flex-row gap-4 md:gap-16">
{hasAuthors && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<p className="text-sm">Author</p>
<p>{formatAuthors(populatedAuthors)}</p>
</div>
</div>
)}
{publishedAt && (
<div className="flex flex-col gap-1">
<p className="text-sm">Date Published</p>
<time dateTime={publishedAt}>{formatDateTime(publishedAt)}</time>
</div>
)}
</div>
</div>
</div>
<div className="min-h-[80vh] select-none">
{heroImage && typeof heroImage !== 'string' && (
<Media fill priority imgClassName="-z-10 object-cover" resource={heroImage} />
)}
<div className="absolute pointer-events-none left-0 bottom-0 w-full h-1/2 bg-gradient-to-t from-black to-transparent" />
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
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'
const heroes = {
highImpact: HighImpactHero,
lowImpact: LowImpactHero,
mediumImpact: MediumImpactHero,
}
export const RenderHero: React.FC<Page['hero']> = (props) => {
const { type } = props || {}
if (!type || type === 'none') return null
const HeroToRender = heroes[type]
if (!HeroToRender) return null
return <HeroToRender {...props} />
}

View File

@@ -0,0 +1,72 @@
import type { Field } from 'payload'
import {
FixedToolbarFeature,
HeadingFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { linkGroup } from '@/fields/linkGroup'
export const hero: Field = {
name: 'hero',
type: 'group',
fields: [
{
name: 'type',
type: 'select',
defaultValue: 'lowImpact',
label: 'Type',
options: [
{
label: 'None',
value: 'none',
},
{
label: 'High Impact',
value: 'highImpact',
},
{
label: 'Medium Impact',
value: 'mediumImpact',
},
{
label: 'Low Impact',
value: 'lowImpact',
},
],
required: true,
},
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: false,
},
linkGroup({
overrides: {
maxRows: 2,
},
}),
{
name: 'media',
type: 'upload',
admin: {
condition: (_, { type } = {}) => ['highImpact', 'mediumImpact'].includes(type),
},
relationTo: 'media',
required: true,
},
],
label: false,
}