diff --git a/media/pigeon-400x300.jpg b/media/pigeon-400x300.jpg new file mode 100644 index 0000000..18f8901 Binary files /dev/null and b/media/pigeon-400x300.jpg differ diff --git a/media/pigeon-768x1024.jpg b/media/pigeon-768x1024.jpg new file mode 100644 index 0000000..43fbc03 Binary files /dev/null and b/media/pigeon-768x1024.jpg differ diff --git a/media/pigeon.jpg b/media/pigeon.jpg new file mode 100644 index 0000000..5af8105 Binary files /dev/null and b/media/pigeon.jpg differ diff --git a/package.json b/package.json index 59c8c7e..d4ae950 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "prettier": "^3.5.3", "tailwindcss": "^4", "typescript": "^5" }, diff --git a/payload-types.ts b/payload-types.ts index e6b0f34..c058ad5 100644 --- a/payload-types.ts +++ b/payload-types.ts @@ -67,7 +67,11 @@ export interface Config { }; blocks: {}; collections: { + projects: Project; news: News; + media: Media; + documents: Document; + data: Datum; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -75,7 +79,11 @@ export interface Config { }; collectionsJoins: {}; collectionsSelect: { + projects: ProjectsSelect | ProjectsSelect; news: NewsSelect | NewsSelect; + media: MediaSelect | MediaSelect; + documents: DocumentsSelect | DocumentsSelect; + data: DataSelect | DataSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -117,6 +125,50 @@ export interface UserAuthOperations { password: string; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "projects". + */ +export interface Project { + id: number; + title: string; + /** + * The slug is used to identify the news item in the URL. + */ + slug: string; + content: { + root: { + type: string; + children: { + type: string; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + }; + /** + * Show this project on the homepage. + */ + featured?: boolean | null; + links?: + | { + link: string; + description?: string | null; + /** + * Optional: organise link under this heading + */ + group?: string | null; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "news". @@ -147,6 +199,93 @@ export interface News { createdAt: string; _status?: ('draft' | 'published') | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media". + */ +export interface Media { + id: number; + alt?: string | null; + description?: string | null; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; + sizes?: { + thumbnail?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + card?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + tablet?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "documents". + */ +export interface Document { + id: number; + title: string; + description?: string | null; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "data". + */ +export interface Datum { + id: number; + title: string; + description?: string | null; + source: string; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -171,10 +310,26 @@ export interface User { export interface PayloadLockedDocument { id: number; document?: + | ({ + relationTo: 'projects'; + value: number | Project; + } | null) | ({ relationTo: 'news'; value: number | News; } | null) + | ({ + relationTo: 'media'; + value: number | Media; + } | null) + | ({ + relationTo: 'documents'; + value: number | Document; + } | null) + | ({ + relationTo: 'data'; + value: number | Datum; + } | null) | ({ relationTo: 'users'; value: number | User; @@ -221,6 +376,26 @@ export interface PayloadMigration { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "projects_select". + */ +export interface ProjectsSelect { + title?: T; + slug?: T; + content?: T; + featured?: T; + links?: + | T + | { + link?: T; + description?: T; + group?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "news_select". @@ -233,6 +408,98 @@ export interface NewsSelect { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media_select". + */ +export interface MediaSelect { + alt?: T; + description?: T; + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; + sizes?: + | T + | { + thumbnail?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + card?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + tablet?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "documents_select". + */ +export interface DocumentsSelect { + title?: T; + description?: T; + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "data_select". + */ +export interface DataSelect { + title?: T; + description?: T; + source?: T; + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select". diff --git a/payload.config.ts b/payload.config.ts index d3b4c84..1fb3351 100644 --- a/payload.config.ts +++ b/payload.config.ts @@ -1,20 +1,29 @@ import sharp from 'sharp' -import { lexicalEditor } from '@payloadcms/richtext-lexical' +import { FixedToolbarFeature, lexicalEditor } from '@payloadcms/richtext-lexical' import { postgresAdapter } from '@payloadcms/db-postgres' import { buildConfig } from 'payload' -import { News } from './src/collections/News' + import { Home } from '@/globals/Home' +import { News } from './src/collections/News' +import { Projects } from '@/collections/Projects' +import { Data, Documents, Media } from '@/collections/Files' + export default buildConfig({ // If you'd like to use Rich Text, pass your editor here - editor: lexicalEditor(), + editor: lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + FixedToolbarFeature(), + ] + }), serverURL: process.env.SERVER_URL || 'http://localhost:3000', globals: [Home], // Define and configure your collections in this array - collections: [News], + collections: [Projects, News, Media, Documents, Data], // Your Payload secret - should be a complex and secure string, unguessable secret: process.env.PAYLOAD_SECRET || '', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb0f9f3..6397434 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.0.4(@types/react@19.0.12) + prettier: + specifier: ^3.5.3 + version: 3.5.3 tailwindcss: specifier: ^4 version: 4.0.17 diff --git a/src/app/(frontend)/projects/[slug]/page.tsx b/src/app/(frontend)/projects/[slug]/page.tsx new file mode 100644 index 0000000..a90bff9 --- /dev/null +++ b/src/app/(frontend)/projects/[slug]/page.tsx @@ -0,0 +1,53 @@ +import configPromise from "@payload-config"; +import { getPayload } from "payload"; +import { notFound } from "next/navigation"; +import { RichText } from "@payloadcms/richtext-lexical/react"; + +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const payload = await getPayload({ config: configPromise }); + const { slug } = await params; + + const result = await payload.find({ + collection: "projects", + where: { + slug: { + equals: slug, + }, + }, + depth: 1, + }); + + const item = result.docs[0]; + + if (!item) { + notFound(); + } + + return ( +
+

{item.title}

+ +
+ ); +} + +export async function generateStaticParams() { + const payload = await getPayload({ config: configPromise }); + + const projectItems = await payload.find({ + collection: "projects", + depth: 1, + limit: 5, + select: { + slug: true, + }, + }); + + return projectItems.docs.map((item) => ({ + slug: item.slug, + })); +} diff --git a/src/app/(frontend)/projects/page.tsx b/src/app/(frontend)/projects/page.tsx new file mode 100644 index 0000000..46b478c --- /dev/null +++ b/src/app/(frontend)/projects/page.tsx @@ -0,0 +1,39 @@ +import configPromise from "@payload-config"; +import Link from "next/link"; +import { getPayload } from "payload"; + +export default async function Page() { + const payload = await getPayload({ config: configPromise }); + + const projectItems = await payload.find({ + collection: "projects", + depth: 1, + limit: 5, + select: { + title: true, + slug: true, + }, + sort: "-created_at", + }); + + return ( +
+

Projects

+ +
    + {projectItems.docs.map((projectItem) => ( +
  • +

    + + {projectItem.title} + +

    +
  • + ))} +
+
+ ); +} diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index dfe0789..37acad6 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -1,5 +1,6 @@ import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' @@ -24,6 +25,7 @@ import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0 export const importMap = { "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, diff --git a/src/collections/Documents.ts b/src/collections/Documents.ts new file mode 100644 index 0000000..1a5606e --- /dev/null +++ b/src/collections/Documents.ts @@ -0,0 +1 @@ +import type { CollectionConfig } from "payload"; diff --git a/src/collections/Files.ts b/src/collections/Files.ts new file mode 100644 index 0000000..591bab6 --- /dev/null +++ b/src/collections/Files.ts @@ -0,0 +1,90 @@ +import type { CollectionConfig } from "payload"; + +export const Media: CollectionConfig = { + slug: 'media', + upload: { + staticDir: 'media', + imageSizes: [ + { + name: 'thumbnail', + width: 400, + height: 300, + position: 'centre', + }, + { + name: 'card', + width: 768, + height: 1024, + position: 'centre', + }, + { + name: 'tablet', + width: 1024, + height: undefined, + position: 'centre', + } + ], + adminThumbnail: 'thumbnail', + mimeTypes: ['image/*'], + }, + fields: [ + { + name: 'alt', + label: 'Alt Text', + type: 'text', + }, + { + name: 'description', + label: 'Description', + type: 'textarea', + } + ] +} + +export const Documents: CollectionConfig = { + slug: 'documents', + upload: { + staticDir: 'documents', + mimeTypes: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'], + }, + fields: [ + { + name: 'title', + label: 'Title', + type: 'text', + required: true, + }, + { + name: 'description', + label: 'Description', + type: 'textarea', + }, + ] +} + +export const Data: CollectionConfig = { + slug: 'data', + upload: { + staticDir: 'data', + mimeTypes: ['application/json', 'text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain'], + }, + fields: [ + { + name: 'title', + label: 'Title', + type: 'text', + required: true, + }, + { + name: 'description', + label: 'Description', + type: 'textarea', + }, + { + name: 'source', + label: 'Source', + type: 'text', + required: true, + }, + ], +} diff --git a/src/collections/News.ts b/src/collections/News.ts index ba70dfd..cd7ceac 100644 --- a/src/collections/News.ts +++ b/src/collections/News.ts @@ -1,11 +1,6 @@ +import { formatSlug } from "@/lib/slugs"; import { CollectionConfig } from "payload"; -export const formatSlug = (val: string): string => - val - .replace(/ /g, '-') - .replace(/[^\w-]+/g, '') - .toLowerCase() - export const News: CollectionConfig = { slug: 'news', fields: [ @@ -28,18 +23,7 @@ export const News: CollectionConfig = { }, hooks: { beforeValidate: [ - ({data, operation, value}) => { - if (typeof value === 'string') { - return formatSlug(value); - } - if (operation === 'create' || !data?.slug) { - const fallbackData = data?.title; - if (fallbackData && typeof fallbackData === 'string') { - return formatSlug(fallbackData); - } - } - return value; - } + formatSlug('title'), ] } }, diff --git a/src/collections/Projects.ts b/src/collections/Projects.ts new file mode 100644 index 0000000..6b98ea9 --- /dev/null +++ b/src/collections/Projects.ts @@ -0,0 +1,84 @@ +import { formatSlug } from "@/lib/slugs"; +import { CollectionConfig } from "payload"; + +export const Projects: CollectionConfig = { + slug: 'projects', + fields: [ + { + name: 'title', + label: 'Title', + type: 'text', + required: true, + }, + { + name: 'slug', + label: 'Slug', + type: 'text', + required: true, + unique: true, + admin: { + position: 'sidebar', + description: 'The slug is used to identify the news item in the URL.', + // readOnly: true, + }, + hooks: { + beforeValidate: [ + formatSlug('title'), + ] + } + }, + { + name: 'content', + label: 'Content', + type: 'richText', + required: true, + }, + // list of files + // gallery + // keywords + // is featured? + { + name: 'featured', + label: 'Featured', + type: 'checkbox', + defaultValue: false, + admin: { + position: 'sidebar', + description: 'Show this project on the homepage.', + } + }, + // links + { + name: 'links', + label: 'Links', + type: 'array', + fields: [ + { + name: 'link', + label: 'Link', + type: 'text', + required: true, + }, + { + name: 'description', + label: 'Description', + type: 'text', + required: false, + }, + { + name: 'group', + label: 'Group', + type: 'text', + required: false, + admin: { + description: 'Optional: organise link under this heading', + }, + } + ], + admin: { + position: 'sidebar', + + } + }, + ] +} diff --git a/src/lib/slugs.ts b/src/lib/slugs.ts new file mode 100644 index 0000000..8f94e08 --- /dev/null +++ b/src/lib/slugs.ts @@ -0,0 +1,21 @@ +import { FieldHook } from "payload"; + +export const format = (val: string): string => + val + .replace(/ /g, '-') + .replace(/[^\w-]+/g, '') + .toLowerCase() + +export const formatSlug = (name: string): FieldHook => + ({data, operation, value}) => { + if (typeof value === 'string') { + return format(value); + } + if (operation === 'create' || !data?.[name]) { + const fallbackData = data?.[name]; + if (fallbackData && typeof fallbackData === 'string') { + return format(fallbackData); + } + } + return value; + };