initial commit

This commit is contained in:
2026-06-17 22:37:47 +05:30
commit bc31b2508b
100 changed files with 18050 additions and 0 deletions
+3
View File
@@ -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;
+11
View File
@@ -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);
+7
View File
@@ -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);
+4
View File
@@ -0,0 +1,4 @@
/* Custom Payload admin theme overrides — kept minimal so the stock look-and-feel wins. */
:root {
--theme-base-0: #ffffff;
}
+29
View File
@@ -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;
+17
View File
@@ -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;
+23
View File
@@ -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;
+101
View File
@@ -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;
},
],
},
};
+38
View File
@@ -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' },
],
},
],
};
+33
View File
@@ -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 },
],
};
+25
View File
@@ -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 } },
],
};
+35
View File
@@ -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({}),
},
],
};
+38
View File
@@ -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;
},
],
},
};
+22
View File
@@ -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,
},
],
};
+37
View File
@@ -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,
},
},
],
};
+20
View File
@@ -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;
};
+58
View File
@@ -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],
},
});