diff --git a/apps/backend/src/collections/Portfolio/__tests__/Portfolio.spec.ts b/apps/backend/src/collections/Portfolio/__tests__/Portfolio.spec.ts new file mode 100644 index 0000000..887da53 --- /dev/null +++ b/apps/backend/src/collections/Portfolio/__tests__/Portfolio.spec.ts @@ -0,0 +1,60 @@ +import { Portfolio } from '../index' + +describe('Portfolio Collection', () => { + it('should have correct slug', () => { + expect(Portfolio.slug).toBe('portfolio') + }) + + it('should have all required fields', () => { + const fieldNames = Portfolio.fields.map((f) => ('name' in f ? f.name : null)) + expect(fieldNames).toContain('title') + expect(fieldNames).toContain('slug') + expect(fieldNames).toContain('url') + expect(fieldNames).toContain('image') + expect(fieldNames).toContain('description') + expect(fieldNames).toContain('websiteType') + expect(fieldNames).toContain('tags') + }) + + it('should have correct access control', () => { + expect(Portfolio.access.read).toBeDefined() + expect(Portfolio.access.create).toBeDefined() + expect(Portfolio.access.update).toBeDefined() + expect(Portfolio.access.delete).toBeDefined() + }) + + it('should have admin configuration', () => { + expect(Portfolio.admin).toBeDefined() + expect(Portfolio.admin?.useAsTitle).toBe('title') + expect(Portfolio.admin?.defaultColumns).toEqual(['title', 'websiteType', 'updatedAt']) + }) + + it('should have websiteType field with correct options', () => { + const websiteTypeField = Portfolio.fields.find((f) => f.name === 'websiteType') + expect(websiteTypeField).toBeDefined() + expect(websiteTypeField?.type).toBe('select') + if (websiteTypeField?.type === 'select') { + const optionValues = websiteTypeField.options.map((o) => o.value) + expect(optionValues).toContain('corporate') + expect(optionValues).toContain('ecommerce') + expect(optionValues).toContain('landing') + expect(optionValues).toContain('brand') + expect(optionValues).toContain('other') + } + }) + + it('should have tags field as array', () => { + const tagsField = Portfolio.fields.find((f) => f.name === 'tags') + expect(tagsField).toBeDefined() + expect(tagsField?.type).toBe('array') + }) + + it('should have image field with relation to media', () => { + const imageField = Portfolio.fields.find((f) => f.name === 'image') + expect(imageField).toBeDefined() + expect(imageField?.type).toBe('upload') + if (imageField?.type === 'upload') { + expect(imageField.relationTo).toBe('media') + } + }) +}) diff --git a/apps/backend/src/collections/Portfolio/index.ts b/apps/backend/src/collections/Portfolio/index.ts new file mode 100644 index 0000000..2714028 --- /dev/null +++ b/apps/backend/src/collections/Portfolio/index.ts @@ -0,0 +1,75 @@ +import type { CollectionConfig } from 'payload' + +import { authenticated } from '../../access/authenticated' +import { anyone } from '../../access/anyone' +import { slugField } from '@/fields/slug' + +export const Portfolio: CollectionConfig = { + slug: 'portfolio', + access: { + create: authenticated, + read: anyone, + update: authenticated, + delete: authenticated, + }, + admin: { + useAsTitle: 'title', + defaultColumns: ['title', 'websiteType', 'updatedAt'], + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'url', + type: 'text', + admin: { + description: 'Website URL (e.g., https://example.com)', + }, + }, + { + name: 'image', + type: 'upload', + relationTo: 'media', + required: true, + admin: { + description: 'Preview image stored in R2', + }, + }, + { + name: 'description', + type: 'textarea', + }, + { + name: 'websiteType', + type: 'select', + options: [ + { label: '企業官網', value: 'corporate' }, + { label: '電商網站', value: 'ecommerce' }, + { label: '活動頁面', value: 'landing' }, + { label: '品牌網站', value: 'brand' }, + { label: '其他', value: 'other' }, + ], + required: true, + }, + { + name: 'tags', + type: 'array', + fields: [ + { + name: 'tag', + type: 'text', + }, + ], + }, + ...slugField(), + ], + versions: { + drafts: { + autosave: true, + }, + maxPerDoc: 10, + }, +} diff --git a/apps/backend/src/payload-types.ts b/apps/backend/src/payload-types.ts index 357c855..a076168 100644 --- a/apps/backend/src/payload-types.ts +++ b/apps/backend/src/payload-types.ts @@ -72,9 +72,8 @@ export interface Config { media: Media; categories: Category; users: User; + portfolio: Portfolio; redirects: Redirect; - forms: Form; - 'form-submissions': FormSubmission; search: Search; 'payload-jobs': PayloadJob; 'payload-locked-documents': PayloadLockedDocument; @@ -88,9 +87,8 @@ export interface Config { media: MediaSelect | MediaSelect; categories: CategoriesSelect | CategoriesSelect; users: UsersSelect | UsersSelect; + portfolio: PortfolioSelect | PortfolioSelect; redirects: RedirectsSelect | RedirectsSelect; - forms: FormsSelect | FormsSelect; - 'form-submissions': FormSubmissionsSelect | FormSubmissionsSelect; search: SearchSelect | SearchSelect; 'payload-jobs': PayloadJobsSelect | PayloadJobsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; @@ -154,7 +152,7 @@ export interface Page { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -191,7 +189,7 @@ export interface Page { | null; media?: (string | null) | Media; }; - layout: (CallToActionBlock | ContentBlock | MediaBlock | ArchiveBlock | FormBlock)[]; + layout: (CallToActionBlock | ContentBlock | MediaBlock | ArchiveBlock)[]; meta?: { title?: string | null; /** @@ -219,7 +217,7 @@ export interface Post { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -265,7 +263,7 @@ export interface Media { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -401,7 +399,7 @@ export interface CallToActionBlock { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -452,7 +450,7 @@ export interface ContentBlock { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -509,7 +507,7 @@ export interface ArchiveBlock { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -536,203 +534,32 @@ export interface ArchiveBlock { } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "FormBlock". + * via the `definition` "portfolio". */ -export interface FormBlock { - form: string | Form; - enableIntro?: boolean | null; - introContent?: { - 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; - } | null; - id?: string | null; - blockName?: string | null; - blockType: 'formBlock'; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "forms". - */ -export interface Form { +export interface Portfolio { id: string; title: string; - fields?: - | ( - | { - name: string; - label?: string | null; - width?: number | null; - required?: boolean | null; - defaultValue?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'checkbox'; - } - | { - name: string; - label?: string | null; - width?: number | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'country'; - } - | { - name: string; - label?: string | null; - width?: number | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'email'; - } - | { - message?: { - 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; - } | null; - id?: string | null; - blockName?: string | null; - blockType: 'message'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: number | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'number'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: string | null; - placeholder?: string | null; - options?: - | { - label: string; - value: string; - id?: string | null; - }[] - | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'select'; - } - | { - name: string; - label?: string | null; - width?: number | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'state'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: string | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'text'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: string | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'textarea'; - } - )[] - | null; - submitButtonLabel?: string | null; /** - * Choose whether to display an on-page message or redirect to a different page after they submit the form. + * Website URL (e.g., https://example.com) */ - confirmationType?: ('message' | 'redirect') | null; - confirmationMessage?: { - 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; - } | null; - redirect?: { - url: string; - }; + url?: string | null; /** - * Send custom emails when the form submits. Use comma separated lists to send the same email to multiple recipients. To reference a value from this form, wrap that field's name with double curly brackets, i.e. {{firstName}}. You can use a wildcard {{*}} to output all data and {{*:table}} to format it as an HTML table in the email. + * Preview image stored in R2 */ - emails?: + image: string | Media; + description?: string | null; + websiteType: 'corporate' | 'ecommerce' | 'landing' | 'brand' | 'other'; + tags?: | { - emailTo?: string | null; - cc?: string | null; - bcc?: string | null; - replyTo?: string | null; - emailFrom?: string | null; - subject: string; - /** - * Enter the message that should be sent in this email. - */ - message?: { - 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; - } | null; + tag?: string | null; id?: string | null; }[] | null; + slug?: string | null; + slugLock?: boolean | null; updatedAt: string; createdAt: string; + _status?: ('draft' | 'published') | null; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -760,23 +587,6 @@ export interface Redirect { updatedAt: string; createdAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "form-submissions". - */ -export interface FormSubmission { - id: string; - form: string | Form; - submissionData?: - | { - field: string; - value: string; - id?: string | null; - }[] - | null; - updatedAt: string; - createdAt: string; -} /** * This is a collection of automatically created search results. These results are used by the global site search and will be updated automatically as documents in the CMS are created or updated. * @@ -927,18 +737,14 @@ export interface PayloadLockedDocument { relationTo: 'users'; value: string | User; } | null) + | ({ + relationTo: 'portfolio'; + value: string | Portfolio; + } | null) | ({ relationTo: 'redirects'; value: string | Redirect; } | null) - | ({ - relationTo: 'forms'; - value: string | Form; - } | null) - | ({ - relationTo: 'form-submissions'; - value: string | FormSubmission; - } | null) | ({ relationTo: 'search'; value: string | Search; @@ -1024,7 +830,6 @@ export interface PagesSelect { content?: T | ContentBlockSelect; mediaBlock?: T | MediaBlockSelect; archive?: T | ArchiveBlockSelect; - formBlock?: T | FormBlockSelect; }; meta?: | T @@ -1113,17 +918,6 @@ export interface ArchiveBlockSelect { id?: T; blockName?: T; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "FormBlock_select". - */ -export interface FormBlockSelect { - form?: T; - enableIntro?: T; - introContent?: T; - id?: T; - blockName?: T; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts_select". @@ -1291,6 +1085,28 @@ export interface UsersSelect { expiresAt?: T; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "portfolio_select". + */ +export interface PortfolioSelect { + title?: T; + url?: T; + image?: T; + description?: T; + websiteType?: T; + tags?: + | T + | { + tag?: T; + id?: T; + }; + slug?: T; + slugLock?: T; + updatedAt?: T; + createdAt?: T; + _status?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "redirects_select". @@ -1307,155 +1123,6 @@ export interface RedirectsSelect { updatedAt?: T; createdAt?: T; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "forms_select". - */ -export interface FormsSelect { - title?: T; - fields?: - | T - | { - checkbox?: - | T - | { - name?: T; - label?: T; - width?: T; - required?: T; - defaultValue?: T; - id?: T; - blockName?: T; - }; - country?: - | T - | { - name?: T; - label?: T; - width?: T; - required?: T; - id?: T; - blockName?: T; - }; - email?: - | T - | { - name?: T; - label?: T; - width?: T; - required?: T; - id?: T; - blockName?: T; - }; - message?: - | T - | { - message?: T; - id?: T; - blockName?: T; - }; - number?: - | T - | { - name?: T; - label?: T; - width?: T; - defaultValue?: T; - required?: T; - id?: T; - blockName?: T; - }; - select?: - | T - | { - name?: T; - label?: T; - width?: T; - defaultValue?: T; - placeholder?: T; - options?: - | T - | { - label?: T; - value?: T; - id?: T; - }; - required?: T; - id?: T; - blockName?: T; - }; - state?: - | T - | { - name?: T; - label?: T; - width?: T; - required?: T; - id?: T; - blockName?: T; - }; - text?: - | T - | { - name?: T; - label?: T; - width?: T; - defaultValue?: T; - required?: T; - id?: T; - blockName?: T; - }; - textarea?: - | T - | { - name?: T; - label?: T; - width?: T; - defaultValue?: T; - required?: T; - id?: T; - blockName?: T; - }; - }; - submitButtonLabel?: T; - confirmationType?: T; - confirmationMessage?: T; - redirect?: - | T - | { - url?: T; - }; - emails?: - | T - | { - emailTo?: T; - cc?: T; - bcc?: T; - replyTo?: T; - emailFrom?: T; - subject?: T; - message?: T; - id?: T; - }; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "form-submissions_select". - */ -export interface FormSubmissionsSelect { - form?: T; - submissionData?: - | T - | { - field?: T; - value?: T; - id?: T; - }; - updatedAt?: T; - createdAt?: T; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "search_select". @@ -1716,7 +1383,7 @@ export interface BannerBlock { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; diff --git a/apps/backend/src/payload.config.ts b/apps/backend/src/payload.config.ts index 97db0e5..315666e 100644 --- a/apps/backend/src/payload.config.ts +++ b/apps/backend/src/payload.config.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from 'url' import { Categories } from './collections/Categories' import { Media } from './collections/Media' import { Pages } from './collections/Pages' +import { Portfolio } from './collections/Portfolio' import { Posts } from './collections/Posts' import { Users } from './collections/Users' import { Footer } from './Footer/config' @@ -62,7 +63,7 @@ export default buildConfig({ db: mongooseAdapter({ url: process.env.DATABASE_URI || '', }), - collections: [Pages, Posts, Media, Categories, Users], + collections: [Pages, Posts, Media, Categories, Users, Portfolio], cors: [ getServerSideURL(), 'http://localhost:4321', // Astro dev server