initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"importMap": {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config';
|
||||
import '@payloadcms/next/css';
|
||||
import type { ServerFunctionClient } from 'payload';
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts';
|
||||
import { importMap } from '../../../admin/importMap';
|
||||
|
||||
type Args = { children: React.ReactNode };
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server';
|
||||
return handleServerFunctions({ ...args, config, importMap });
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,19 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next';
|
||||
import config from '@payload-config';
|
||||
import { generatePageMetadata, RootPage } from '@payloadcms/next/views';
|
||||
import { importMap } from '../../admin/importMap';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] }>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params, searchParams, importMap });
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,11 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config';
|
||||
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST, REST_PUT } from '@payloadcms/next/routes';
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -0,0 +1,7 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config';
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes';
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -0,0 +1,7 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config';
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes';
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Custom Payload admin theme overrides — kept minimal so the stock look-and-feel wins. */
|
||||
:root {
|
||||
--theme-base-0: #ffffff;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config';
|
||||
import '@payloadcms/next/css';
|
||||
import type { ServerFunctionClient } from 'payload';
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts';
|
||||
import { importMap } from './admin/importMap';
|
||||
import './custom.scss';
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server';
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next';
|
||||
import config from '@payload-config';
|
||||
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views';
|
||||
import { importMap } from '../admin/importMap';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] }>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, params, searchParams, importMap });
|
||||
|
||||
export default NotFound;
|
||||
@@ -0,0 +1,23 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next';
|
||||
import config from '@payload-config';
|
||||
import { generatePageMetadata, RootPage } from '@payloadcms/next/views';
|
||||
import { importMap } from '../admin/importMap';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params, searchParams, importMap });
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { CollectionConfig, FieldHook } from 'payload';
|
||||
|
||||
const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
|
||||
const autoSlug: FieldHook = ({ value, data }) => {
|
||||
if (value) return slugify(String(value));
|
||||
if (data?.title) return slugify(String(data.title));
|
||||
return value;
|
||||
};
|
||||
|
||||
export const Articles: CollectionConfig = {
|
||||
slug: 'articles',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'status', 'category', 'author', 'publishedAt'],
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
versions: { drafts: { autosave: true } },
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
hooks: { beforeValidate: [autoSlug] },
|
||||
},
|
||||
{ name: 'excerpt', type: 'textarea', required: true, maxLength: 280 },
|
||||
{
|
||||
name: 'body',
|
||||
type: 'richText',
|
||||
required: true,
|
||||
},
|
||||
{ name: 'coverImage', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'category', type: 'relationship', relationTo: 'categories', required: true },
|
||||
{ name: 'tags', type: 'relationship', relationTo: 'tags', hasMany: true },
|
||||
{ name: 'author', type: 'relationship', relationTo: 'authors', required: true },
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'draft',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
],
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: { date: { pickerAppearance: 'dayAndTime' }, position: 'sidebar' },
|
||||
},
|
||||
{
|
||||
name: 'readingMinutes',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Auto-computed if empty. Manual override takes precedence.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'featured',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
{ name: 'featuredRank', type: 'number', defaultValue: 0, admin: { position: 'sidebar' } },
|
||||
{
|
||||
name: 'seo',
|
||||
type: 'group',
|
||||
label: 'SEO',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'description', type: 'textarea' },
|
||||
{ name: 'ogImage', type: 'upload', relationTo: 'media' },
|
||||
],
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ data }) => {
|
||||
if (data?.status === 'published' && !data.publishedAt) {
|
||||
return { ...data, publishedAt: new Date().toISOString() };
|
||||
}
|
||||
return data;
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Authors: CollectionConfig = {
|
||||
slug: 'authors',
|
||||
admin: { useAsTitle: 'name', defaultColumns: ['name', 'role', 'updatedAt'] },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{ name: 'role', type: 'text', admin: { description: 'e.g. Founding Editor' } },
|
||||
{
|
||||
name: 'avatar',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
{ name: 'bio', type: 'textarea' },
|
||||
{
|
||||
name: 'socials',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'twitter', type: 'text' },
|
||||
{ name: 'github', type: 'text' },
|
||||
{ name: 'site', type: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Categories: CollectionConfig = {
|
||||
slug: 'categories',
|
||||
admin: { useAsTitle: 'name', defaultColumns: ['name', 'order', 'updatedAt'] },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: { description: 'Lowercase, dash-separated.' },
|
||||
},
|
||||
{ name: 'description', type: 'textarea' },
|
||||
{
|
||||
name: 'icon',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Material Symbols icon name. e.g. memory, code, layers.',
|
||||
},
|
||||
},
|
||||
{ name: 'color', type: 'text', admin: { description: 'Optional accent color (hex).' } },
|
||||
{ name: 'order', type: 'number', defaultValue: 0 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Editions: CollectionConfig = {
|
||||
slug: 'editions',
|
||||
admin: { useAsTitle: 'title', defaultColumns: ['number', 'title', 'publishedAt'] },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'number', type: 'number', required: true, unique: true },
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'intro', type: 'textarea' },
|
||||
{ name: 'cover', type: 'upload', relationTo: 'media' },
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: { date: { pickerAppearance: 'dayAndTime' } },
|
||||
},
|
||||
{ name: 'articles', type: 'relationship', relationTo: 'articles', hasMany: true },
|
||||
{ name: 'sentCount', type: 'number', defaultValue: 0, admin: { readOnly: true } },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
admin: { useAsTitle: 'alt' },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => Boolean(req.user),
|
||||
},
|
||||
upload: {
|
||||
staticDir: 'media',
|
||||
mimeTypes: ['image/*'],
|
||||
imageSizes: [
|
||||
{ name: 'thumbnail', width: 160, height: 160, position: 'centre' },
|
||||
{ name: 'card', width: 640, height: 400, position: 'centre' },
|
||||
{ name: 'hero', width: 1280, height: 720, position: 'centre' },
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{ name: 'caption', type: 'text' },
|
||||
{
|
||||
name: 'credit',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Subscribers: CollectionConfig = {
|
||||
slug: 'subscribers',
|
||||
admin: { useAsTitle: 'email', defaultColumns: ['email', 'source', 'createdAt'] },
|
||||
access: {
|
||||
read: ({ req }) => Boolean(req.user),
|
||||
create: () => true,
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
type: 'text',
|
||||
defaultValue: 'web',
|
||||
},
|
||||
{
|
||||
name: 'unsubscribedAt',
|
||||
type: 'date',
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ data }) => {
|
||||
if (data?.email) data.email = String(data.email).toLowerCase().trim();
|
||||
return data;
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Tags: CollectionConfig = {
|
||||
slug: 'tags',
|
||||
admin: { useAsTitle: 'name' },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'roles', 'updatedAt'],
|
||||
},
|
||||
auth: true,
|
||||
access: {
|
||||
create: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
read: ({ req }) => Boolean(req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
defaultValue: ['editor'],
|
||||
options: [
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
{ label: 'Editor', value: 'editor' },
|
||||
{ label: 'Author', value: 'author' },
|
||||
],
|
||||
access: {
|
||||
update: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CollectionAfterChangeHook } from 'payload';
|
||||
import { logger as payloadLogger } from 'payload';
|
||||
|
||||
const TRIGGERING = new Set(['articles', 'authors', 'categories', 'editions', 'tags']);
|
||||
|
||||
export const revalidateAstro: CollectionAfterChangeHook = async ({ doc, operation, collection }) => {
|
||||
if (!TRIGGERING.has(collection.slug)) return doc;
|
||||
if (process.env.ASTRO_REVALIDATE_URL && process.env.ASTRO_REVALIDATE_TOKEN) {
|
||||
try {
|
||||
const url = `${process.env.ASTRO_REVALIDATE_URL}?secret=${encodeURIComponent(
|
||||
process.env.ASTRO_REVALIDATE_TOKEN,
|
||||
)}&collection=${collection.slug}&op=${operation}`;
|
||||
const res = await fetch(url, { method: 'POST' });
|
||||
payloadLogger.info(`Revalidate ${collection.slug} ${operation}: ${res.status}`);
|
||||
} catch (e) {
|
||||
payloadLogger.warn(`Revalidate failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { buildConfig } from 'payload';
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
|
||||
import { Users } from './collections/Users';
|
||||
import { Media } from './collections/Media';
|
||||
import { Articles } from './collections/Articles';
|
||||
import { Authors } from './collections/Authors';
|
||||
import { Categories } from './collections/Categories';
|
||||
import { Tags } from './collections/Tags';
|
||||
import { Editions } from './collections/Editions';
|
||||
import { Subscribers } from './collections/Subscribers';
|
||||
|
||||
import { revalidateAstro } from './hooks/revalidateAstro';
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
const corsOrigins = (process.env.CORS_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export default buildConfig({
|
||||
serverURL: process.env.SERVER_URL,
|
||||
cors: corsOrigins,
|
||||
csrf: corsOrigins,
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
importMap: { baseDir: path.resolve(dirname) },
|
||||
meta: {
|
||||
titleSuffix: ' · SoftVowels CMS',
|
||||
},
|
||||
},
|
||||
collections: [Users, Media, Articles, Authors, Categories, Tags, Editions, Subscribers],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || 'dev-secret-change-me',
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.DATABASE_URI || '',
|
||||
},
|
||||
}),
|
||||
sharp: (sharp) => sharp,
|
||||
upload: {
|
||||
limits: {
|
||||
fileSize: 25_000_000,
|
||||
},
|
||||
},
|
||||
endpoints: [],
|
||||
hooks: {
|
||||
afterChange: [revalidateAstro],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user