- Connect homepage, blog, services, header, footer to Payload CMS via local API - Add HomePage global with 7 editorial sections (hero, painPoints, solution, whyAxil, audience, process, finalCta) - Add ServerHeader/ServerFooter async wrappers with unstable_cache + tag-based ISR revalidation - Rewrite blog/[slug] and services/[slug] pages to fetch from CMS with fallbacks - Add seed script (src/lib/seed.ts) to populate all collections and globals from hardcoded defaults - Restructure app into (site)/ route group to fix Payload admin hydration conflicts - Make root layout a passthrough; (site)/layout.tsx owns html/body/fonts - Restrict user creation/update/delete to admin role only - Fix migration imports: type MigrateUpArgs/Down from @payloadcms/db-postgres - Wrap revalidateTag dynamic imports in try/catch for seed/CLI compatibility - Skip migrations in dev mode (Payload handles schema push automatically) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
129 lines
2.9 KiB
TypeScript
129 lines
2.9 KiB
TypeScript
import type {
|
|
CollectionConfig,
|
|
CollectionAfterChangeHook,
|
|
CollectionAfterDeleteHook,
|
|
} from 'payload';
|
|
|
|
const calculateReadingTime: CollectionAfterChangeHook = async ({ doc, req: { payload } }) => {
|
|
if (!doc.content) return doc;
|
|
try {
|
|
// Rough estimate: 200 words per minute
|
|
const wordCount = JSON.stringify(doc.content).split(' ').length;
|
|
const minutes = Math.ceil(wordCount / 200);
|
|
await payload.update({ collection: 'posts', id: doc.id, data: { readingTime: minutes } });
|
|
} catch {
|
|
/* skip if update fails during seed */
|
|
}
|
|
return doc;
|
|
};
|
|
|
|
const revalidatePosts: CollectionAfterChangeHook = async ({ doc }) => {
|
|
try {
|
|
const { revalidateTag } = await import('next/cache');
|
|
revalidateTag('posts');
|
|
if (doc?.slug) revalidateTag(`post-${doc.slug}`);
|
|
} catch {
|
|
/* not in Next.js runtime */
|
|
}
|
|
return doc;
|
|
};
|
|
|
|
const revalidatePostsOnDelete: CollectionAfterDeleteHook = async ({ doc }) => {
|
|
try {
|
|
const { revalidateTag } = await import('next/cache');
|
|
revalidateTag('posts');
|
|
if (doc?.slug) revalidateTag(`post-${doc.slug}`);
|
|
} catch {
|
|
/* not in Next.js runtime */
|
|
}
|
|
};
|
|
|
|
export const Posts: CollectionConfig = {
|
|
slug: 'posts',
|
|
admin: {
|
|
useAsTitle: 'title',
|
|
defaultColumns: ['title', 'status', 'publishedAt', 'author'],
|
|
},
|
|
access: {
|
|
read: () => true,
|
|
},
|
|
hooks: {
|
|
afterChange: [calculateReadingTime, revalidatePosts],
|
|
afterDelete: [revalidatePostsOnDelete],
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'title',
|
|
type: 'text',
|
|
required: true,
|
|
},
|
|
{
|
|
name: 'slug',
|
|
type: 'text',
|
|
required: true,
|
|
unique: true,
|
|
},
|
|
{
|
|
name: 'author',
|
|
type: 'relationship',
|
|
relationTo: 'users',
|
|
},
|
|
{
|
|
name: 'category',
|
|
type: 'relationship',
|
|
relationTo: 'categories',
|
|
},
|
|
{
|
|
name: 'coverImage',
|
|
type: 'upload',
|
|
relationTo: 'media',
|
|
},
|
|
{
|
|
name: 'excerpt',
|
|
type: 'textarea',
|
|
admin: {
|
|
description: 'Short summary shown on listing pages (max 150 chars)',
|
|
},
|
|
},
|
|
{
|
|
name: 'content',
|
|
type: 'richText',
|
|
},
|
|
{
|
|
name: 'readingTime',
|
|
type: 'number',
|
|
admin: {
|
|
description: 'Auto-calculated on save (minutes)',
|
|
readOnly: true,
|
|
},
|
|
},
|
|
{
|
|
name: 'publishedAt',
|
|
type: 'date',
|
|
},
|
|
{
|
|
name: 'status',
|
|
type: 'select',
|
|
defaultValue: 'draft',
|
|
options: [
|
|
{ label: 'Draft', value: 'draft' },
|
|
{ label: 'Published', value: 'published' },
|
|
{ label: 'Scheduled', value: 'scheduled' },
|
|
],
|
|
},
|
|
{
|
|
name: 'tags',
|
|
type: 'array',
|
|
fields: [{ name: 'tag', type: 'text' }],
|
|
},
|
|
{
|
|
name: 'seo',
|
|
type: 'group',
|
|
fields: [
|
|
{ name: 'metaTitle', type: 'text' },
|
|
{ name: 'metaDescription', type: 'textarea' },
|
|
{ name: 'ogImage', type: 'upload', relationTo: 'media' },
|
|
],
|
|
},
|
|
],
|
|
};
|