import type { CollectionConfig } from 'payload'; import { revalidatePath } from 'next/cache'; import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical'; import { Callout } from '@/blocks/Callout'; /** * Drop the cached copy of a post page when it changes. * * Only the post page needs this. The blog index, the RSS feed and the content * sitemap are force-dynamic — they have no dynamic params, so Next would * prerender them at build time, where CI has no database — which means they * already reflect a change on the next request. * * /blog/[slug] is ISR: generated on first request and cached, so without this * an edit to an already-published post would not appear until the revalidate * window expired — up to an hour of a writer concluding that saving is broken. * * Payload runs in the same process as Next, so this is a direct revalidatePath * call: no webhook, no shared secret, no network hop to get wrong. */ function revalidatePost(slug: string) { revalidatePath(`/blog/${slug}`); } export const Posts: CollectionConfig = { slug: 'posts', access: { /* * Drafts must be hidden here, not in the pages that query this collection. * * From Payload's own documentation: "The `draft` argument alone does not * restrict documents with `_status: 'draft'` from being returned by the * API." The blog index and post page both filter on `_status`, but that * is a convenience, not a control — a direct GET /cms-api/posts would * hand every unpublished draft to any visitor. * * Returning a query constraint rather than a boolean is the documented * mechanism: Payload merges it into every read for an anonymous caller. */ read: ({ req }) => { if (req.user) return true; return { _status: { equals: 'published' } }; }, }, admin: { useAsTitle: 'title', defaultColumns: ['title', 'publishedAt', '_status'], }, versions: { // Posts get written across several sittings and previewed before they go // live. Without drafts, saving is publishing. drafts: true, }, hooks: { afterChange: [({ doc }) => { revalidatePost(String(doc.slug)); }], afterDelete: [({ doc }) => { revalidatePost(String(doc.slug)); }], }, fields: [ { name: 'title', type: 'text', required: true }, { name: 'slug', type: 'text', required: true, unique: true, index: true, admin: { position: 'sidebar', description: 'The URL segment. Never change it after publishing.', }, }, { name: 'publishedAt', type: 'date', required: true, admin: { position: 'sidebar', date: { pickerAppearance: 'dayOnly' } }, }, { name: 'excerpt', type: 'textarea', required: true, maxLength: 200, admin: { description: 'Shown on the index and used as the meta description.', }, }, { name: 'heroImage', type: 'upload', relationTo: 'media' }, { name: 'content', type: 'richText', required: true, editor: lexicalEditor({ features: ({ defaultFeatures }) => [ ...defaultFeatures, BlocksFeature({ blocks: [Callout] }), ], }), }, ], };