feat: initial commit
This commit is contained in:
commit
44699d4fed
519
.cursor/rules/access-control-advanced.md
Normal file
519
.cursor/rules/access-control-advanced.md
Normal file
@ -0,0 +1,519 @@
|
||||
---
|
||||
title: Access Control - Advanced Patterns
|
||||
description: Context-aware, time-based, subscription-based access, factory functions, templates
|
||||
tags: [payload, access-control, security, advanced, performance]
|
||||
priority: high
|
||||
---
|
||||
|
||||
# Advanced Access Control Patterns
|
||||
|
||||
Advanced access control patterns including context-aware access, time-based restrictions, factory functions, and production templates.
|
||||
|
||||
## Context-Aware Access Patterns
|
||||
|
||||
### Locale-Specific Access
|
||||
|
||||
```typescript
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const localeSpecificAccess: Access = ({ req: { user, locale } }) => {
|
||||
// Authenticated users can access all locales
|
||||
if (user) return true
|
||||
|
||||
// Public users can only access English content
|
||||
if (locale === 'en') return true
|
||||
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
### Device-Specific Access
|
||||
|
||||
```typescript
|
||||
export const mobileOnlyAccess: Access = ({ req: { headers } }) => {
|
||||
const userAgent = headers?.get('user-agent') || ''
|
||||
return /mobile|android|iphone/i.test(userAgent)
|
||||
}
|
||||
|
||||
export const desktopOnlyAccess: Access = ({ req: { headers } }) => {
|
||||
const userAgent = headers?.get('user-agent') || ''
|
||||
return !/mobile|android|iphone/i.test(userAgent)
|
||||
}
|
||||
```
|
||||
|
||||
### IP-Based Access
|
||||
|
||||
```typescript
|
||||
export const restrictedIpAccess = (allowedIps: string[]): Access => {
|
||||
return ({ req: { headers } }) => {
|
||||
const ip = headers?.get('x-forwarded-for') || headers?.get('x-real-ip')
|
||||
return allowedIps.includes(ip || '')
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const internalIps = ['192.168.1.0/24', '10.0.0.5']
|
||||
|
||||
export const InternalDocs: CollectionConfig = {
|
||||
slug: 'internal-docs',
|
||||
access: {
|
||||
read: restrictedIpAccess(internalIps),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Time-Based Access Patterns
|
||||
|
||||
### Today's Records Only
|
||||
|
||||
```typescript
|
||||
export const todayOnlyAccess: Access = ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
|
||||
const now = new Date()
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)
|
||||
|
||||
return {
|
||||
createdAt: {
|
||||
greater_than_equal: startOfDay.toISOString(),
|
||||
less_than: endOfDay.toISOString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Recent Records (Last N Days)
|
||||
|
||||
```typescript
|
||||
export const recentRecordsAccess = (days: number): Access => {
|
||||
return ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - days)
|
||||
|
||||
return {
|
||||
createdAt: {
|
||||
greater_than_equal: cutoff.toISOString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: Users see only last 30 days, admins see all
|
||||
export const Logs: CollectionConfig = {
|
||||
slug: 'logs',
|
||||
access: {
|
||||
read: recentRecordsAccess(30),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Scheduled Content (Publish Date Range)
|
||||
|
||||
```typescript
|
||||
export const scheduledContentAccess: Access = ({ req: { user } }) => {
|
||||
// Editors see all content
|
||||
if (user?.roles?.includes('admin') || user?.roles?.includes('editor')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Public sees only content within publish window
|
||||
return {
|
||||
and: [
|
||||
{ publishDate: { less_than_equal: now } },
|
||||
{
|
||||
or: [{ unpublishDate: { exists: false } }, { unpublishDate: { greater_than: now } }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Subscription-Based Access
|
||||
|
||||
### Active Subscription Required
|
||||
|
||||
```typescript
|
||||
export const activeSubscriptionAccess: Access = async ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
try {
|
||||
const subscription = await req.payload.findByID({
|
||||
collection: 'subscriptions',
|
||||
id: user.subscriptionId,
|
||||
})
|
||||
|
||||
return subscription?.status === 'active'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Subscription Tier-Based Access
|
||||
|
||||
```typescript
|
||||
export const tierBasedAccess = (requiredTier: string): Access => {
|
||||
const tierHierarchy = ['free', 'basic', 'pro', 'enterprise']
|
||||
|
||||
return async ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
try {
|
||||
const subscription = await req.payload.findByID({
|
||||
collection: 'subscriptions',
|
||||
id: user.subscriptionId,
|
||||
})
|
||||
|
||||
if (subscription?.status !== 'active') return false
|
||||
|
||||
const userTierIndex = tierHierarchy.indexOf(subscription.tier)
|
||||
const requiredTierIndex = tierHierarchy.indexOf(requiredTier)
|
||||
|
||||
return userTierIndex >= requiredTierIndex
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
export const EnterpriseFeatures: CollectionConfig = {
|
||||
slug: 'enterprise-features',
|
||||
access: {
|
||||
read: tierBasedAccess('enterprise'),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Factory Functions
|
||||
|
||||
### createRoleBasedAccess
|
||||
|
||||
```typescript
|
||||
export function createRoleBasedAccess(roles: string[]): Access {
|
||||
return ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
return roles.some((role) => user.roles?.includes(role))
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const adminOrEditor = createRoleBasedAccess(['admin', 'editor'])
|
||||
const moderatorAccess = createRoleBasedAccess(['admin', 'moderator'])
|
||||
```
|
||||
|
||||
### createOrgScopedAccess
|
||||
|
||||
```typescript
|
||||
export function createOrgScopedAccess(allowAdmin = true): Access {
|
||||
return ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (allowAdmin && user.roles?.includes('admin')) return true
|
||||
|
||||
return {
|
||||
organizationId: { in: user.organizationIds || [] },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const orgScoped = createOrgScopedAccess() // Admins bypass
|
||||
const strictOrgScoped = createOrgScopedAccess(false) // Admins also scoped
|
||||
```
|
||||
|
||||
### createTeamBasedAccess
|
||||
|
||||
```typescript
|
||||
export function createTeamBasedAccess(teamField = 'teamId'): Access {
|
||||
return ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
return {
|
||||
[teamField]: { in: user.teamIds || [] },
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### createTimeLimitedAccess
|
||||
|
||||
```typescript
|
||||
export function createTimeLimitedAccess(daysAccess: number): Access {
|
||||
return ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - daysAccess)
|
||||
|
||||
return {
|
||||
createdAt: {
|
||||
greater_than_equal: cutoff.toISOString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Templates
|
||||
|
||||
### Public + Authenticated Collection
|
||||
|
||||
```typescript
|
||||
export const PublicAuthCollection: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
access: {
|
||||
// Only admins/editors can create
|
||||
create: ({ req: { user } }) => {
|
||||
return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
|
||||
},
|
||||
|
||||
// Authenticated users see all, public sees only published
|
||||
read: ({ req: { user } }) => {
|
||||
if (user) return true
|
||||
return { _status: { equals: 'published' } }
|
||||
},
|
||||
|
||||
// Only admins/editors can update
|
||||
update: ({ req: { user } }) => {
|
||||
return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
|
||||
},
|
||||
|
||||
// Only admins can delete
|
||||
delete: ({ req: { user } }) => {
|
||||
return user?.roles?.includes('admin') || false
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'content', type: 'richText', required: true },
|
||||
{ name: 'author', type: 'relationship', relationTo: 'users' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Self-Service Collection
|
||||
|
||||
```typescript
|
||||
export const SelfServiceCollection: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
access: {
|
||||
// Admins can create users
|
||||
create: ({ req: { user } }) => user?.roles?.includes('admin') || false,
|
||||
|
||||
// Anyone can read user profiles
|
||||
read: () => true,
|
||||
|
||||
// Users can update self, admins can update anyone
|
||||
update: ({ req: { user }, id }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
return user.id === id
|
||||
},
|
||||
|
||||
// Only admins can delete
|
||||
delete: ({ req: { user } }) => user?.roles?.includes('admin') || false,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'email', type: 'email', required: true },
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['admin', 'editor', 'user'],
|
||||
access: {
|
||||
// Only admins can read/update roles
|
||||
read: ({ req: { user } }) => user?.roles?.includes('admin') || false,
|
||||
update: ({ req: { user } }) => user?.roles?.includes('admin') || false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Avoid Async Operations in Hot Paths
|
||||
|
||||
```typescript
|
||||
// ❌ Slow: Multiple sequential async calls
|
||||
export const slowAccess: Access = async ({ req: { user } }) => {
|
||||
const org = await req.payload.findByID({ collection: 'orgs', id: user.orgId })
|
||||
const team = await req.payload.findByID({ collection: 'teams', id: user.teamId })
|
||||
const subscription = await req.payload.findByID({ collection: 'subs', id: user.subId })
|
||||
|
||||
return org.active && team.active && subscription.active
|
||||
}
|
||||
|
||||
// ✅ Fast: Use query constraints or cache in context
|
||||
export const fastAccess: Access = ({ req: { user, context } }) => {
|
||||
// Cache expensive lookups
|
||||
if (!context.orgStatus) {
|
||||
context.orgStatus = checkOrgStatus(user.orgId)
|
||||
}
|
||||
|
||||
return context.orgStatus
|
||||
}
|
||||
```
|
||||
|
||||
### Query Constraint Optimization
|
||||
|
||||
```typescript
|
||||
// ❌ Avoid: Non-indexed fields in constraints
|
||||
export const slowQuery: Access = () => ({
|
||||
'metadata.internalCode': { equals: 'ABC123' }, // Slow if not indexed
|
||||
})
|
||||
|
||||
// ✅ Better: Use indexed fields
|
||||
export const fastQuery: Access = () => ({
|
||||
status: { equals: 'active' }, // Indexed field
|
||||
organizationId: { in: ['org1', 'org2'] }, // Indexed field
|
||||
})
|
||||
```
|
||||
|
||||
### Field Access on Large Arrays
|
||||
|
||||
```typescript
|
||||
// ❌ Slow: Complex access on array fields
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'secretData',
|
||||
type: 'text',
|
||||
access: {
|
||||
read: async ({ req }) => {
|
||||
// Async call runs for EVERY array item
|
||||
const result = await expensiveCheck()
|
||||
return result
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ✅ Fast: Simple checks or cache result
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'secretData',
|
||||
type: 'text',
|
||||
access: {
|
||||
read: ({ req: { user }, context }) => {
|
||||
// Cache once, reuse for all items
|
||||
if (context.canReadSecret === undefined) {
|
||||
context.canReadSecret = user?.roles?.includes('admin')
|
||||
}
|
||||
return context.canReadSecret
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid N+1 Queries
|
||||
|
||||
```typescript
|
||||
// ❌ N+1 Problem: Query per access check
|
||||
export const n1Access: Access = async ({ req, id }) => {
|
||||
// Runs for EACH document in list
|
||||
const doc = await req.payload.findByID({ collection: 'docs', id })
|
||||
return doc.isPublic
|
||||
}
|
||||
|
||||
// ✅ Better: Use query constraint to filter at DB level
|
||||
export const efficientAccess: Access = () => {
|
||||
return { isPublic: { equals: true } }
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Log Access Check Execution
|
||||
|
||||
```typescript
|
||||
export const debugAccess: Access = ({ req: { user }, id }) => {
|
||||
console.log('Access check:', {
|
||||
userId: user?.id,
|
||||
userRoles: user?.roles,
|
||||
docId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
### Verify Arguments Availability
|
||||
|
||||
```typescript
|
||||
export const checkArgsAccess: Access = (args) => {
|
||||
console.log('Available arguments:', {
|
||||
hasReq: 'req' in args,
|
||||
hasUser: args.req?.user ? 'yes' : 'no',
|
||||
hasId: args.id ? 'provided' : 'undefined',
|
||||
hasData: args.data ? 'provided' : 'undefined',
|
||||
})
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
### Test Access Without User
|
||||
|
||||
```typescript
|
||||
// In test/development
|
||||
const testAccess = await payload.find({
|
||||
collection: 'posts',
|
||||
overrideAccess: false, // Enforce access control
|
||||
user: undefined, // Simulate no user
|
||||
})
|
||||
|
||||
console.log('Public access result:', testAccess.docs.length)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Default Deny**: Start with restrictive access, gradually add permissions
|
||||
2. **Type Guards**: Use TypeScript for user type safety
|
||||
3. **Validate Data**: Never trust frontend-provided IDs or data
|
||||
4. **Async for Critical Checks**: Use async operations for important security decisions
|
||||
5. **Consistent Logic**: Apply same rules at field and collection levels
|
||||
6. **Test Edge Cases**: Test with no user, wrong user, admin user scenarios
|
||||
7. **Monitor Access**: Log failed access attempts for security review
|
||||
8. **Regular Audit**: Review access rules quarterly or after major changes
|
||||
9. **Cache Wisely**: Use `req.context` for expensive operations
|
||||
10. **Document Intent**: Add comments explaining complex access rules
|
||||
11. **Avoid Secrets in Client**: Never expose sensitive logic to client-side
|
||||
12. **Handle Errors Gracefully**: Access functions should return `false` on error, not throw
|
||||
13. **Test Local API**: Remember to set `overrideAccess: false` when testing
|
||||
14. **Consider Performance**: Measure impact of async operations
|
||||
15. **Principle of Least Privilege**: Grant minimum access required
|
||||
|
||||
## Performance Summary
|
||||
|
||||
**Minimize Async Operations**: Use query constraints over async lookups when possible
|
||||
|
||||
**Cache Expensive Checks**: Store results in `req.context` for reuse
|
||||
|
||||
**Index Query Fields**: Ensure fields in query constraints are indexed
|
||||
|
||||
**Avoid Complex Logic in Array Fields**: Simple boolean checks preferred
|
||||
|
||||
**Use Query Constraints**: Let database filter rather than loading all records
|
||||
225
.cursor/rules/access-control.md
Normal file
225
.cursor/rules/access-control.md
Normal file
@ -0,0 +1,225 @@
|
||||
---
|
||||
title: Access Control
|
||||
description: Collection, field, and global access control patterns
|
||||
tags: [payload, access-control, security, permissions, rbac]
|
||||
---
|
||||
|
||||
# Payload CMS Access Control
|
||||
|
||||
## Access Control Layers
|
||||
|
||||
1. **Collection-Level**: Controls operations on entire documents (create, read, update, delete, admin)
|
||||
2. **Field-Level**: Controls access to individual fields (create, read, update)
|
||||
3. **Global-Level**: Controls access to global documents (read, update)
|
||||
|
||||
## Collection Access Control
|
||||
|
||||
```typescript
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
access: {
|
||||
// Boolean: Only authenticated users can create
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
|
||||
// Query constraint: Public sees published, users see all
|
||||
read: ({ req: { user } }) => {
|
||||
if (user) return true
|
||||
return { status: { equals: 'published' } }
|
||||
},
|
||||
|
||||
// User-specific: Admins or document owner
|
||||
update: ({ req: { user }, id }) => {
|
||||
if (user?.roles?.includes('admin')) return true
|
||||
return { author: { equals: user?.id } }
|
||||
},
|
||||
|
||||
// Async: Check related data
|
||||
delete: async ({ req, id }) => {
|
||||
const hasComments = await req.payload.count({
|
||||
collection: 'comments',
|
||||
where: { post: { equals: id } },
|
||||
})
|
||||
return hasComments === 0
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Common Access Patterns
|
||||
|
||||
```typescript
|
||||
// Anyone
|
||||
export const anyone: Access = () => true
|
||||
|
||||
// Authenticated only
|
||||
export const authenticated: Access = ({ req: { user } }) => Boolean(user)
|
||||
|
||||
// Admin only
|
||||
export const adminOnly: Access = ({ req: { user } }) => {
|
||||
return user?.roles?.includes('admin')
|
||||
}
|
||||
|
||||
// Admin or self
|
||||
export const adminOrSelf: Access = ({ req: { user } }) => {
|
||||
if (user?.roles?.includes('admin')) return true
|
||||
return { id: { equals: user?.id } }
|
||||
}
|
||||
|
||||
// Published or authenticated
|
||||
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
|
||||
if (user) return true
|
||||
return { _status: { equals: 'published' } }
|
||||
}
|
||||
```
|
||||
|
||||
## Row-Level Security
|
||||
|
||||
```typescript
|
||||
// Organization-scoped access
|
||||
export const organizationScoped: Access = ({ req: { user } }) => {
|
||||
if (user?.roles?.includes('admin')) return true
|
||||
|
||||
// Users see only their organization's data
|
||||
return {
|
||||
organization: {
|
||||
equals: user?.organization,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Team-based access
|
||||
export const teamMemberAccess: Access = ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
return {
|
||||
'team.members': {
|
||||
contains: user.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Field Access Control
|
||||
|
||||
**Field access ONLY returns boolean** (no query constraints).
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'salary',
|
||||
type: 'number',
|
||||
access: {
|
||||
read: ({ req: { user }, doc }) => {
|
||||
// Self can read own salary
|
||||
if (user?.id === doc?.id) return true
|
||||
// Admin can read all
|
||||
return user?.roles?.includes('admin')
|
||||
},
|
||||
update: ({ req: { user } }) => {
|
||||
// Only admins can update
|
||||
return user?.roles?.includes('admin')
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## RBAC Pattern
|
||||
|
||||
Payload does NOT provide a roles system by default. Add a `roles` field to your auth collection:
|
||||
|
||||
```typescript
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['admin', 'editor', 'user'],
|
||||
defaultValue: ['user'],
|
||||
required: true,
|
||||
saveToJWT: true, // Include in JWT for fast access checks
|
||||
access: {
|
||||
update: ({ req: { user } }) => user?.roles?.includes('admin'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Tenant Access Control
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
id: string
|
||||
tenantId: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
const tenantAccess: Access = ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('super-admin')) return true
|
||||
|
||||
return {
|
||||
tenant: {
|
||||
equals: (user as User).tenantId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
access: {
|
||||
create: tenantAccess,
|
||||
read: tenantAccess,
|
||||
update: tenantAccess,
|
||||
delete: tenantAccess,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'tenant',
|
||||
type: 'text',
|
||||
required: true,
|
||||
access: {
|
||||
update: ({ req: { user } }) => user?.roles?.includes('super-admin'),
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ req, operation, value }) => {
|
||||
if (operation === 'create' && !value) {
|
||||
return (req.user as User)?.tenantId
|
||||
}
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Local API Default**: Access control is **skipped by default** in Local API (`overrideAccess: true`). When passing a `user` parameter, you must set `overrideAccess: false`:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Passes user but bypasses access control
|
||||
await payload.find({
|
||||
collection: 'posts',
|
||||
user: someUser,
|
||||
})
|
||||
|
||||
// ✅ CORRECT: Respects the user's permissions
|
||||
await payload.find({
|
||||
collection: 'posts',
|
||||
user: someUser,
|
||||
overrideAccess: false, // Required to enforce access control
|
||||
})
|
||||
```
|
||||
|
||||
2. **Field Access Limitations**: Field-level access does NOT support query constraints - only boolean returns.
|
||||
|
||||
3. **Admin Panel Visibility**: The `admin` access control determines if a collection appears in the admin panel for a user.
|
||||
209
.cursor/rules/adapters.md
Normal file
209
.cursor/rules/adapters.md
Normal file
@ -0,0 +1,209 @@
|
||||
---
|
||||
title: Database Adapters & Transactions
|
||||
description: Database adapters, storage, email, and transaction patterns
|
||||
tags: [payload, database, mongodb, postgres, sqlite, transactions]
|
||||
---
|
||||
|
||||
# Payload CMS Adapters
|
||||
|
||||
## Database Adapters
|
||||
|
||||
### MongoDB
|
||||
|
||||
```typescript
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
|
||||
export default buildConfig({
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URL,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
### Postgres
|
||||
|
||||
```typescript
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres'
|
||||
|
||||
export default buildConfig({
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
},
|
||||
push: false, // Don't auto-push schema changes
|
||||
migrationDir: './migrations',
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```typescript
|
||||
import { sqliteAdapter } from '@payloadcms/db-sqlite'
|
||||
|
||||
export default buildConfig({
|
||||
db: sqliteAdapter({
|
||||
client: {
|
||||
url: 'file:./payload.db',
|
||||
},
|
||||
transactionOptions: {}, // Enable transactions (disabled by default)
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
## Transactions
|
||||
|
||||
Payload automatically uses transactions for all-or-nothing database operations.
|
||||
|
||||
### Threading req Through Operations
|
||||
|
||||
**CRITICAL**: When performing nested operations in hooks, always pass `req` to maintain transaction context.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT: Thread req through nested operations
|
||||
const resaveChildren: CollectionAfterChangeHook = async ({ collection, doc, req }) => {
|
||||
// Find children - pass req
|
||||
const children = await req.payload.find({
|
||||
collection: 'children',
|
||||
where: { parent: { equals: doc.id } },
|
||||
req, // Maintains transaction context
|
||||
})
|
||||
|
||||
// Update each child - pass req
|
||||
for (const child of children.docs) {
|
||||
await req.payload.update({
|
||||
id: child.id,
|
||||
collection: 'children',
|
||||
data: { updatedField: 'value' },
|
||||
req, // Same transaction as parent operation
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ WRONG: Missing req breaks transaction
|
||||
const brokenHook: CollectionAfterChangeHook = async ({ collection, doc, req }) => {
|
||||
const children = await req.payload.find({
|
||||
collection: 'children',
|
||||
where: { parent: { equals: doc.id } },
|
||||
// Missing req - separate transaction or no transaction
|
||||
})
|
||||
|
||||
for (const child of children.docs) {
|
||||
await req.payload.update({
|
||||
id: child.id,
|
||||
collection: 'children',
|
||||
data: { updatedField: 'value' },
|
||||
// Missing req - if parent operation fails, these updates persist
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Matters:**
|
||||
|
||||
- **MongoDB (with replica sets)**: Creates atomic session across operations
|
||||
- **PostgreSQL**: All operations use same Drizzle transaction
|
||||
- **SQLite (with transactions enabled)**: Ensures rollback on errors
|
||||
- **Without req**: Each operation runs independently, breaking atomicity
|
||||
|
||||
### Manual Transaction Control
|
||||
|
||||
```typescript
|
||||
const transactionID = await payload.db.beginTransaction()
|
||||
try {
|
||||
await payload.create({
|
||||
collection: 'orders',
|
||||
data: orderData,
|
||||
req: { transactionID },
|
||||
})
|
||||
await payload.update({
|
||||
collection: 'inventory',
|
||||
id: itemId,
|
||||
data: { stock: newStock },
|
||||
req: { transactionID },
|
||||
})
|
||||
await payload.db.commitTransaction(transactionID)
|
||||
} catch (error) {
|
||||
await payload.db.rollbackTransaction(transactionID)
|
||||
throw error
|
||||
}
|
||||
```
|
||||
|
||||
## Storage Adapters
|
||||
|
||||
Available storage adapters:
|
||||
|
||||
- **@payloadcms/storage-s3** - AWS S3
|
||||
- **@payloadcms/storage-azure** - Azure Blob Storage
|
||||
- **@payloadcms/storage-gcs** - Google Cloud Storage
|
||||
- **@payloadcms/storage-r2** - Cloudflare R2
|
||||
- **@payloadcms/storage-vercel-blob** - Vercel Blob
|
||||
- **@payloadcms/storage-uploadthing** - Uploadthing
|
||||
|
||||
### AWS S3
|
||||
|
||||
```typescript
|
||||
import { s3Storage } from '@payloadcms/storage-s3'
|
||||
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
s3Storage({
|
||||
collections: {
|
||||
media: true,
|
||||
},
|
||||
bucket: process.env.S3_BUCKET,
|
||||
config: {
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
region: process.env.S3_REGION,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Email Adapters
|
||||
|
||||
### Nodemailer (SMTP)
|
||||
|
||||
```typescript
|
||||
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
|
||||
|
||||
export default buildConfig({
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress: 'noreply@example.com',
|
||||
defaultFromName: 'My App',
|
||||
transportOptions: {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: 587,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
### Resend
|
||||
|
||||
```typescript
|
||||
import { resendAdapter } from '@payloadcms/email-resend'
|
||||
|
||||
export default buildConfig({
|
||||
email: resendAdapter({
|
||||
defaultFromAddress: 'noreply@example.com',
|
||||
defaultFromName: 'My App',
|
||||
apiKey: process.env.RESEND_API_KEY,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **MongoDB Transactions**: Require replica set configuration
|
||||
2. **SQLite Transactions**: Disabled by default, enable with `transactionOptions: {}`
|
||||
3. **Pass req**: Always pass `req` to nested operations in hooks for transaction safety
|
||||
4. **Point Fields**: Not supported in SQLite
|
||||
171
.cursor/rules/collections.md
Normal file
171
.cursor/rules/collections.md
Normal file
@ -0,0 +1,171 @@
|
||||
---
|
||||
title: Collections
|
||||
description: Collection configurations and patterns
|
||||
tags: [payload, collections, auth, upload, drafts]
|
||||
---
|
||||
|
||||
# Payload CMS Collections
|
||||
|
||||
## Basic Collection
|
||||
|
||||
```typescript
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'author', 'status', 'createdAt'],
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'slug', type: 'text', unique: true, index: true },
|
||||
{ name: 'content', type: 'richText' },
|
||||
{ name: 'author', type: 'relationship', relationTo: 'users' },
|
||||
],
|
||||
timestamps: true,
|
||||
}
|
||||
```
|
||||
|
||||
## Auth Collection with RBAC
|
||||
|
||||
```typescript
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['admin', 'editor', 'user'],
|
||||
defaultValue: ['user'],
|
||||
required: true,
|
||||
saveToJWT: true, // Include in JWT for fast access checks
|
||||
access: {
|
||||
update: ({ req: { user } }) => user?.roles?.includes('admin'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Upload Collection
|
||||
|
||||
```typescript
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
upload: {
|
||||
staticDir: 'media',
|
||||
mimeTypes: ['image/*'],
|
||||
imageSizes: [
|
||||
{
|
||||
name: 'thumbnail',
|
||||
width: 400,
|
||||
height: 300,
|
||||
position: 'centre',
|
||||
},
|
||||
{
|
||||
name: 'card',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
adminThumbnail: 'thumbnail',
|
||||
focalPoint: true,
|
||||
crop: true,
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Versioning & Drafts
|
||||
|
||||
```typescript
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: true,
|
||||
schedulePublish: true,
|
||||
validate: false, // Don't validate drafts
|
||||
},
|
||||
maxPerDoc: 100,
|
||||
},
|
||||
access: {
|
||||
read: ({ req: { user } }) => {
|
||||
// Public sees only published
|
||||
if (!user) return { _status: { equals: 'published' } }
|
||||
// Authenticated sees all
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Draft API Usage
|
||||
|
||||
```typescript
|
||||
// Create draft
|
||||
await payload.create({
|
||||
collection: 'posts',
|
||||
data: { title: 'Draft Post' },
|
||||
draft: true, // Skips required field validation
|
||||
})
|
||||
|
||||
// Read with drafts
|
||||
const page = await payload.findByID({
|
||||
collection: 'pages',
|
||||
id: '123',
|
||||
draft: true, // Returns draft version if exists
|
||||
})
|
||||
```
|
||||
|
||||
## Globals
|
||||
|
||||
Globals are single-instance documents (not collections).
|
||||
|
||||
```typescript
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
export const Header: GlobalConfig = {
|
||||
slug: 'header',
|
||||
label: 'Header',
|
||||
admin: {
|
||||
group: 'Settings',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'logo',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'nav',
|
||||
type: 'array',
|
||||
maxRows: 8,
|
||||
fields: [
|
||||
{
|
||||
name: 'link',
|
||||
type: 'relationship',
|
||||
relationTo: 'pages',
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
794
.cursor/rules/components.md
Normal file
794
.cursor/rules/components.md
Normal file
@ -0,0 +1,794 @@
|
||||
# Custom Components in Payload CMS
|
||||
|
||||
Custom Components allow you to fully customize the Admin Panel by swapping in your own React components. You can replace nearly every part of the interface or add entirely new functionality.
|
||||
|
||||
## Component Types
|
||||
|
||||
There are four main types of Custom Components:
|
||||
|
||||
1. **Root Components** - Affect the Admin Panel globally (logo, nav, header)
|
||||
2. **Collection Components** - Specific to collection views
|
||||
3. **Global Components** - Specific to global document views
|
||||
4. **Field Components** - Custom field UI and cells
|
||||
|
||||
## Defining Custom Components
|
||||
|
||||
### Component Paths
|
||||
|
||||
Components are defined using file paths (not direct imports) to keep the config lightweight and Node.js compatible.
|
||||
|
||||
```typescript
|
||||
import { buildConfig } from 'payload'
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
components: {
|
||||
logout: {
|
||||
Button: '/src/components/Logout#MyComponent', // Named export
|
||||
},
|
||||
Nav: '/src/components/Nav', // Default export
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Component Path Rules:**
|
||||
|
||||
1. Paths are relative to project root (or `config.admin.importMap.baseDir`)
|
||||
2. For **named exports**: append `#ExportName` or use `exportName` property
|
||||
3. For **default exports**: no suffix needed
|
||||
4. File extensions can be omitted
|
||||
|
||||
### Component Config Object
|
||||
|
||||
Instead of a string path, you can pass a config object:
|
||||
|
||||
```typescript
|
||||
{
|
||||
logout: {
|
||||
Button: {
|
||||
path: '/src/components/Logout',
|
||||
exportName: 'MyComponent',
|
||||
clientProps: { customProp: 'value' },
|
||||
serverProps: { asyncData: someData },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Config Properties:**
|
||||
|
||||
| Property | Description |
|
||||
| ------------- | ----------------------------------------------------- |
|
||||
| `path` | File path to component (named exports via `#`) |
|
||||
| `exportName` | Named export (alternative to `#` in path) |
|
||||
| `clientProps` | Props for Client Components (must be serializable) |
|
||||
| `serverProps` | Props for Server Components (can be non-serializable) |
|
||||
|
||||
### Setting Base Directory
|
||||
|
||||
```typescript
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname, 'src'), // Set base directory
|
||||
},
|
||||
components: {
|
||||
Nav: '/components/Nav', // Now relative to src/
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Server vs Client Components
|
||||
|
||||
**All components are React Server Components by default.**
|
||||
|
||||
### Server Components (Default)
|
||||
|
||||
Can use Local API directly, perform async operations, and access full Payload instance.
|
||||
|
||||
```tsx
|
||||
import React from 'react'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
async function MyServerComponent({ payload }: { payload: Payload }) {
|
||||
const page = await payload.findByID({
|
||||
collection: 'pages',
|
||||
id: '123',
|
||||
})
|
||||
|
||||
return <p>{page.title}</p>
|
||||
}
|
||||
|
||||
export default MyServerComponent
|
||||
```
|
||||
|
||||
### Client Components
|
||||
|
||||
Use the `'use client'` directive for interactivity, hooks, state, etc.
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export function MyClientComponent() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Client Components cannot receive non-serializable props (functions, class instances, etc.). Payload automatically strips these when passing to client components.
|
||||
|
||||
## Default Props
|
||||
|
||||
All Custom Components receive these props by default:
|
||||
|
||||
| Prop | Description | Type |
|
||||
| --------- | ---------------------------------------- | --------- |
|
||||
| `payload` | Payload instance (Local API access) | `Payload` |
|
||||
| `i18n` | Internationalization object | `I18n` |
|
||||
| `locale` | Current locale (if localization enabled) | `string` |
|
||||
|
||||
**Server Component Example:**
|
||||
|
||||
```tsx
|
||||
async function MyComponent({ payload, i18n, locale }) {
|
||||
const data = await payload.find({
|
||||
collection: 'posts',
|
||||
locale,
|
||||
})
|
||||
|
||||
return <div>{data.docs.length} posts</div>
|
||||
}
|
||||
```
|
||||
|
||||
**Client Component Example:**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { usePayload, useLocale, useTranslation } from '@payloadcms/ui'
|
||||
|
||||
export function MyComponent() {
|
||||
// Access via hooks in client components
|
||||
const { getLocal, getByID } = usePayload()
|
||||
const locale = useLocale()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
return <div>{t('myKey')}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Props
|
||||
|
||||
Pass additional props using `clientProps` or `serverProps`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
logout: {
|
||||
Button: {
|
||||
path: '/components/Logout',
|
||||
clientProps: {
|
||||
buttonText: 'Sign Out',
|
||||
onLogout: () => console.log('Logged out'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Receive in component:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
export function Logout({ buttonText, onLogout }) {
|
||||
return <button onClick={onLogout}>{buttonText}</button>
|
||||
}
|
||||
```
|
||||
|
||||
## Root Components
|
||||
|
||||
Root Components affect the entire Admin Panel.
|
||||
|
||||
### Available Root Components
|
||||
|
||||
| Component | Description | Config Path |
|
||||
| ----------------- | -------------------------------- | ---------------------------------- |
|
||||
| `Nav` | Entire navigation sidebar | `admin.components.Nav` |
|
||||
| `graphics.Icon` | Small icon (used in nav) | `admin.components.graphics.Icon` |
|
||||
| `graphics.Logo` | Full logo (used on login) | `admin.components.graphics.Logo` |
|
||||
| `logout.Button` | Logout button | `admin.components.logout.Button` |
|
||||
| `actions` | Header actions (array) | `admin.components.actions` |
|
||||
| `header` | Above header (array) | `admin.components.header` |
|
||||
| `beforeDashboard` | Before dashboard content (array) | `admin.components.beforeDashboard` |
|
||||
| `afterDashboard` | After dashboard content (array) | `admin.components.afterDashboard` |
|
||||
| `beforeLogin` | Before login form (array) | `admin.components.beforeLogin` |
|
||||
| `afterLogin` | After login form (array) | `admin.components.afterLogin` |
|
||||
| `beforeNavLinks` | Before nav links (array) | `admin.components.beforeNavLinks` |
|
||||
| `afterNavLinks` | After nav links (array) | `admin.components.afterNavLinks` |
|
||||
| `settingsMenu` | Settings menu items (array) | `admin.components.settingsMenu` |
|
||||
| `providers` | Custom React Context providers | `admin.components.providers` |
|
||||
| `views` | Custom views (dashboard, etc.) | `admin.components.views` |
|
||||
|
||||
### Example: Custom Logo
|
||||
|
||||
```typescript
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
components: {
|
||||
graphics: {
|
||||
Logo: '/components/Logo',
|
||||
Icon: '/components/Icon',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```tsx
|
||||
// components/Logo.tsx
|
||||
export default function Logo() {
|
||||
return <img src="/logo.png" alt="My Brand" width={200} />
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Header Actions
|
||||
|
||||
```typescript
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
components: {
|
||||
actions: ['/components/ClearCacheButton', '/components/PreviewButton'],
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```tsx
|
||||
// components/ClearCacheButton.tsx
|
||||
'use client'
|
||||
export default function ClearCacheButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await fetch('/api/clear-cache', { method: 'POST' })
|
||||
alert('Cache cleared!')
|
||||
}}
|
||||
>
|
||||
Clear Cache
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Collection Components
|
||||
|
||||
Collection Components are specific to a collection's views.
|
||||
|
||||
```typescript
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
admin: {
|
||||
components: {
|
||||
// Edit view components
|
||||
edit: {
|
||||
PreviewButton: '/components/PostPreview',
|
||||
SaveButton: '/components/CustomSave',
|
||||
SaveDraftButton: '/components/CustomSaveDraft',
|
||||
PublishButton: '/components/CustomPublish',
|
||||
},
|
||||
|
||||
// List view components
|
||||
list: {
|
||||
Header: '/components/PostsListHeader',
|
||||
beforeList: ['/components/ListFilters'],
|
||||
afterList: ['/components/ListFooter'],
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
// ...
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Global Components
|
||||
|
||||
Similar to Collection Components but for Global documents.
|
||||
|
||||
```typescript
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
export const Settings: GlobalConfig = {
|
||||
slug: 'settings',
|
||||
admin: {
|
||||
components: {
|
||||
edit: {
|
||||
PreviewButton: '/components/SettingsPreview',
|
||||
SaveButton: '/components/SettingsSave',
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
// ...
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Field Components
|
||||
|
||||
Customize how fields render in Edit and List views.
|
||||
|
||||
### Field Component (Edit View)
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: ['draft', 'published'],
|
||||
admin: {
|
||||
components: {
|
||||
Field: '/components/StatusField',
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// components/StatusField.tsx
|
||||
'use client'
|
||||
import { useField } from '@payloadcms/ui'
|
||||
import type { SelectFieldClientComponent } from 'payload'
|
||||
|
||||
export const StatusField: SelectFieldClientComponent = ({ path, field }) => {
|
||||
const { value, setValue } = useField({ path })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label>{field.label}</label>
|
||||
<select value={value} onChange={(e) => setValue(e.target.value)}>
|
||||
{field.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Cell Component (List View)
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: ['draft', 'published'],
|
||||
admin: {
|
||||
components: {
|
||||
Cell: '/components/StatusCell',
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// components/StatusCell.tsx
|
||||
import type { SelectFieldCellComponent } from 'payload'
|
||||
|
||||
export const StatusCell: SelectFieldCellComponent = ({ data, cellData }) => {
|
||||
const isPublished = cellData === 'published'
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
color: isPublished ? 'green' : 'orange',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{cellData}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### UI Field (Presentational Only)
|
||||
|
||||
Special field type for adding custom UI without affecting data:
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'refundButton',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '/components/RefundButton',
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// components/RefundButton.tsx
|
||||
'use client'
|
||||
import { useDocumentInfo } from '@payloadcms/ui'
|
||||
|
||||
export default function RefundButton() {
|
||||
const { id } = useDocumentInfo()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await fetch(`/api/orders/${id}/refund`, { method: 'POST' })
|
||||
alert('Refund processed')
|
||||
}}
|
||||
>
|
||||
Process Refund
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Using Hooks
|
||||
|
||||
Payload provides many React hooks for Client Components:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import {
|
||||
useAuth, // Current user
|
||||
useConfig, // Payload config (client-safe)
|
||||
useDocumentInfo, // Current document info (id, slug, etc.)
|
||||
useField, // Field value and setValue
|
||||
useForm, // Form state and dispatch
|
||||
useFormFields, // Multiple field values (optimized)
|
||||
useLocale, // Current locale
|
||||
useTranslation, // i18n translations
|
||||
usePayload, // Local API methods
|
||||
} from '@payloadcms/ui'
|
||||
|
||||
export function MyComponent() {
|
||||
const { user } = useAuth()
|
||||
const { config } = useConfig()
|
||||
const { id, collection } = useDocumentInfo()
|
||||
const locale = useLocale()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return <div>Hello {user?.email}</div>
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** These hooks only work in Client Components within the Admin Panel context.
|
||||
|
||||
## Accessing Payload Config
|
||||
|
||||
**In Server Components:**
|
||||
|
||||
```tsx
|
||||
async function MyServerComponent({ payload }) {
|
||||
const { config } = payload
|
||||
return <div>{config.serverURL}</div>
|
||||
}
|
||||
```
|
||||
|
||||
**In Client Components:**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
|
||||
export function MyClientComponent() {
|
||||
const { config } = useConfig() // Client-safe config
|
||||
return <div>{config.serverURL}</div>
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Client Components receive a serializable version of the config (functions, validation, etc. are stripped).
|
||||
|
||||
## Field Config Access
|
||||
|
||||
**Server Component:**
|
||||
|
||||
```tsx
|
||||
import type { TextFieldServerComponent } from 'payload'
|
||||
|
||||
export const MyFieldComponent: TextFieldServerComponent = ({ field }) => {
|
||||
return <div>Field name: {field.name}</div>
|
||||
}
|
||||
```
|
||||
|
||||
**Client Component:**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import type { TextFieldClientComponent } from 'payload'
|
||||
|
||||
export const MyFieldComponent: TextFieldClientComponent = ({ clientField }) => {
|
||||
// clientField has non-serializable props removed
|
||||
return <div>Field name: {clientField.name}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Translations (i18n)
|
||||
|
||||
**Server Component:**
|
||||
|
||||
```tsx
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
|
||||
async function MyServerComponent({ i18n }) {
|
||||
const translatedTitle = getTranslation(myTranslation, i18n)
|
||||
return <p>{translatedTitle}</p>
|
||||
}
|
||||
```
|
||||
|
||||
**Client Component:**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { useTranslation } from '@payloadcms/ui'
|
||||
|
||||
export function MyClientComponent() {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{t('namespace:key', { variable: 'value' })}</p>
|
||||
<p>Language: {i18n.language}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Styling Components
|
||||
|
||||
### Using CSS Variables
|
||||
|
||||
```tsx
|
||||
import './styles.scss'
|
||||
|
||||
export function MyComponent() {
|
||||
return <div className="my-component">Custom Component</div>
|
||||
}
|
||||
```
|
||||
|
||||
```scss
|
||||
// styles.scss
|
||||
.my-component {
|
||||
background-color: var(--theme-elevation-500);
|
||||
color: var(--theme-text);
|
||||
padding: var(--base);
|
||||
border-radius: var(--border-radius-m);
|
||||
}
|
||||
```
|
||||
|
||||
### Importing Payload SCSS
|
||||
|
||||
```scss
|
||||
@import '~@payloadcms/ui/scss';
|
||||
|
||||
.my-component {
|
||||
@include mid-break {
|
||||
background-color: var(--theme-elevation-900);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Conditional Field Visibility
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { useFormFields } from '@payloadcms/ui'
|
||||
import type { TextFieldClientComponent } from 'payload'
|
||||
|
||||
export const ConditionalField: TextFieldClientComponent = ({ path }) => {
|
||||
const showField = useFormFields(([fields]) => fields.enableFeature?.value)
|
||||
|
||||
if (!showField) return null
|
||||
|
||||
return <input type="text" />
|
||||
}
|
||||
```
|
||||
|
||||
### Loading Data from API
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function DataLoader() {
|
||||
const [data, setData] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/custom-data')
|
||||
.then((res) => res.json())
|
||||
.then(setData)
|
||||
}, [])
|
||||
|
||||
return <div>{JSON.stringify(data)}</div>
|
||||
}
|
||||
```
|
||||
|
||||
### Using Local API in Server Components
|
||||
|
||||
```tsx
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
async function RelatedPosts({ payload, id }: { payload: Payload; id: string }) {
|
||||
const post = await payload.findByID({
|
||||
collection: 'posts',
|
||||
id,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
const related = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
category: { equals: post.category },
|
||||
id: { not_equals: id },
|
||||
},
|
||||
limit: 5,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Related Posts</h3>
|
||||
<ul>
|
||||
{related.docs.map((doc) => (
|
||||
<li key={doc.id}>{doc.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RelatedPosts
|
||||
```
|
||||
|
||||
## Performance Best Practices
|
||||
|
||||
### 1. Minimize Client Bundle Size
|
||||
|
||||
```tsx
|
||||
// ❌ BAD: Imports entire package
|
||||
'use client'
|
||||
import { Button } from '@payloadcms/ui'
|
||||
|
||||
// ✅ GOOD: Tree-shakeable import for frontend
|
||||
import { Button } from '@payloadcms/ui/elements/Button'
|
||||
```
|
||||
|
||||
**Rule:** In Admin Panel UI, import from `@payloadcms/ui`. In frontend code, use specific paths.
|
||||
|
||||
### 2. Optimize Re-renders
|
||||
|
||||
```tsx
|
||||
// ❌ BAD: Re-renders on every form change
|
||||
'use client'
|
||||
import { useForm } from '@payloadcms/ui'
|
||||
|
||||
export function MyComponent() {
|
||||
const { fields } = useForm()
|
||||
// Re-renders on ANY field change
|
||||
}
|
||||
|
||||
// ✅ GOOD: Only re-renders when specific field changes
|
||||
;('use client')
|
||||
import { useFormFields } from '@payloadcms/ui'
|
||||
|
||||
export function MyComponent({ path }) {
|
||||
const value = useFormFields(([fields]) => fields[path])
|
||||
// Only re-renders when this field changes
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use Server Components When Possible
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD: No JavaScript sent to client
|
||||
async function PostCount({ payload }) {
|
||||
const { totalDocs } = await payload.find({
|
||||
collection: 'posts',
|
||||
limit: 0,
|
||||
})
|
||||
|
||||
return <p>{totalDocs} posts</p>
|
||||
}
|
||||
|
||||
// Only use client components when you need:
|
||||
// - State (useState, useReducer)
|
||||
// - Effects (useEffect)
|
||||
// - Event handlers (onClick, onChange)
|
||||
// - Browser APIs (localStorage, window)
|
||||
```
|
||||
|
||||
### 4. React Best Practices
|
||||
|
||||
- Use React.memo() for expensive components
|
||||
- Implement proper key props in lists
|
||||
- Avoid inline function definitions in renders
|
||||
- Use Suspense boundaries for async operations
|
||||
|
||||
## Import Map
|
||||
|
||||
Payload generates an import map at `app/(payload)/admin/importMap.js` that resolves all component paths.
|
||||
|
||||
**Regenerate manually:**
|
||||
|
||||
```bash
|
||||
payload generate:importmap
|
||||
```
|
||||
|
||||
**Override location:**
|
||||
|
||||
```typescript
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname, 'src'),
|
||||
importMapFile: path.resolve(dirname, 'app', 'custom-import-map.js'),
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Type Safety
|
||||
|
||||
Use Payload's TypeScript types for components:
|
||||
|
||||
```tsx
|
||||
import type {
|
||||
TextFieldServerComponent,
|
||||
TextFieldClientComponent,
|
||||
TextFieldCellComponent,
|
||||
} from 'payload'
|
||||
|
||||
export const MyFieldComponent: TextFieldServerComponent = (props) => {
|
||||
// Fully typed props
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "useConfig is undefined" or similar hook errors
|
||||
|
||||
**Cause:** Dependency version mismatch between Payload packages.
|
||||
|
||||
**Solution:** Pin all `@payloadcms/*` packages to the exact same version:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"payload": "3.0.0",
|
||||
"@payloadcms/ui": "3.0.0",
|
||||
"@payloadcms/richtext-lexical": "3.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Component not loading
|
||||
|
||||
1. Check file path is correct (relative to baseDir)
|
||||
2. Verify named export syntax: `/path/to/file#ExportName`
|
||||
3. Run `payload generate:importmap` to regenerate
|
||||
4. Check for TypeScript errors in component file
|
||||
|
||||
## Resources
|
||||
|
||||
- [Custom Components Docs](https://payloadcms.com/docs/custom-components/overview)
|
||||
- [Root Components](https://payloadcms.com/docs/custom-components/root-components)
|
||||
- [Custom Views](https://payloadcms.com/docs/custom-components/custom-views)
|
||||
- [React Hooks](https://payloadcms.com/docs/admin/react-hooks)
|
||||
- [Custom CSS](https://payloadcms.com/docs/admin/customizing-css)
|
||||
236
.cursor/rules/endpoints.md
Normal file
236
.cursor/rules/endpoints.md
Normal file
@ -0,0 +1,236 @@
|
||||
---
|
||||
title: Custom Endpoints
|
||||
description: Custom REST API endpoints with authentication and helpers
|
||||
tags: [payload, endpoints, api, routes, webhooks]
|
||||
---
|
||||
|
||||
# Payload Custom Endpoints
|
||||
|
||||
## Basic Endpoint Pattern
|
||||
|
||||
Custom endpoints are **not authenticated by default**. Always check `req.user`.
|
||||
|
||||
```typescript
|
||||
import { APIError } from 'payload'
|
||||
import type { Endpoint } from 'payload'
|
||||
|
||||
export const protectedEndpoint: Endpoint = {
|
||||
path: '/protected',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
if (!req.user) {
|
||||
throw new APIError('Unauthorized', 401)
|
||||
}
|
||||
|
||||
// Use req.payload for database operations
|
||||
const data = await req.payload.find({
|
||||
collection: 'posts',
|
||||
where: { author: { equals: req.user.id } },
|
||||
})
|
||||
|
||||
return Response.json(data)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Route Parameters
|
||||
|
||||
```typescript
|
||||
export const trackingEndpoint: Endpoint = {
|
||||
path: '/:id/tracking',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
const { id } = req.routeParams
|
||||
|
||||
const tracking = await getTrackingInfo(id)
|
||||
|
||||
if (!tracking) {
|
||||
return Response.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return Response.json(tracking)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Request Body Handling
|
||||
|
||||
```typescript
|
||||
// Manual JSON parsing
|
||||
export const createEndpoint: Endpoint = {
|
||||
path: '/create',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
const data = await req.json()
|
||||
|
||||
const result = await req.payload.create({
|
||||
collection: 'posts',
|
||||
data,
|
||||
})
|
||||
|
||||
return Response.json(result)
|
||||
},
|
||||
}
|
||||
|
||||
// Using helper (handles JSON + files)
|
||||
import { addDataAndFileToRequest } from 'payload'
|
||||
|
||||
export const uploadEndpoint: Endpoint = {
|
||||
path: '/upload',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
await addDataAndFileToRequest(req)
|
||||
|
||||
// req.data contains parsed body
|
||||
// req.file contains uploaded file (if multipart)
|
||||
|
||||
const result = await req.payload.create({
|
||||
collection: 'media',
|
||||
data: req.data,
|
||||
file: req.file,
|
||||
})
|
||||
|
||||
return Response.json(result)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Query Parameters
|
||||
|
||||
```typescript
|
||||
export const searchEndpoint: Endpoint = {
|
||||
path: '/search',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
const url = new URL(req.url)
|
||||
const query = url.searchParams.get('q')
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10')
|
||||
|
||||
const results = await req.payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
title: {
|
||||
contains: query,
|
||||
},
|
||||
},
|
||||
limit,
|
||||
})
|
||||
|
||||
return Response.json(results)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## CORS Headers
|
||||
|
||||
```typescript
|
||||
import { headersWithCors } from 'payload'
|
||||
|
||||
export const corsEndpoint: Endpoint = {
|
||||
path: '/public-data',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
const data = await fetchPublicData()
|
||||
|
||||
return Response.json(data, {
|
||||
headers: headersWithCors({
|
||||
headers: new Headers(),
|
||||
req,
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import { APIError } from 'payload'
|
||||
|
||||
export const validateEndpoint: Endpoint = {
|
||||
path: '/validate',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
const data = await req.json()
|
||||
|
||||
if (!data.email) {
|
||||
throw new APIError('Email is required', 400)
|
||||
}
|
||||
|
||||
return Response.json({ valid: true })
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Endpoint Placement
|
||||
|
||||
### Collection Endpoints
|
||||
|
||||
Mounted at `/api/{collection-slug}/{path}`.
|
||||
|
||||
```typescript
|
||||
export const Orders: CollectionConfig = {
|
||||
slug: 'orders',
|
||||
endpoints: [
|
||||
{
|
||||
path: '/:id/tracking',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
// Available at: /api/orders/:id/tracking
|
||||
const orderId = req.routeParams.id
|
||||
return Response.json({ orderId })
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Global Endpoints
|
||||
|
||||
Mounted at `/api/globals/{global-slug}/{path}`.
|
||||
|
||||
```typescript
|
||||
export const Settings: GlobalConfig = {
|
||||
slug: 'settings',
|
||||
endpoints: [
|
||||
{
|
||||
path: '/clear-cache',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
// Available at: /api/globals/settings/clear-cache
|
||||
await clearCache()
|
||||
return Response.json({ message: 'Cache cleared' })
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Root Endpoints
|
||||
|
||||
Mounted at `/api/{path}`.
|
||||
|
||||
```typescript
|
||||
export default buildConfig({
|
||||
endpoints: [
|
||||
{
|
||||
path: '/hello',
|
||||
method: 'get',
|
||||
handler: () => {
|
||||
// Available at: /api/hello
|
||||
return Response.json({ message: 'Hello!' })
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always check authentication** - Custom endpoints are not authenticated by default
|
||||
2. **Use `req.payload` for operations** - Ensures access control and hooks execute
|
||||
3. **Use helpers for common tasks** - `addDataAndFileToRequest`, `headersWithCors`
|
||||
4. **Throw `APIError` for errors** - Provides consistent error responses
|
||||
5. **Return Web API `Response`** - Use `Response.json()` for consistent responses
|
||||
6. **Validate input** - Check required fields, validate types
|
||||
7. **Log errors** - Use `req.payload.logger` for debugging
|
||||
230
.cursor/rules/field-type-guards.md
Normal file
230
.cursor/rules/field-type-guards.md
Normal file
@ -0,0 +1,230 @@
|
||||
---
|
||||
title: Field Type Guards
|
||||
description: Runtime field type checking and safe type narrowing
|
||||
tags: [payload, typescript, type-guards, fields]
|
||||
---
|
||||
|
||||
# Payload Field Type Guards
|
||||
|
||||
Type guards for runtime field type checking and safe type narrowing.
|
||||
|
||||
## Most Common Guards
|
||||
|
||||
### fieldAffectsData
|
||||
|
||||
**Most commonly used guard.** Checks if field stores data (has name and is not UI-only).
|
||||
|
||||
```typescript
|
||||
import { fieldAffectsData } from 'payload'
|
||||
|
||||
function generateSchema(fields: Field[]) {
|
||||
fields.forEach((field) => {
|
||||
if (fieldAffectsData(field)) {
|
||||
// Safe to access field.name
|
||||
schema[field.name] = getFieldType(field)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Filter data fields
|
||||
const dataFields = fields.filter(fieldAffectsData)
|
||||
```
|
||||
|
||||
### fieldHasSubFields
|
||||
|
||||
Checks if field contains nested fields (group, array, row, or collapsible).
|
||||
|
||||
```typescript
|
||||
import { fieldHasSubFields } from 'payload'
|
||||
|
||||
function traverseFields(fields: Field[]): void {
|
||||
fields.forEach((field) => {
|
||||
if (fieldHasSubFields(field)) {
|
||||
// Safe to access field.fields
|
||||
traverseFields(field.fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### fieldIsArrayType
|
||||
|
||||
Checks if field type is `'array'`.
|
||||
|
||||
```typescript
|
||||
import { fieldIsArrayType } from 'payload'
|
||||
|
||||
if (fieldIsArrayType(field)) {
|
||||
// field.type === 'array'
|
||||
console.log(`Min rows: ${field.minRows}`)
|
||||
console.log(`Max rows: ${field.maxRows}`)
|
||||
}
|
||||
```
|
||||
|
||||
## Capability Guards
|
||||
|
||||
### fieldSupportsMany
|
||||
|
||||
Checks if field can have multiple values (select, relationship, or upload with `hasMany`).
|
||||
|
||||
```typescript
|
||||
import { fieldSupportsMany } from 'payload'
|
||||
|
||||
if (fieldSupportsMany(field)) {
|
||||
// field.type is 'select' | 'relationship' | 'upload'
|
||||
if (field.hasMany) {
|
||||
console.log('Field accepts multiple values')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### fieldHasMaxDepth
|
||||
|
||||
Checks if field is relationship/upload/join with numeric `maxDepth` property.
|
||||
|
||||
```typescript
|
||||
import { fieldHasMaxDepth } from 'payload'
|
||||
|
||||
if (fieldHasMaxDepth(field)) {
|
||||
// field.type is 'upload' | 'relationship' | 'join'
|
||||
// AND field.maxDepth is number
|
||||
const remainingDepth = field.maxDepth - currentDepth
|
||||
}
|
||||
```
|
||||
|
||||
### fieldIsVirtual
|
||||
|
||||
Checks if field is virtual (computed or virtual relationship).
|
||||
|
||||
```typescript
|
||||
import { fieldIsVirtual } from 'payload'
|
||||
|
||||
if (fieldIsVirtual(field)) {
|
||||
// field.virtual is truthy
|
||||
if (typeof field.virtual === 'string') {
|
||||
console.log(`Virtual path: ${field.virtual}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Type Checking Guards
|
||||
|
||||
### fieldIsBlockType
|
||||
|
||||
```typescript
|
||||
import { fieldIsBlockType } from 'payload'
|
||||
|
||||
if (fieldIsBlockType(field)) {
|
||||
// field.type === 'blocks'
|
||||
field.blocks.forEach((block) => {
|
||||
console.log(`Block: ${block.slug}`)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### fieldIsGroupType
|
||||
|
||||
```typescript
|
||||
import { fieldIsGroupType } from 'payload'
|
||||
|
||||
if (fieldIsGroupType(field)) {
|
||||
// field.type === 'group'
|
||||
console.log(`Interface: ${field.interfaceName}`)
|
||||
}
|
||||
```
|
||||
|
||||
### fieldIsPresentationalOnly
|
||||
|
||||
```typescript
|
||||
import { fieldIsPresentationalOnly } from 'payload'
|
||||
|
||||
if (fieldIsPresentationalOnly(field)) {
|
||||
// field.type === 'ui'
|
||||
// Skip in data operations, GraphQL schema, etc.
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Recursive Field Traversal
|
||||
|
||||
```typescript
|
||||
import { fieldAffectsData, fieldHasSubFields } from 'payload'
|
||||
|
||||
function traverseFields(fields: Field[], callback: (field: Field) => void) {
|
||||
fields.forEach((field) => {
|
||||
if (fieldAffectsData(field)) {
|
||||
callback(field)
|
||||
}
|
||||
|
||||
if (fieldHasSubFields(field)) {
|
||||
traverseFields(field.fields, callback)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Filter Data-Bearing Fields
|
||||
|
||||
```typescript
|
||||
import { fieldAffectsData, fieldIsPresentationalOnly, fieldIsHiddenOrDisabled } from 'payload'
|
||||
|
||||
const dataFields = fields.filter(
|
||||
(field) =>
|
||||
fieldAffectsData(field) && !fieldIsPresentationalOnly(field) && !fieldIsHiddenOrDisabled(field),
|
||||
)
|
||||
```
|
||||
|
||||
### Container Type Switching
|
||||
|
||||
```typescript
|
||||
import { fieldIsArrayType, fieldIsBlockType, fieldHasSubFields } from 'payload'
|
||||
|
||||
if (fieldIsArrayType(field)) {
|
||||
// Handle array-specific logic
|
||||
} else if (fieldIsBlockType(field)) {
|
||||
// Handle blocks-specific logic
|
||||
} else if (fieldHasSubFields(field)) {
|
||||
// Handle group/row/collapsible
|
||||
}
|
||||
```
|
||||
|
||||
### Safe Property Access
|
||||
|
||||
```typescript
|
||||
import { fieldSupportsMany, fieldHasMaxDepth } from 'payload'
|
||||
|
||||
// With guard - safe access
|
||||
if (fieldSupportsMany(field) && field.hasMany) {
|
||||
console.log('Multiple values supported')
|
||||
}
|
||||
|
||||
if (fieldHasMaxDepth(field)) {
|
||||
const depth = field.maxDepth // TypeScript knows this is number
|
||||
}
|
||||
```
|
||||
|
||||
## All Available Guards
|
||||
|
||||
| Type Guard | Checks For | Use When |
|
||||
| --------------------------- | --------------------------------- | ---------------------------------------- |
|
||||
| `fieldAffectsData` | Field stores data (has name) | Need to access field data or name |
|
||||
| `fieldHasSubFields` | Field contains nested fields | Recursively traverse fields |
|
||||
| `fieldIsArrayType` | Field is array type | Distinguish arrays from other containers |
|
||||
| `fieldIsBlockType` | Field is blocks type | Handle blocks-specific logic |
|
||||
| `fieldIsGroupType` | Field is group type | Handle group-specific logic |
|
||||
| `fieldSupportsMany` | Field can have multiple values | Check for `hasMany` support |
|
||||
| `fieldHasMaxDepth` | Field supports depth control | Control relationship/upload/join depth |
|
||||
| `fieldIsPresentationalOnly` | Field is UI-only | Exclude from data operations |
|
||||
| `fieldIsSidebar` | Field positioned in sidebar | Separate sidebar rendering |
|
||||
| `fieldIsID` | Field name is 'id' | Special ID field handling |
|
||||
| `fieldIsHiddenOrDisabled` | Field is hidden or disabled | Filter from UI operations |
|
||||
| `fieldShouldBeLocalized` | Field needs localization | Proper locale table checks |
|
||||
| `fieldIsVirtual` | Field is virtual | Skip in database transforms |
|
||||
| `tabHasName` | Tab is named (stores data) | Distinguish named vs unnamed tabs |
|
||||
| `groupHasName` | Group is named (stores data) | Distinguish named vs unnamed groups |
|
||||
| `optionIsObject` | Option is `{label, value}` | Access option properties safely |
|
||||
| `optionsAreObjects` | All options are objects | Batch option processing |
|
||||
| `optionIsValue` | Option is string value | Handle string options |
|
||||
| `valueIsValueWithRelation` | Value is polymorphic relationship | Handle polymorphic relationships |
|
||||
317
.cursor/rules/fields.md
Normal file
317
.cursor/rules/fields.md
Normal file
@ -0,0 +1,317 @@
|
||||
---
|
||||
title: Fields
|
||||
description: Field types, patterns, and configurations
|
||||
tags: [payload, fields, validation, conditional]
|
||||
---
|
||||
|
||||
# Payload CMS Fields
|
||||
|
||||
## Common Field Patterns
|
||||
|
||||
```typescript
|
||||
// Auto-generate slugs
|
||||
import { slugField } from 'payload'
|
||||
slugField({ fieldToUse: 'title' })
|
||||
|
||||
// Relationship with filtering
|
||||
{
|
||||
name: 'category',
|
||||
type: 'relationship',
|
||||
relationTo: 'categories',
|
||||
filterOptions: { active: { equals: true } },
|
||||
}
|
||||
|
||||
// Conditional field
|
||||
{
|
||||
name: 'featuredImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
admin: {
|
||||
condition: (data) => data.featured === true,
|
||||
},
|
||||
}
|
||||
|
||||
// Virtual field
|
||||
{
|
||||
name: 'fullName',
|
||||
type: 'text',
|
||||
virtual: true,
|
||||
hooks: {
|
||||
afterRead: [({ siblingData }) => `${siblingData.firstName} ${siblingData.lastName}`],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Field Types
|
||||
|
||||
### Text Field
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
minLength: 5,
|
||||
maxLength: 100,
|
||||
index: true,
|
||||
localized: true,
|
||||
defaultValue: 'Default Title',
|
||||
validate: (value) => Boolean(value) || 'Required',
|
||||
admin: {
|
||||
placeholder: 'Enter title...',
|
||||
position: 'sidebar',
|
||||
condition: (data) => data.showTitle === true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Rich Text (Lexical)
|
||||
|
||||
```typescript
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { HeadingFeature, LinkFeature } from '@payloadcms/richtext-lexical'
|
||||
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
required: true,
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
HeadingFeature({
|
||||
enabledHeadingSizes: ['h1', 'h2', 'h3'],
|
||||
}),
|
||||
LinkFeature({
|
||||
enabledCollections: ['posts', 'pages'],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
### Relationship
|
||||
|
||||
```typescript
|
||||
// Single relationship
|
||||
{
|
||||
name: 'author',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
maxDepth: 2,
|
||||
}
|
||||
|
||||
// Multiple relationships (hasMany)
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'relationship',
|
||||
relationTo: 'categories',
|
||||
hasMany: true,
|
||||
filterOptions: {
|
||||
active: { equals: true },
|
||||
},
|
||||
}
|
||||
|
||||
// Polymorphic relationship
|
||||
{
|
||||
name: 'relatedContent',
|
||||
type: 'relationship',
|
||||
relationTo: ['posts', 'pages'],
|
||||
hasMany: true,
|
||||
}
|
||||
```
|
||||
|
||||
### Array
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'slides',
|
||||
type: 'array',
|
||||
minRows: 2,
|
||||
maxRows: 10,
|
||||
labels: {
|
||||
singular: 'Slide',
|
||||
plural: 'Slides',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
],
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Blocks
|
||||
|
||||
```typescript
|
||||
import type { Block } from 'payload'
|
||||
|
||||
const HeroBlock: Block = {
|
||||
slug: 'hero',
|
||||
interfaceName: 'HeroBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'background',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const ContentBlock: Block = {
|
||||
slug: 'content',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'richText',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
{
|
||||
name: 'layout',
|
||||
type: 'blocks',
|
||||
blocks: [HeroBlock, ContentBlock],
|
||||
}
|
||||
```
|
||||
|
||||
### Select
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
],
|
||||
defaultValue: 'draft',
|
||||
required: true,
|
||||
}
|
||||
|
||||
// Multiple select
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['tech', 'news', 'sports'],
|
||||
}
|
||||
```
|
||||
|
||||
### Upload
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'featuredImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
filterOptions: {
|
||||
mimeType: { contains: 'image' },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Point (Geolocation)
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'location',
|
||||
type: 'point',
|
||||
label: 'Location',
|
||||
required: true,
|
||||
}
|
||||
|
||||
// Query by distance
|
||||
const nearbyLocations = await payload.find({
|
||||
collection: 'stores',
|
||||
where: {
|
||||
location: {
|
||||
near: [10, 20], // [longitude, latitude]
|
||||
maxDistance: 5000, // in meters
|
||||
minDistance: 1000,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Join Fields (Reverse Relationships)
|
||||
|
||||
```typescript
|
||||
// From Users collection - show user's orders
|
||||
{
|
||||
name: 'orders',
|
||||
type: 'join',
|
||||
collection: 'orders',
|
||||
on: 'customer', // The field in 'orders' that references this user
|
||||
}
|
||||
```
|
||||
|
||||
### Tabs & Groups
|
||||
|
||||
```typescript
|
||||
// Tabs
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
label: 'Content',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'body', type: 'richText' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SEO',
|
||||
fields: [
|
||||
{ name: 'metaTitle', type: 'text' },
|
||||
{ name: 'metaDescription', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Group (named)
|
||||
{
|
||||
name: 'meta',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'description', type: 'textarea' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
validate: (value, { operation, data, siblingData }) => {
|
||||
if (operation === 'create' && !value) {
|
||||
return 'Email is required'
|
||||
}
|
||||
if (value && !value.includes('@')) {
|
||||
return 'Invalid email format'
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
```
|
||||
175
.cursor/rules/hooks.md
Normal file
175
.cursor/rules/hooks.md
Normal file
@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Hooks
|
||||
description: Collection hooks, field hooks, and context patterns
|
||||
tags: [payload, hooks, lifecycle, context]
|
||||
---
|
||||
|
||||
# Payload CMS Hooks
|
||||
|
||||
## Collection Hooks
|
||||
|
||||
```typescript
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
hooks: {
|
||||
// Before validation - format data
|
||||
beforeValidate: [
|
||||
async ({ data, operation }) => {
|
||||
if (operation === 'create') {
|
||||
data.slug = slugify(data.title)
|
||||
}
|
||||
return data
|
||||
},
|
||||
],
|
||||
|
||||
// Before save - business logic
|
||||
beforeChange: [
|
||||
async ({ data, req, operation, originalDoc }) => {
|
||||
if (operation === 'update' && data.status === 'published') {
|
||||
data.publishedAt = new Date()
|
||||
}
|
||||
return data
|
||||
},
|
||||
],
|
||||
|
||||
// After save - side effects
|
||||
afterChange: [
|
||||
async ({ doc, req, operation, previousDoc, context }) => {
|
||||
// Check context to prevent loops
|
||||
if (context.skipNotification) return
|
||||
|
||||
if (operation === 'create') {
|
||||
await sendNotification(doc)
|
||||
}
|
||||
return doc
|
||||
},
|
||||
],
|
||||
|
||||
// After read - computed fields
|
||||
afterRead: [
|
||||
async ({ doc, req }) => {
|
||||
doc.viewCount = await getViewCount(doc.id)
|
||||
return doc
|
||||
},
|
||||
],
|
||||
|
||||
// Before delete - cascading deletes
|
||||
beforeDelete: [
|
||||
async ({ req, id }) => {
|
||||
await req.payload.delete({
|
||||
collection: 'comments',
|
||||
where: { post: { equals: id } },
|
||||
req, // Important for transaction
|
||||
})
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Field Hooks
|
||||
|
||||
```typescript
|
||||
import type { FieldHook } from 'payload'
|
||||
|
||||
const beforeValidateHook: FieldHook = ({ value }) => {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const afterReadHook: FieldHook = ({ value, req }) => {
|
||||
// Hide email from non-admins
|
||||
if (!req.user?.roles?.includes('admin')) {
|
||||
return value.replace(/(.{2})(.*)(@.*)/, '$1***$3')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
hooks: {
|
||||
beforeValidate: [beforeValidateHook],
|
||||
afterRead: [afterReadHook],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Hook Context
|
||||
|
||||
Share data between hooks or control hook behavior using request context:
|
||||
|
||||
```typescript
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
async ({ context }) => {
|
||||
context.expensiveData = await fetchExpensiveData()
|
||||
},
|
||||
],
|
||||
afterChange: [
|
||||
async ({ context, doc }) => {
|
||||
// Reuse from previous hook
|
||||
await processData(doc, context.expensiveData)
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Next.js Revalidation Pattern
|
||||
|
||||
```typescript
|
||||
import type { CollectionAfterChangeHook } from 'payload'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export const revalidatePage: CollectionAfterChangeHook = ({
|
||||
doc,
|
||||
previousDoc,
|
||||
req: { payload, context },
|
||||
}) => {
|
||||
if (!context.disableRevalidate) {
|
||||
if (doc._status === 'published') {
|
||||
const path = doc.slug === 'home' ? '/' : `/${doc.slug}`
|
||||
payload.logger.info(`Revalidating page at path: ${path}`)
|
||||
revalidatePath(path)
|
||||
}
|
||||
|
||||
// Revalidate old path if unpublished
|
||||
if (previousDoc?._status === 'published' && doc._status !== 'published') {
|
||||
const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}`
|
||||
revalidatePath(oldPath)
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
```
|
||||
|
||||
## Date Field Auto-Set
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'publishedOn',
|
||||
type: 'date',
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (siblingData._status === 'published' && !value) {
|
||||
return new Date()
|
||||
}
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use `beforeValidate` for data formatting
|
||||
- Use `beforeChange` for business logic
|
||||
- Use `afterChange` for side effects
|
||||
- Use `afterRead` for computed fields
|
||||
- Store expensive operations in `context`
|
||||
- Pass `req` to nested operations for transaction safety
|
||||
- Use context flags to prevent infinite loops
|
||||
126
.cursor/rules/payload-overview.md
Normal file
126
.cursor/rules/payload-overview.md
Normal file
@ -0,0 +1,126 @@
|
||||
---
|
||||
title: Payload CMS Overview
|
||||
description: Core principles and quick reference for Payload CMS development
|
||||
tags: [payload, overview, quickstart]
|
||||
---
|
||||
|
||||
# Payload CMS Development Rules
|
||||
|
||||
You are an expert Payload CMS developer. When working with Payload projects, follow these rules:
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **TypeScript-First**: Always use TypeScript with proper types from Payload
|
||||
2. **Security-Critical**: Follow all security patterns, especially access control
|
||||
3. **Type Generation**: Run `generate:types` script after schema changes
|
||||
4. **Transaction Safety**: Always pass `req` to nested operations in hooks
|
||||
5. **Access Control**: Understand Local API bypasses access control by default
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── (frontend)/ # Frontend routes
|
||||
│ └── (payload)/ # Payload admin routes
|
||||
├── collections/ # Collection configs
|
||||
├── globals/ # Global configs
|
||||
├── components/ # Custom React components
|
||||
├── hooks/ # Hook functions
|
||||
├── access/ # Access control functions
|
||||
└── payload.config.ts # Main config
|
||||
```
|
||||
|
||||
## Minimal Config Pattern
|
||||
|
||||
```typescript
|
||||
import { buildConfig } from 'payload'
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: 'users',
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
collections: [Users, Media],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET,
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URL,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
## Getting Payload Instance
|
||||
|
||||
```typescript
|
||||
// In API routes (Next.js)
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
|
||||
export async function GET() {
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
})
|
||||
|
||||
return Response.json(posts)
|
||||
}
|
||||
|
||||
// In Server Components
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
|
||||
export default async function Page() {
|
||||
const payload = await getPayload({ config })
|
||||
const { docs } = await payload.find({ collection: 'posts' })
|
||||
|
||||
return <div>{docs.map(post => <h1 key={post.id}>{post.title}</h1>)}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Solution |
|
||||
| --------------------- | ---------------------------------- |
|
||||
| Auto-generate slugs | `slugField()` |
|
||||
| Restrict by user | Access control with query |
|
||||
| Local API user ops | `user` + `overrideAccess: false` |
|
||||
| Draft/publish | `versions: { drafts: true }` |
|
||||
| Computed fields | `virtual: true` with afterRead |
|
||||
| Conditional fields | `admin.condition` |
|
||||
| Custom validation | `validate` function |
|
||||
| Filter relationships | `filterOptions` on field |
|
||||
| Select fields | `select` parameter |
|
||||
| Auto-set dates | beforeChange hook |
|
||||
| Prevent loops | `req.context` check |
|
||||
| Cascading deletes | beforeDelete hook |
|
||||
| Geospatial queries | `point` field with `near`/`within` |
|
||||
| Reverse relationships | `join` field type |
|
||||
| Query relationships | Nested property syntax |
|
||||
| Complex queries | AND/OR logic |
|
||||
| Transactions | Pass `req` to operations |
|
||||
| Background jobs | Jobs queue with tasks |
|
||||
| Custom routes | Collection custom endpoints |
|
||||
| Cloud storage | Storage adapter plugins |
|
||||
| Multi-language | `localization` + `localized: true` |
|
||||
|
||||
## Resources
|
||||
|
||||
- Docs: https://payloadcms.com/docs
|
||||
- LLM Context: https://payloadcms.com/llms-full.txt
|
||||
- GitHub: https://github.com/payloadcms/payload
|
||||
- Examples: https://github.com/payloadcms/payload/tree/main/examples
|
||||
- Templates: https://github.com/payloadcms/payload/tree/main/templates
|
||||
323
.cursor/rules/plugin-development.md
Normal file
323
.cursor/rules/plugin-development.md
Normal file
@ -0,0 +1,323 @@
|
||||
---
|
||||
title: Plugin Development
|
||||
description: Creating Payload CMS plugins with TypeScript patterns
|
||||
tags: [payload, plugins, architecture, patterns]
|
||||
---
|
||||
|
||||
# Payload Plugin Development
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
Plugins are functions that receive configuration options and return a function that transforms the Payload config:
|
||||
|
||||
```typescript
|
||||
import type { Config, Plugin } from 'payload'
|
||||
|
||||
interface MyPluginConfig {
|
||||
enabled?: boolean
|
||||
collections?: string[]
|
||||
}
|
||||
|
||||
export const myPlugin =
|
||||
(options: MyPluginConfig): Plugin =>
|
||||
(config: Config): Config => ({
|
||||
...config,
|
||||
// Transform config here
|
||||
})
|
||||
```
|
||||
|
||||
**Key Pattern:** Double arrow function (currying)
|
||||
|
||||
- First function: Accepts plugin options, returns plugin function
|
||||
- Second function: Accepts Payload config, returns modified config
|
||||
|
||||
## Adding Fields to Collections
|
||||
|
||||
```typescript
|
||||
export const seoPlugin =
|
||||
(options: { collections?: string[] }): Plugin =>
|
||||
(config: Config): Config => {
|
||||
const seoFields: Field[] = [
|
||||
{
|
||||
name: 'meta',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'description', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
...config,
|
||||
collections: config.collections?.map((collection) => {
|
||||
if (options.collections?.includes(collection.slug)) {
|
||||
return {
|
||||
...collection,
|
||||
fields: [...(collection.fields || []), ...seoFields],
|
||||
}
|
||||
}
|
||||
return collection
|
||||
}),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Adding New Collections
|
||||
|
||||
```typescript
|
||||
export const redirectsPlugin =
|
||||
(options: { overrides?: Partial<CollectionConfig> }): Plugin =>
|
||||
(config: Config): Config => {
|
||||
const redirectsCollection: CollectionConfig = {
|
||||
slug: 'redirects',
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{ name: 'from', type: 'text', required: true, unique: true },
|
||||
{ name: 'to', type: 'text', required: true },
|
||||
],
|
||||
...options.overrides,
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
collections: [...(config.collections || []), redirectsCollection],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Adding Hooks
|
||||
|
||||
```typescript
|
||||
const resaveChildrenHook: CollectionAfterChangeHook = async ({ doc, req, operation }) => {
|
||||
if (operation === 'update') {
|
||||
const children = await req.payload.find({
|
||||
collection: 'pages',
|
||||
where: { parent: { equals: doc.id } },
|
||||
})
|
||||
|
||||
for (const child of children.docs) {
|
||||
await req.payload.update({
|
||||
collection: 'pages',
|
||||
id: child.id,
|
||||
data: child,
|
||||
})
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export const nestedDocsPlugin =
|
||||
(options: { collections: string[] }): Plugin =>
|
||||
(config: Config): Config => ({
|
||||
...config,
|
||||
collections: (config.collections || []).map((collection) => {
|
||||
if (options.collections.includes(collection.slug)) {
|
||||
return {
|
||||
...collection,
|
||||
hooks: {
|
||||
...(collection.hooks || {}),
|
||||
afterChange: [resaveChildrenHook, ...(collection.hooks?.afterChange || [])],
|
||||
},
|
||||
}
|
||||
}
|
||||
return collection
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
## Adding Root-Level Endpoints
|
||||
|
||||
```typescript
|
||||
export const seoPlugin =
|
||||
(options: { generateTitle?: (doc: any) => string }): Plugin =>
|
||||
(config: Config): Config => {
|
||||
const generateTitleEndpoint: Endpoint = {
|
||||
path: '/plugin-seo/generate-title',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
const data = await req.json?.()
|
||||
const result = options.generateTitle ? options.generateTitle(data.doc) : ''
|
||||
return Response.json({ result })
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
endpoints: [...(config.endpoints ?? []), generateTitleEndpoint],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Field Overrides with Defaults
|
||||
|
||||
```typescript
|
||||
type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
|
||||
|
||||
interface PluginConfig {
|
||||
collections?: string[]
|
||||
fields?: FieldsOverride
|
||||
}
|
||||
|
||||
export const myPlugin =
|
||||
(options: PluginConfig): Plugin =>
|
||||
(config: Config): Config => {
|
||||
const defaultFields: Field[] = [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'description', type: 'textarea' },
|
||||
]
|
||||
|
||||
const fields =
|
||||
options.fields && typeof options.fields === 'function'
|
||||
? options.fields({ defaultFields })
|
||||
: defaultFields
|
||||
|
||||
return {
|
||||
...config,
|
||||
collections: config.collections?.map((collection) => {
|
||||
if (options.collections?.includes(collection.slug)) {
|
||||
return {
|
||||
...collection,
|
||||
fields: [...(collection.fields || []), ...fields],
|
||||
}
|
||||
}
|
||||
return collection
|
||||
}),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Disable Plugin Pattern
|
||||
|
||||
```typescript
|
||||
interface PluginConfig {
|
||||
disabled?: boolean
|
||||
collections?: string[]
|
||||
}
|
||||
|
||||
export const myPlugin =
|
||||
(options: PluginConfig): Plugin =>
|
||||
(config: Config): Config => {
|
||||
// Always add collections/fields for database schema consistency
|
||||
if (!config.collections) {
|
||||
config.collections = []
|
||||
}
|
||||
|
||||
config.collections.push({
|
||||
slug: 'plugin-collection',
|
||||
fields: [{ name: 'title', type: 'text' }],
|
||||
})
|
||||
|
||||
// If disabled, return early but keep schema changes
|
||||
if (options.disabled) {
|
||||
return config
|
||||
}
|
||||
|
||||
// Add endpoints, hooks, components only when enabled
|
||||
config.endpoints = [
|
||||
...(config.endpoints ?? []),
|
||||
{
|
||||
path: '/my-endpoint',
|
||||
method: 'get',
|
||||
handler: async () => Response.json({ message: 'Hello' }),
|
||||
},
|
||||
]
|
||||
|
||||
return config
|
||||
}
|
||||
```
|
||||
|
||||
## Admin Components
|
||||
|
||||
```typescript
|
||||
export const myPlugin =
|
||||
(options: PluginConfig): Plugin =>
|
||||
(config: Config): Config => {
|
||||
if (!config.admin) config.admin = {}
|
||||
if (!config.admin.components) config.admin.components = {}
|
||||
if (!config.admin.components.beforeDashboard) {
|
||||
config.admin.components.beforeDashboard = []
|
||||
}
|
||||
|
||||
// Add client component
|
||||
config.admin.components.beforeDashboard.push('my-plugin-name/client#BeforeDashboardClient')
|
||||
|
||||
// Add server component (RSC)
|
||||
config.admin.components.beforeDashboard.push('my-plugin-name/rsc#BeforeDashboardServer')
|
||||
|
||||
return config
|
||||
}
|
||||
```
|
||||
|
||||
## onInit Hook
|
||||
|
||||
```typescript
|
||||
export const myPlugin =
|
||||
(options: PluginConfig): Plugin =>
|
||||
(config: Config): Config => {
|
||||
const incomingOnInit = config.onInit
|
||||
|
||||
config.onInit = async (payload) => {
|
||||
// IMPORTANT: Call existing onInit first
|
||||
if (incomingOnInit) await incomingOnInit(payload)
|
||||
|
||||
// Plugin initialization
|
||||
payload.logger.info('Plugin initialized')
|
||||
|
||||
// Example: Seed data
|
||||
const { totalDocs } = await payload.count({
|
||||
collection: 'plugin-collection',
|
||||
where: { id: { equals: 'seeded-by-plugin' } },
|
||||
})
|
||||
|
||||
if (totalDocs === 0) {
|
||||
await payload.create({
|
||||
collection: 'plugin-collection',
|
||||
data: { id: 'seeded-by-plugin' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Preserve Existing Config
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
collections: [...(config.collections || []), newCollection]
|
||||
|
||||
// ❌ Bad
|
||||
collections: [newCollection]
|
||||
```
|
||||
|
||||
### Respect User Overrides
|
||||
|
||||
```typescript
|
||||
const collection: CollectionConfig = {
|
||||
slug: 'redirects',
|
||||
fields: defaultFields,
|
||||
...options.overrides, // User overrides last
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Composition
|
||||
|
||||
```typescript
|
||||
hooks: {
|
||||
...collection.hooks,
|
||||
afterChange: [
|
||||
myHook,
|
||||
...(collection.hooks?.afterChange || []),
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Type Safety
|
||||
|
||||
```typescript
|
||||
import type { Config, Plugin, CollectionConfig, Field } from 'payload'
|
||||
```
|
||||
223
.cursor/rules/queries.md
Normal file
223
.cursor/rules/queries.md
Normal file
@ -0,0 +1,223 @@
|
||||
---
|
||||
title: Queries
|
||||
description: Local API, REST, and GraphQL query patterns
|
||||
tags: [payload, queries, local-api, rest, graphql]
|
||||
---
|
||||
|
||||
# Payload CMS Queries
|
||||
|
||||
## Query Operators
|
||||
|
||||
```typescript
|
||||
// Equals
|
||||
{ color: { equals: 'blue' } }
|
||||
|
||||
// Not equals
|
||||
{ status: { not_equals: 'draft' } }
|
||||
|
||||
// Greater/less than
|
||||
{ price: { greater_than: 100 } }
|
||||
{ age: { less_than_equal: 65 } }
|
||||
|
||||
// Contains (case-insensitive)
|
||||
{ title: { contains: 'payload' } }
|
||||
|
||||
// Like (all words present)
|
||||
{ description: { like: 'cms headless' } }
|
||||
|
||||
// In/not in
|
||||
{ category: { in: ['tech', 'news'] } }
|
||||
|
||||
// Exists
|
||||
{ image: { exists: true } }
|
||||
|
||||
// Near (point fields)
|
||||
{ location: { near: [10, 20, 5000] } } // [lng, lat, maxDistance]
|
||||
```
|
||||
|
||||
## AND/OR Logic
|
||||
|
||||
```typescript
|
||||
{
|
||||
or: [
|
||||
{ color: { equals: 'mint' } },
|
||||
{
|
||||
and: [
|
||||
{ color: { equals: 'white' } },
|
||||
{ featured: { equals: false } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Nested Properties
|
||||
|
||||
```typescript
|
||||
{
|
||||
'author.role': { equals: 'editor' },
|
||||
'meta.featured': { exists: true },
|
||||
}
|
||||
```
|
||||
|
||||
## Local API
|
||||
|
||||
```typescript
|
||||
// Find documents
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
status: { equals: 'published' },
|
||||
'author.name': { contains: 'john' },
|
||||
},
|
||||
depth: 2, // Populate relationships
|
||||
limit: 10,
|
||||
page: 1,
|
||||
sort: '-createdAt',
|
||||
locale: 'en',
|
||||
select: {
|
||||
title: true,
|
||||
author: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Find by ID
|
||||
const post = await payload.findByID({
|
||||
collection: 'posts',
|
||||
id: '123',
|
||||
depth: 2,
|
||||
})
|
||||
|
||||
// Create
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'New Post',
|
||||
status: 'draft',
|
||||
},
|
||||
})
|
||||
|
||||
// Update
|
||||
await payload.update({
|
||||
collection: 'posts',
|
||||
id: '123',
|
||||
data: {
|
||||
status: 'published',
|
||||
},
|
||||
})
|
||||
|
||||
// Delete
|
||||
await payload.delete({
|
||||
collection: 'posts',
|
||||
id: '123',
|
||||
})
|
||||
|
||||
// Count
|
||||
const count = await payload.count({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
status: { equals: 'published' },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Access Control in Local API
|
||||
|
||||
**CRITICAL**: Local API bypasses access control by default (`overrideAccess: true`).
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: User is passed but access control is bypassed
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
user: currentUser,
|
||||
// Result: Operation runs with ADMIN privileges
|
||||
})
|
||||
|
||||
// ✅ CORRECT: Respects user's access control permissions
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
user: currentUser,
|
||||
overrideAccess: false, // Required to enforce access control
|
||||
})
|
||||
|
||||
// Administrative operation (intentionally bypass access control)
|
||||
const allPosts = await payload.find({
|
||||
collection: 'posts',
|
||||
// No user parameter, overrideAccess defaults to true
|
||||
})
|
||||
```
|
||||
|
||||
**When to use `overrideAccess: false`:**
|
||||
|
||||
- Performing operations on behalf of a user
|
||||
- Testing access control logic
|
||||
- API routes that should respect user permissions
|
||||
|
||||
## REST API
|
||||
|
||||
```typescript
|
||||
import { stringify } from 'qs-esm'
|
||||
|
||||
const query = {
|
||||
status: { equals: 'published' },
|
||||
}
|
||||
|
||||
const queryString = stringify(
|
||||
{
|
||||
where: query,
|
||||
depth: 2,
|
||||
limit: 10,
|
||||
},
|
||||
{ addQueryPrefix: true },
|
||||
)
|
||||
|
||||
const response = await fetch(`https://api.example.com/api/posts${queryString}`)
|
||||
const data = await response.json()
|
||||
```
|
||||
|
||||
### REST Endpoints
|
||||
|
||||
```
|
||||
GET /api/{collection} - Find documents
|
||||
GET /api/{collection}/{id} - Find by ID
|
||||
POST /api/{collection} - Create
|
||||
PATCH /api/{collection}/{id} - Update
|
||||
DELETE /api/{collection}/{id} - Delete
|
||||
GET /api/{collection}/count - Count documents
|
||||
|
||||
GET /api/globals/{slug} - Get global
|
||||
POST /api/globals/{slug} - Update global
|
||||
```
|
||||
|
||||
## GraphQL
|
||||
|
||||
```graphql
|
||||
query {
|
||||
Posts(where: { status: { equals: published } }, limit: 10, sort: "-createdAt") {
|
||||
docs {
|
||||
id
|
||||
title
|
||||
author {
|
||||
name
|
||||
}
|
||||
}
|
||||
totalDocs
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
|
||||
mutation {
|
||||
createPost(data: { title: "New Post", status: draft }) {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Best Practices
|
||||
|
||||
- Set `maxDepth` on relationships to prevent over-fetching
|
||||
- Use `select` to limit returned fields
|
||||
- Index frequently queried fields
|
||||
- Use `virtual` fields for computed data
|
||||
- Cache expensive operations in hook `context`
|
||||
122
.cursor/rules/security-critical.mdc
Normal file
122
.cursor/rules/security-critical.mdc
Normal file
@ -0,0 +1,122 @@
|
||||
---
|
||||
title: Critical Security Patterns
|
||||
description: The three most important security patterns in Payload CMS
|
||||
tags: [payload, security, critical, access-control, transactions, hooks]
|
||||
priority: high
|
||||
---
|
||||
|
||||
# CRITICAL SECURITY PATTERNS
|
||||
|
||||
These are the three most critical security patterns that MUST be followed in every Payload CMS project.
|
||||
|
||||
## 1. Local API Access Control (MOST IMPORTANT)
|
||||
|
||||
**By default, Local API operations bypass ALL access control**, even when passing a user.
|
||||
|
||||
```typescript
|
||||
// ❌ SECURITY BUG: Passes user but ignores their permissions
|
||||
await payload.find({
|
||||
collection: 'posts',
|
||||
user: someUser, // Access control is BYPASSED!
|
||||
})
|
||||
|
||||
// ✅ SECURE: Actually enforces the user's permissions
|
||||
await payload.find({
|
||||
collection: 'posts',
|
||||
user: someUser,
|
||||
overrideAccess: false, // REQUIRED for access control
|
||||
})
|
||||
|
||||
// ✅ Administrative operation (intentional bypass)
|
||||
await payload.find({
|
||||
collection: 'posts',
|
||||
// No user, overrideAccess defaults to true
|
||||
})
|
||||
```
|
||||
|
||||
**When to use each:**
|
||||
|
||||
- `overrideAccess: true` (default) - Server-side operations you trust (cron jobs, system tasks)
|
||||
- `overrideAccess: false` - When operating on behalf of a user (API routes, webhooks)
|
||||
|
||||
**Rule**: When passing `user` to Local API, ALWAYS set `overrideAccess: false`
|
||||
|
||||
## 2. Transaction Safety in Hooks
|
||||
|
||||
**Nested operations in hooks without `req` break transaction atomicity.**
|
||||
|
||||
```typescript
|
||||
// ❌ DATA CORRUPTION RISK: Separate transaction
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, req }) => {
|
||||
await req.payload.create({
|
||||
collection: 'audit-log',
|
||||
data: { docId: doc.id },
|
||||
// Missing req - runs in separate transaction!
|
||||
})
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ✅ ATOMIC: Same transaction
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, req }) => {
|
||||
await req.payload.create({
|
||||
collection: 'audit-log',
|
||||
data: { docId: doc.id },
|
||||
req, // Maintains atomicity
|
||||
})
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Matters:**
|
||||
|
||||
- **MongoDB (with replica sets)**: Creates atomic session across operations
|
||||
- **PostgreSQL**: All operations use same Drizzle transaction
|
||||
- **SQLite (with transactions enabled)**: Ensures rollback on errors
|
||||
- **Without req**: Each operation runs independently, breaking atomicity
|
||||
|
||||
**Rule**: ALWAYS pass `req` to nested operations in hooks
|
||||
|
||||
## 3. Prevent Infinite Hook Loops
|
||||
|
||||
**Hooks triggering operations that trigger the same hooks create infinite loops.**
|
||||
|
||||
```typescript
|
||||
// ❌ INFINITE LOOP
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, req }) => {
|
||||
await req.payload.update({
|
||||
collection: 'posts',
|
||||
id: doc.id,
|
||||
data: { views: doc.views + 1 },
|
||||
req,
|
||||
}) // Triggers afterChange again!
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ✅ SAFE: Use context flag
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, req, context }) => {
|
||||
if (context.skipHooks) return
|
||||
|
||||
await req.payload.update({
|
||||
collection: 'posts',
|
||||
id: doc.id,
|
||||
data: { views: doc.views + 1 },
|
||||
context: { skipHooks: true },
|
||||
req,
|
||||
})
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Rule**: Use `req.context` flags to prevent hook loops
|
||||
10
.editorconfig
Normal file
10
.editorconfig
Normal file
@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
end_of_line = lf
|
||||
max_line_length = null
|
||||
17
.env.example
Normal file
17
.env.example
Normal file
@ -0,0 +1,17 @@
|
||||
# Database connection string
|
||||
DATABASE_URL=mongodb://127.0.0.1/your-database-name
|
||||
|
||||
# Or use a PG connection string
|
||||
#DATABASE_URL=postgresql://127.0.0.1:5432/your-database-name
|
||||
|
||||
# Used to encrypt JWT tokens
|
||||
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
||||
|
||||
# Used to configure CORS, format links and more. No trailing slash
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
|
||||
# Secret used to authenticate cron jobs
|
||||
CRON_SECRET=YOUR_CRON_SECRET_HERE
|
||||
|
||||
# Used to validate preview requests
|
||||
PREVIEW_SECRET=YOUR_SECRET_HERE
|
||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
build
|
||||
dist / media
|
||||
node_modules
|
||||
.DS_Store
|
||||
.env
|
||||
.next
|
||||
.vercel
|
||||
|
||||
# Payload default media upload directory
|
||||
public/media/
|
||||
|
||||
public/robots.txt
|
||||
public/sitemap*.xml
|
||||
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
14
.prettierignore
Normal file
14
.prettierignore
Normal file
@ -0,0 +1,14 @@
|
||||
**/payload-types.ts
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
**/docs/**
|
||||
tsconfig.json
|
||||
|
||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": false
|
||||
}
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
||||
24
.vscode/launch.json
vendored
Normal file
24
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
|
||||
"runtimeArgs": ["--inspect"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"serverReadyAction": {
|
||||
"action": "debugWithChrome",
|
||||
"killOnServerStop": true,
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
},
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
40
.vscode/settings.json
vendored
Normal file
40
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"npm.packageManager": "pnpm",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"[javascript][typescript][typescriptreact]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
}
|
||||
}
|
||||
71
Dockerfile
Normal file
71
Dockerfile
Normal file
@ -0,0 +1,71 @@
|
||||
# To use this Dockerfile, you have to set `output: 'standalone'` in your next.config.js file.
|
||||
# From https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
|
||||
|
||||
FROM node:22.17.0-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Remove this line if you do not have this folder
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
CMD HOSTNAME="0.0.0.0" node server.js
|
||||
303
README.md
Normal file
303
README.md
Normal file
@ -0,0 +1,303 @@
|
||||
# Payload Website Template
|
||||
|
||||
This is the official [Payload Website Template](https://github.com/payloadcms/payload/blob/main/templates/website). Use it to power websites, blogs, or portfolios from small to enterprise. This repo includes a fully-working backend, enterprise-grade admin panel, and a beautifully designed, production-ready website.
|
||||
|
||||
This template is right for you if you are working on:
|
||||
|
||||
- A personal or enterprise-grade website, blog, or portfolio
|
||||
- A content publishing platform with a fully featured publication workflow
|
||||
- Exploring the capabilities of Payload
|
||||
|
||||
Core features:
|
||||
|
||||
- [Pre-configured Payload Config](#how-it-works)
|
||||
- [Authentication](#users-authentication)
|
||||
- [Access Control](#access-control)
|
||||
- [Layout Builder](#layout-builder)
|
||||
- [Draft Preview](#draft-preview)
|
||||
- [Live Preview](#live-preview)
|
||||
- [On-demand Revalidation](#on-demand-revalidation)
|
||||
- [SEO](#seo)
|
||||
- [Search](#search)
|
||||
- [Redirects](#redirects)
|
||||
- [Jobs and Scheduled Publishing](#jobs-and-scheduled-publish)
|
||||
- [Website](#website)
|
||||
|
||||
## Quick Start
|
||||
|
||||
To spin up this example locally, follow these steps:
|
||||
|
||||
### Clone
|
||||
|
||||
If you have not done so already, you need to have standalone copy of this repo on your machine. If you've already cloned this repo, skip to [Development](#development).
|
||||
|
||||
Use the `create-payload-app` CLI to clone this template directly to your machine:
|
||||
|
||||
```bash
|
||||
pnpx create-payload-app my-project -t website
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
1. First [clone the repo](#clone) if you have not done so already
|
||||
1. `cd my-project && cp .env.example .env` to copy the example environment variables
|
||||
1. `pnpm install && pnpm dev` to install dependencies and start the dev server
|
||||
1. open `http://localhost:3000` to open the app in your browser
|
||||
|
||||
That's it! Changes made in `./src` will be reflected in your app. Follow the on-screen instructions to login and create your first admin user. Then check out [Production](#production) once you're ready to build and serve your app, and [Deployment](#deployment) when you're ready to go live.
|
||||
|
||||
## How it works
|
||||
|
||||
The Payload config is tailored specifically to the needs of most websites. It is pre-configured in the following ways:
|
||||
|
||||
### Collections
|
||||
|
||||
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend this functionality.
|
||||
|
||||
- #### Users (Authentication)
|
||||
|
||||
Users are auth-enabled collections that have access to the admin panel and unpublished content. See [Access Control](#access-control) for more details.
|
||||
|
||||
For additional help, see the official [Auth Example](https://github.com/payloadcms/payload/tree/main/examples/auth) or the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs.
|
||||
|
||||
- #### Posts
|
||||
|
||||
Posts are used to generate blog posts, news articles, or any other type of content that is published over time. All posts are layout builder enabled so you can generate unique layouts for each post using layout-building blocks, see [Layout Builder](#layout-builder) for more details. Posts are also draft-enabled so you can preview them before publishing them to your website, see [Draft Preview](#draft-preview) for more details.
|
||||
|
||||
- #### Pages
|
||||
|
||||
All pages are layout builder enabled so you can generate unique layouts for each page using layout-building blocks, see [Layout Builder](#layout-builder) for more details. Pages are also draft-enabled so you can preview them before publishing them to your website, see [Draft Preview](#draft-preview) for more details.
|
||||
|
||||
- #### Media
|
||||
|
||||
This is the uploads enabled collection used by pages, posts, and projects to contain media like images, videos, downloads, and other assets. It features pre-configured sizes, focal point and manual resizing to help you manage your pictures.
|
||||
|
||||
- #### Categories
|
||||
|
||||
A taxonomy used to group posts together. Categories can be nested inside of one another, for example "News > Technology". See the official [Payload Nested Docs Plugin](https://payloadcms.com/docs/plugins/nested-docs) for more details.
|
||||
|
||||
### Globals
|
||||
|
||||
See the [Globals](https://payloadcms.com/docs/configuration/globals) docs for details on how to extend this functionality.
|
||||
|
||||
- `Header`
|
||||
|
||||
The data required by the header on your front-end like nav links.
|
||||
|
||||
- `Footer`
|
||||
|
||||
Same as above but for the footer of your site.
|
||||
|
||||
## Access control
|
||||
|
||||
Basic access control is setup to limit access to various content based based on publishing status.
|
||||
|
||||
- `users`: Users can access the admin panel and create or edit content.
|
||||
- `posts`: Everyone can access published posts, but only users can create, update, or delete them.
|
||||
- `pages`: Everyone can access published pages, but only users can create, update, or delete them.
|
||||
|
||||
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview#access-control) docs.
|
||||
|
||||
## Layout Builder
|
||||
|
||||
Create unique page layouts for any type of content using a powerful layout builder. This template comes pre-configured with the following layout building blocks:
|
||||
|
||||
- Hero
|
||||
- Content
|
||||
- Media
|
||||
- Call To Action
|
||||
- Archive
|
||||
|
||||
Each block is fully designed and built into the front-end website that comes with this template. See [Website](#website) for more details.
|
||||
|
||||
## Lexical editor
|
||||
|
||||
A deep editorial experience that allows complete freedom to focus just on writing content without breaking out of the flow with support for Payload blocks, media, links and other features provided out of the box. See [Lexical](https://payloadcms.com/docs/rich-text/overview) docs.
|
||||
|
||||
## Draft Preview
|
||||
|
||||
All posts and pages are draft-enabled so you can preview them before publishing them to your website. To do this, these collections use [Versions](https://payloadcms.com/docs/configuration/collections#versions) with `drafts` set to `true`. This means that when you create a new post, project, or page, it will be saved as a draft and will not be visible on your website until you publish it. This also means that you can preview your draft before publishing it to your website. To do this, we automatically format a custom URL which redirects to your front-end to securely fetch the draft version of your content.
|
||||
|
||||
Since the front-end of this template is statically generated, this also means that pages, posts, and projects will need to be regenerated as changes are made to published documents. To do this, we use an `afterChange` hook to regenerate the front-end when a document has changed and its `_status` is `published`.
|
||||
|
||||
For more details on how to extend this functionality, see the official [Draft Preview Example](https://github.com/payloadcms/payload/tree/examples/draft-preview).
|
||||
|
||||
## Live preview
|
||||
|
||||
In addition to draft previews you can also enable live preview to view your end resulting page as you're editing content with full support for SSR rendering. See [Live preview docs](https://payloadcms.com/docs/live-preview/overview) for more details.
|
||||
|
||||
## On-demand Revalidation
|
||||
|
||||
We've added hooks to collections and globals so that all of your pages, posts, footer, or header changes will automatically be updated in the frontend via on-demand revalidation supported by Nextjs.
|
||||
|
||||
> Note: if an image has been changed, for example it's been cropped, you will need to republish the page it's used on in order to be able to revalidate the Nextjs image cache.
|
||||
|
||||
## SEO
|
||||
|
||||
This template comes pre-configured with the official [Payload SEO Plugin](https://payloadcms.com/docs/plugins/seo) for complete SEO control from the admin panel. All SEO data is fully integrated into the front-end website that comes with this template. See [Website](#website) for more details.
|
||||
|
||||
## Search
|
||||
|
||||
This template also pre-configured with the official [Payload Search Plugin](https://payloadcms.com/docs/plugins/search) to showcase how SSR search features can easily be implemented into Next.js with Payload. See [Website](#website) for more details.
|
||||
|
||||
## Redirects
|
||||
|
||||
If you are migrating an existing site or moving content to a new URL, you can use the `redirects` collection to create a proper redirect from old URLs to new ones. This will ensure that proper request status codes are returned to search engines and that your users are not left with a broken link. This template comes pre-configured with the official [Payload Redirects Plugin](https://payloadcms.com/docs/plugins/redirects) for complete redirect control from the admin panel. All redirects are fully integrated into the front-end website that comes with this template. See [Website](#website) for more details.
|
||||
|
||||
## Jobs and Scheduled Publish
|
||||
|
||||
We have configured [Scheduled Publish](https://payloadcms.com/docs/versions/drafts#scheduled-publish) which uses the [jobs queue](https://payloadcms.com/docs/jobs-queue/jobs) in order to publish or unpublish your content on a scheduled time. The tasks are run on a cron schedule and can also be run as a separate instance if needed.
|
||||
|
||||
> Note: When deployed on Vercel, depending on the plan tier, you may be limited to daily cron only.
|
||||
|
||||
## Website
|
||||
|
||||
This template includes a beautifully designed, production-ready front-end built with the [Next.js App Router](https://nextjs.org), served right alongside your Payload app in a instance. This makes it so that you can deploy both your backend and website where you need it.
|
||||
|
||||
Core features:
|
||||
|
||||
- [Next.js App Router](https://nextjs.org)
|
||||
- [TypeScript](https://www.typescriptlang.org)
|
||||
- [React Hook Form](https://react-hook-form.com)
|
||||
- [Payload Admin Bar](https://github.com/payloadcms/payload/tree/main/packages/admin-bar)
|
||||
- [TailwindCSS styling](https://tailwindcss.com/)
|
||||
- [shadcn/ui components](https://ui.shadcn.com/)
|
||||
- User Accounts and Authentication
|
||||
- Fully featured blog
|
||||
- Publication workflow
|
||||
- Dark mode
|
||||
- Pre-made layout building blocks
|
||||
- SEO
|
||||
- Search
|
||||
- Redirects
|
||||
- Live preview
|
||||
|
||||
### Cache
|
||||
|
||||
Although Next.js includes a robust set of caching strategies out of the box, Payload Cloud proxies and caches all files through Cloudflare using the [Official Cloud Plugin](https://www.npmjs.com/package/@payloadcms/payload-cloud). This means that Next.js caching is not needed and is disabled by default. If you are hosting your app outside of Payload Cloud, you can easily reenable the Next.js caching mechanisms by removing the `no-store` directive from all fetch requests in `./src/app/_api` and then removing all instances of `export const dynamic = 'force-dynamic'` from pages files, such as `./src/app/(pages)/[slug]/page.tsx`. For more details, see the official [Next.js Caching Docs](https://nextjs.org/docs/app/building-your-application/caching).
|
||||
|
||||
## Development
|
||||
|
||||
To spin up this example locally, follow the [Quick Start](#quick-start). Then [Seed](#seed) the database with a few pages, posts, and projects.
|
||||
|
||||
### Working with Postgres
|
||||
|
||||
Postgres and other SQL-based databases follow a strict schema for managing your data. In comparison to our MongoDB adapter, this means that there's a few extra steps to working with Postgres.
|
||||
|
||||
Note that often times when making big schema changes you can run the risk of losing data if you're not manually migrating it.
|
||||
|
||||
#### Local development
|
||||
|
||||
Ideally we recommend running a local copy of your database so that schema updates are as fast as possible. By default the Postgres adapter has `push: true` for development environments. This will let you add, modify and remove fields and collections without needing to run any data migrations.
|
||||
|
||||
If your database is pointed to production you will want to set `push: false` otherwise you will risk losing data or having your migrations out of sync.
|
||||
|
||||
#### Migrations
|
||||
|
||||
[Migrations](https://payloadcms.com/docs/database/migrations) are essentially SQL code versions that keeps track of your schema. When deploy with Postgres you will need to make sure you create and then run your migrations.
|
||||
|
||||
Locally create a migration
|
||||
|
||||
```bash
|
||||
pnpm payload migrate:create
|
||||
```
|
||||
|
||||
This creates the migration files you will need to push alongside with your new configuration.
|
||||
|
||||
On the server after building and before running `pnpm start` you will want to run your migrations
|
||||
|
||||
```bash
|
||||
pnpm payload migrate
|
||||
```
|
||||
|
||||
This command will check for any migrations that have not yet been run and try to run them and it will keep a record of migrations that have been run in the database.
|
||||
|
||||
### Docker
|
||||
|
||||
Alternatively, you can use [Docker](https://www.docker.com) to spin up this template locally. To do so, follow these steps:
|
||||
|
||||
1. Follow [steps 1 and 2 from above](#development), the docker-compose file will automatically use the `.env` file in your project root
|
||||
1. Next run `docker-compose up`
|
||||
1. Follow [steps 4 and 5 from above](#development) to login and create your first admin user
|
||||
|
||||
That's it! The Docker instance will help you get up and running quickly while also standardizing the development environment across your teams.
|
||||
|
||||
### Seed
|
||||
|
||||
To seed the database with a few pages, posts, and projects you can click the 'seed database' link from the admin panel.
|
||||
|
||||
The seed script will also create a demo user for demonstration purposes only:
|
||||
|
||||
- Demo Author
|
||||
- Email: `demo-author@payloadcms.com`
|
||||
- Password: `password`
|
||||
|
||||
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
|
||||
|
||||
## Production
|
||||
|
||||
To run Payload in production, you need to build and start the Admin panel. To do so, follow these steps:
|
||||
|
||||
1. Invoke the `next build` script by running `pnpm build` or `npm run build` in your project root. This creates a `.next` directory with a production-ready admin bundle.
|
||||
1. Finally run `pnpm start` or `npm run start` to run Node in production and serve Payload from the `.build` directory.
|
||||
1. When you're ready to go live, see Deployment below for more details.
|
||||
|
||||
### Deploying to Vercel
|
||||
|
||||
This template can also be deployed to Vercel for free. You can get started by choosing the Vercel DB adapter during the setup of the template or by manually installing and configuring it:
|
||||
|
||||
```bash
|
||||
pnpm add @payloadcms/db-vercel-postgres
|
||||
```
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'
|
||||
|
||||
export default buildConfig({
|
||||
// ...
|
||||
db: vercelPostgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.POSTGRES_URL || '',
|
||||
},
|
||||
}),
|
||||
// ...
|
||||
```
|
||||
|
||||
We also support Vercel's blob storage:
|
||||
|
||||
```bash
|
||||
pnpm add @payloadcms/storage-vercel-blob
|
||||
```
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
|
||||
|
||||
export default buildConfig({
|
||||
// ...
|
||||
plugins: [
|
||||
vercelBlobStorage({
|
||||
collections: {
|
||||
[Media.slug]: true,
|
||||
},
|
||||
token: process.env.BLOB_READ_WRITE_TOKEN || '',
|
||||
}),
|
||||
],
|
||||
// ...
|
||||
```
|
||||
|
||||
There is also a simplified [one click deploy](https://github.com/payloadcms/payload/tree/templates/with-vercel-postgres) to Vercel should you need it.
|
||||
|
||||
### Self-hosting
|
||||
|
||||
Before deploying your app, you need to:
|
||||
|
||||
1. Ensure your app builds and serves in production. See [Production](#production) for more details.
|
||||
2. You can then deploy Payload as you would any other Node.js or Next.js application either directly on a VPS, DigitalOcean's Apps Platform, via Coolify or more. More guides coming soon.
|
||||
|
||||
You can also deploy your app manually, check out the [deployment documentation](https://payloadcms.com/docs/production/deployment) for full details.
|
||||
|
||||
## Questions
|
||||
|
||||
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).
|
||||
17
components.json
Normal file
17
components.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/app/(frontend)/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/utilities/ui"
|
||||
}
|
||||
}
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@ -0,0 +1,31 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
payload:
|
||||
image: node:18-alpine
|
||||
ports:
|
||||
- '3000:3000'
|
||||
volumes:
|
||||
- .:/home/node/app
|
||||
- node_modules:/home/node/app/node_modules
|
||||
working_dir: /home/node/app/
|
||||
command: sh -c "yarn install && yarn dev"
|
||||
depends_on:
|
||||
- mongo
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
mongo:
|
||||
image: mongo:latest
|
||||
ports:
|
||||
- '27017:27017'
|
||||
command:
|
||||
- --storageEngine=wiredTiger
|
||||
volumes:
|
||||
- data:/data/db
|
||||
logging:
|
||||
driver: none
|
||||
|
||||
volumes:
|
||||
data:
|
||||
node_modules:
|
||||
38
eslint.config.mjs
Normal file
38
eslint.config.mjs
Normal file
@ -0,0 +1,38 @@
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { FlatCompat } from '@eslint/eslintrc'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
})
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/ban-ts-comment': 'warn',
|
||||
'@typescript-eslint/no-empty-object-type': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
args: 'after-used',
|
||||
ignoreRestSiblings: false,
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^(_|ignore)',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['.next/'],
|
||||
},
|
||||
]
|
||||
|
||||
export default eslintConfig
|
||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
20
next-sitemap.config.cjs
Normal file
20
next-sitemap.config.cjs
Normal file
@ -0,0 +1,20 @@
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SERVER_URL ||
|
||||
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
|
||||
'https://example.com'
|
||||
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: SITE_URL,
|
||||
generateRobotsTxt: true,
|
||||
exclude: ['/posts-sitemap.xml', '/pages-sitemap.xml', '/*', '/posts/*'],
|
||||
robotsTxtOptions: {
|
||||
policies: [
|
||||
{
|
||||
userAgent: '*',
|
||||
disallow: '/admin/*',
|
||||
},
|
||||
],
|
||||
additionalSitemaps: [`${SITE_URL}/pages-sitemap.xml`, `${SITE_URL}/posts-sitemap.xml`],
|
||||
},
|
||||
}
|
||||
36
next.config.js
Normal file
36
next.config.js
Normal file
@ -0,0 +1,36 @@
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
|
||||
import redirects from './redirects.js'
|
||||
|
||||
const NEXT_PUBLIC_SERVER_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL
|
||||
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: undefined || process.env.__NEXT_PRIVATE_ORIGIN || 'http://localhost:3000'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
...[NEXT_PUBLIC_SERVER_URL /* 'https://example.com' */].map((item) => {
|
||||
const url = new URL(item)
|
||||
|
||||
return {
|
||||
hostname: url.hostname,
|
||||
protocol: url.protocol.replace(':', ''),
|
||||
}
|
||||
}),
|
||||
],
|
||||
},
|
||||
webpack: (webpackConfig) => {
|
||||
webpackConfig.resolve.extensionAlias = {
|
||||
'.cjs': ['.cts', '.cjs'],
|
||||
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
||||
'.mjs': ['.mts', '.mjs'],
|
||||
}
|
||||
|
||||
return webpackConfig
|
||||
},
|
||||
reactStrictMode: true,
|
||||
redirects,
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig, { devBundleServerPackages: false })
|
||||
15368
package-lock.json
generated
Normal file
15368
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
94
package.json
Normal file
94
package.json
Normal file
@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "fdweb2",
|
||||
"version": "1.0.0",
|
||||
"description": "Website template for Payload",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
|
||||
"postbuild": "next-sitemap --config next-sitemap.config.cjs",
|
||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && pnpm build && pnpm start",
|
||||
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
|
||||
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
||||
"ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install",
|
||||
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
|
||||
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation next lint --fix",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
||||
"test": "pnpm run test:int && pnpm run test:e2e",
|
||||
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts",
|
||||
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/admin-bar": "3.76.0",
|
||||
"@payloadcms/live-preview-react": "3.76.0",
|
||||
"@payloadcms/next": "3.76.0",
|
||||
"@payloadcms/plugin-form-builder": "3.76.0",
|
||||
"@payloadcms/plugin-nested-docs": "3.76.0",
|
||||
"@payloadcms/plugin-redirects": "3.76.0",
|
||||
"@payloadcms/plugin-search": "3.76.0",
|
||||
"@payloadcms/plugin-seo": "3.76.0",
|
||||
"@payloadcms/richtext-lexical": "3.76.0",
|
||||
"@payloadcms/ui": "3.76.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"geist": "^1.3.0",
|
||||
"graphql": "^16.8.2",
|
||||
"lucide-react": "0.563.0",
|
||||
"next": "15.4.11",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"payload": "3.76.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "7.71.1",
|
||||
"sharp": "0.34.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"@payloadcms/db-sqlite": "3.76.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@types/escape-html": "^1.0.2",
|
||||
"@types/node": "22.19.9",
|
||||
"@types/react": "19.2.9",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "4.5.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "15.4.11",
|
||||
"jsdom": "28.0.0",
|
||||
"playwright": "1.56.1",
|
||||
"playwright-core": "1.56.1",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "5.7.3",
|
||||
"vite-tsconfig-paths": "6.0.5",
|
||||
"vitest": "4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0",
|
||||
"pnpm": "^9 || ^10"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"esbuild",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
}
|
||||
41
playwright.config.ts
Normal file
41
playwright.config.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
import 'dotenv/config'
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
reuseExistingServer: true,
|
||||
url: 'http://localhost:3000',
|
||||
},
|
||||
})
|
||||
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
23
public/favicon.svg
Normal file
23
public/favicon.svg
Normal file
@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><style>
|
||||
#light-icon {
|
||||
display: inline;
|
||||
}
|
||||
#dark-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#light-icon {
|
||||
display: none;
|
||||
}
|
||||
#dark-icon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
</style><g id="light-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><g clip-path="url(#SvgjsClipPath1059)"><rect width="1000" height="1000" fill="#000000"></rect><g transform="matrix(5,0,0,5,192.5,150)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="123" height="140"><svg width="123" height="140" viewBox="0 0 123 140" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M60.2569 118.758L18.9035 94.9917C18.4016 94.6917 18.067 94.1583 18.067 93.5583V56.825C18.067 56.1917 18.7696 55.7917 19.3049 56.0917L67.3164 83.6917C67.9855 84.0917 68.822 83.5917 68.822 82.825V64.925C68.822 64.225 68.4539 63.5583 67.8182 63.1917L10.0707 29.9917C9.56883 29.6917 8.89968 29.6917 8.39782 29.9917L0.836436 34.3583C0.334574 34.6583 0 35.1917 0 35.7917V104.025C0 104.625 0.334574 105.158 0.836436 105.458L60.1565 139.592C60.6583 139.892 61.3275 139.892 61.8293 139.592L111.647 110.925C112.317 110.525 112.317 109.592 111.647 109.192L96.1232 100.258C95.4875 99.8917 94.7515 99.8917 94.1158 100.258L61.9632 118.758C61.4613 119.058 60.7922 119.058 60.2903 118.758H60.2569Z" fill="white"></path>
|
||||
<path d="M121.149 34.325L61.8294 0.225C61.3275 -0.075 60.6584 -0.075 60.1565 0.225L28.8069 18.2583C28.1378 18.6583 28.1378 19.5917 28.8069 19.9917L44.1973 28.8583C44.833 29.225 45.5691 29.225 46.2048 28.8583L60.2569 20.7917C60.7588 20.4917 61.4279 20.4917 61.9298 20.7917L103.283 44.5583C103.785 44.8583 104.12 45.3917 104.12 45.9917V82.8917C104.12 83.5917 104.488 84.2583 105.123 84.625L120.514 93.4583C121.183 93.8583 122.019 93.3583 122.019 92.5917V35.7917C122.019 35.1917 121.685 34.6583 121.183 34.3583L121.149 34.325Z" fill="white"></path>
|
||||
</svg></svg></g></g><defs><clipPath id="SvgjsClipPath1059"><rect width="1000" height="1000" x="0" y="0" rx="350" ry="350"></rect></clipPath></defs></svg></g><g id="dark-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><g clip-path="url(#SvgjsClipPath1060)"><rect width="1000" height="1000" fill="#000000"></rect><g transform="matrix(5,0,0,5,192.5,150)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="123" height="140"><svg width="123" height="140" viewBox="0 0 123 140" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M60.2569 118.758L18.9035 94.9917C18.4016 94.6917 18.067 94.1583 18.067 93.5583V56.825C18.067 56.1917 18.7696 55.7917 19.3049 56.0917L67.3164 83.6917C67.9855 84.0917 68.822 83.5917 68.822 82.825V64.925C68.822 64.225 68.4539 63.5583 67.8182 63.1917L10.0707 29.9917C9.56883 29.6917 8.89968 29.6917 8.39782 29.9917L0.836436 34.3583C0.334574 34.6583 0 35.1917 0 35.7917V104.025C0 104.625 0.334574 105.158 0.836436 105.458L60.1565 139.592C60.6583 139.892 61.3275 139.892 61.8293 139.592L111.647 110.925C112.317 110.525 112.317 109.592 111.647 109.192L96.1232 100.258C95.4875 99.8917 94.7515 99.8917 94.1158 100.258L61.9632 118.758C61.4613 119.058 60.7922 119.058 60.2903 118.758H60.2569Z" fill="white"></path>
|
||||
<path d="M121.149 34.325L61.8294 0.225C61.3275 -0.075 60.6584 -0.075 60.1565 0.225L28.8069 18.2583C28.1378 18.6583 28.1378 19.5917 28.8069 19.9917L44.1973 28.8583C44.833 29.225 45.5691 29.225 46.2048 28.8583L60.2569 20.7917C60.7588 20.4917 61.4279 20.4917 61.9298 20.7917L103.283 44.5583C103.785 44.8583 104.12 45.3917 104.12 45.9917V82.8917C104.12 83.5917 104.488 84.2583 105.123 84.625L120.514 93.4583C121.183 93.8583 122.019 93.3583 122.019 92.5917V35.7917C122.019 35.1917 121.685 34.6583 121.183 34.3583L121.149 34.325Z" fill="white"></path>
|
||||
</svg></svg></g></g><defs><clipPath id="SvgjsClipPath1060"><rect width="1000" height="1000" x="0" y="0" rx="350" ry="350"></rect></clipPath></defs></svg></g></svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/website-template-OG.webp
Normal file
BIN
public/website-template-OG.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
20
redirects.js
Normal file
20
redirects.js
Normal file
@ -0,0 +1,20 @@
|
||||
const redirects = async () => {
|
||||
const internetExplorerRedirect = {
|
||||
destination: '/ie-incompatible.html',
|
||||
has: [
|
||||
{
|
||||
type: 'header',
|
||||
key: 'user-agent',
|
||||
value: '(.*Trident.*)', // all ie browsers
|
||||
},
|
||||
],
|
||||
permanent: false,
|
||||
source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page
|
||||
}
|
||||
|
||||
const redirects = [internetExplorerRedirect]
|
||||
|
||||
return redirects
|
||||
}
|
||||
|
||||
export default redirects
|
||||
34
src/Footer/Component.tsx
Normal file
34
src/Footer/Component.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { getCachedGlobal } from '@/utilities/getGlobals'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import type { Footer } from '@/payload-types'
|
||||
|
||||
import { ThemeSelector } from '@/providers/Theme/ThemeSelector'
|
||||
import { CMSLink } from '@/components/Link'
|
||||
import { Logo } from '@/components/Logo/Logo'
|
||||
|
||||
export async function Footer() {
|
||||
const footerData: Footer = await getCachedGlobal('footer', 1)()
|
||||
|
||||
const navItems = footerData?.navItems || []
|
||||
|
||||
return (
|
||||
<footer className="mt-auto border-t border-border bg-black dark:bg-card text-white">
|
||||
<div className="container py-8 gap-8 flex flex-col md:flex-row md:justify-between">
|
||||
<Link className="flex items-center" href="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col-reverse items-start md:flex-row gap-4 md:items-center">
|
||||
<ThemeSelector />
|
||||
<nav className="flex flex-col md:flex-row gap-4">
|
||||
{navItems.map(({ link }, i) => {
|
||||
return <CMSLink className="text-white" key={i} {...link} />
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
13
src/Footer/RowLabel.tsx
Normal file
13
src/Footer/RowLabel.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
import { Header } from '@/payload-types'
|
||||
import { RowLabelProps, useRowLabel } from '@payloadcms/ui'
|
||||
|
||||
export const RowLabel: React.FC<RowLabelProps> = () => {
|
||||
const data = useRowLabel<NonNullable<Header['navItems']>[number]>()
|
||||
|
||||
const label = data?.data?.link?.label
|
||||
? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.link?.label}`
|
||||
: 'Row'
|
||||
|
||||
return <div>{label}</div>
|
||||
}
|
||||
32
src/Footer/config.ts
Normal file
32
src/Footer/config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { link } from '@/fields/link'
|
||||
import { revalidateFooter } from './hooks/revalidateFooter'
|
||||
|
||||
export const Footer: GlobalConfig = {
|
||||
slug: 'footer',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'navItems',
|
||||
type: 'array',
|
||||
fields: [
|
||||
link({
|
||||
appearances: false,
|
||||
}),
|
||||
],
|
||||
maxRows: 6,
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
components: {
|
||||
RowLabel: '@/Footer/RowLabel#RowLabel',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidateFooter],
|
||||
},
|
||||
}
|
||||
13
src/Footer/hooks/revalidateFooter.ts
Normal file
13
src/Footer/hooks/revalidateFooter.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { GlobalAfterChangeHook } from 'payload'
|
||||
|
||||
import { revalidateTag } from 'next/cache'
|
||||
|
||||
export const revalidateFooter: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
payload.logger.info(`Revalidating footer`)
|
||||
|
||||
revalidateTag('global_footer')
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
42
src/Header/Component.client.tsx
Normal file
42
src/Header/Component.client.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
import { useHeaderTheme } from '@/providers/HeaderTheme'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
import type { Header } from '@/payload-types'
|
||||
|
||||
import { Logo } from '@/components/Logo/Logo'
|
||||
import { HeaderNav } from './Nav'
|
||||
|
||||
interface HeaderClientProps {
|
||||
data: Header
|
||||
}
|
||||
|
||||
export const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => {
|
||||
/* Storing the value in a useState to avoid hydration errors */
|
||||
const [theme, setTheme] = useState<string | null>(null)
|
||||
const { headerTheme, setHeaderTheme } = useHeaderTheme()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderTheme(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (headerTheme && headerTheme !== theme) setTheme(headerTheme)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [headerTheme])
|
||||
|
||||
return (
|
||||
<header className="container relative z-20 " {...(theme ? { 'data-theme': theme } : {})}>
|
||||
<div className="py-8 flex justify-between">
|
||||
<Link href="/">
|
||||
<Logo loading="eager" priority="high" className="invert dark:invert-0" />
|
||||
</Link>
|
||||
<HeaderNav data={data} />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
11
src/Header/Component.tsx
Normal file
11
src/Header/Component.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { HeaderClient } from './Component.client'
|
||||
import { getCachedGlobal } from '@/utilities/getGlobals'
|
||||
import React from 'react'
|
||||
|
||||
import type { Header } from '@/payload-types'
|
||||
|
||||
export async function Header() {
|
||||
const headerData: Header = await getCachedGlobal('header', 1)()
|
||||
|
||||
return <HeaderClient data={headerData} />
|
||||
}
|
||||
25
src/Header/Nav/index.tsx
Normal file
25
src/Header/Nav/index.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import type { Header as HeaderType } from '@/payload-types'
|
||||
|
||||
import { CMSLink } from '@/components/Link'
|
||||
import Link from 'next/link'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
|
||||
export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
||||
const navItems = data?.navItems || []
|
||||
|
||||
return (
|
||||
<nav className="flex gap-3 items-center">
|
||||
{navItems.map(({ link }, i) => {
|
||||
return <CMSLink key={i} {...link} appearance="link" />
|
||||
})}
|
||||
<Link href="/search">
|
||||
<span className="sr-only">Search</span>
|
||||
<SearchIcon className="w-5 text-primary" />
|
||||
</Link>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
13
src/Header/RowLabel.tsx
Normal file
13
src/Header/RowLabel.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
import { Header } from '@/payload-types'
|
||||
import { RowLabelProps, useRowLabel } from '@payloadcms/ui'
|
||||
|
||||
export const RowLabel: React.FC<RowLabelProps> = () => {
|
||||
const data = useRowLabel<NonNullable<Header['navItems']>[number]>()
|
||||
|
||||
const label = data?.data?.link?.label
|
||||
? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.link?.label}`
|
||||
: 'Row'
|
||||
|
||||
return <div>{label}</div>
|
||||
}
|
||||
32
src/Header/config.ts
Normal file
32
src/Header/config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { link } from '@/fields/link'
|
||||
import { revalidateHeader } from './hooks/revalidateHeader'
|
||||
|
||||
export const Header: GlobalConfig = {
|
||||
slug: 'header',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'navItems',
|
||||
type: 'array',
|
||||
fields: [
|
||||
link({
|
||||
appearances: false,
|
||||
}),
|
||||
],
|
||||
maxRows: 6,
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
components: {
|
||||
RowLabel: '@/Header/RowLabel#RowLabel',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidateHeader],
|
||||
},
|
||||
}
|
||||
13
src/Header/hooks/revalidateHeader.ts
Normal file
13
src/Header/hooks/revalidateHeader.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { GlobalAfterChangeHook } from 'payload'
|
||||
|
||||
import { revalidateTag } from 'next/cache'
|
||||
|
||||
export const revalidateHeader: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
payload.logger.info(`Revalidating header`)
|
||||
|
||||
revalidateTag('global_header')
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
3
src/access/anyone.ts
Normal file
3
src/access/anyone.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const anyone: Access = () => true
|
||||
9
src/access/authenticated.ts
Normal file
9
src/access/authenticated.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { AccessArgs } from 'payload'
|
||||
|
||||
import type { User } from '@/payload-types'
|
||||
|
||||
type isAuthenticated = (args: AccessArgs<User>) => boolean
|
||||
|
||||
export const authenticated: isAuthenticated = ({ req: { user } }) => {
|
||||
return Boolean(user)
|
||||
}
|
||||
13
src/access/authenticatedOrPublished.ts
Normal file
13
src/access/authenticatedOrPublished.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
|
||||
if (user) {
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
_status: {
|
||||
equals: 'published',
|
||||
},
|
||||
}
|
||||
}
|
||||
68
src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts
Normal file
68
src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { getServerSideSitemap } from 'next-sitemap'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
|
||||
const getPagesSitemap = unstable_cache(
|
||||
async () => {
|
||||
const payload = await getPayload({ config })
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SERVER_URL ||
|
||||
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
|
||||
'https://example.com'
|
||||
|
||||
const results = await payload.find({
|
||||
collection: 'pages',
|
||||
overrideAccess: false,
|
||||
draft: false,
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
pagination: false,
|
||||
where: {
|
||||
_status: {
|
||||
equals: 'published',
|
||||
},
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
const dateFallback = new Date().toISOString()
|
||||
|
||||
const defaultSitemap = [
|
||||
{
|
||||
loc: `${SITE_URL}/search`,
|
||||
lastmod: dateFallback,
|
||||
},
|
||||
{
|
||||
loc: `${SITE_URL}/posts`,
|
||||
lastmod: dateFallback,
|
||||
},
|
||||
]
|
||||
|
||||
const sitemap = results.docs
|
||||
? results.docs
|
||||
.filter((page) => Boolean(page?.slug))
|
||||
.map((page) => {
|
||||
return {
|
||||
loc: page?.slug === 'home' ? `${SITE_URL}/` : `${SITE_URL}/${page?.slug}`,
|
||||
lastmod: page.updatedAt || dateFallback,
|
||||
}
|
||||
})
|
||||
: []
|
||||
|
||||
return [...defaultSitemap, ...sitemap]
|
||||
},
|
||||
['pages-sitemap'],
|
||||
{
|
||||
tags: ['pages-sitemap'],
|
||||
},
|
||||
)
|
||||
|
||||
export async function GET() {
|
||||
const sitemap = await getPagesSitemap()
|
||||
|
||||
return getServerSideSitemap(sitemap)
|
||||
}
|
||||
55
src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts
Normal file
55
src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { getServerSideSitemap } from 'next-sitemap'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
|
||||
const getPostsSitemap = unstable_cache(
|
||||
async () => {
|
||||
const payload = await getPayload({ config })
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SERVER_URL ||
|
||||
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
|
||||
'https://example.com'
|
||||
|
||||
const results = await payload.find({
|
||||
collection: 'posts',
|
||||
overrideAccess: false,
|
||||
draft: false,
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
pagination: false,
|
||||
where: {
|
||||
_status: {
|
||||
equals: 'published',
|
||||
},
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
const dateFallback = new Date().toISOString()
|
||||
|
||||
const sitemap = results.docs
|
||||
? results.docs
|
||||
.filter((post) => Boolean(post?.slug))
|
||||
.map((post) => ({
|
||||
loc: `${SITE_URL}/posts/${post?.slug}`,
|
||||
lastmod: post.updatedAt || dateFallback,
|
||||
}))
|
||||
: []
|
||||
|
||||
return sitemap
|
||||
},
|
||||
['posts-sitemap'],
|
||||
{
|
||||
tags: ['posts-sitemap'],
|
||||
},
|
||||
)
|
||||
|
||||
export async function GET() {
|
||||
const sitemap = await getPostsSitemap()
|
||||
|
||||
return getServerSideSitemap(sitemap)
|
||||
}
|
||||
15
src/app/(frontend)/[slug]/page.client.tsx
Normal file
15
src/app/(frontend)/[slug]/page.client.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
import { useHeaderTheme } from '@/providers/HeaderTheme'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
const PageClient: React.FC = () => {
|
||||
/* Force the header to be dark mode while we have an image behind it */
|
||||
const { setHeaderTheme } = useHeaderTheme()
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderTheme('light')
|
||||
}, [setHeaderTheme])
|
||||
return <React.Fragment />
|
||||
}
|
||||
|
||||
export default PageClient
|
||||
113
src/app/(frontend)/[slug]/page.tsx
Normal file
113
src/app/(frontend)/[slug]/page.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { PayloadRedirects } from '@/components/PayloadRedirects'
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload, type RequiredDataFromCollectionSlug } from 'payload'
|
||||
import { draftMode } from 'next/headers'
|
||||
import React, { cache } from 'react'
|
||||
import { homeStatic } from '@/endpoints/seed/home-static'
|
||||
|
||||
import { RenderBlocks } from '@/blocks/RenderBlocks'
|
||||
import { RenderHero } from '@/heros/RenderHero'
|
||||
import { generateMeta } from '@/utilities/generateMeta'
|
||||
import PageClient from './page.client'
|
||||
import { LivePreviewListener } from '@/components/LivePreviewListener'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const pages = await payload.find({
|
||||
collection: 'pages',
|
||||
draft: false,
|
||||
limit: 1000,
|
||||
overrideAccess: false,
|
||||
pagination: false,
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
})
|
||||
|
||||
const params = pages.docs
|
||||
?.filter((doc) => {
|
||||
return doc.slug !== 'home'
|
||||
})
|
||||
.map(({ slug }) => {
|
||||
return { slug }
|
||||
})
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
slug?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function Page({ params: paramsPromise }: Args) {
|
||||
const { isEnabled: draft } = await draftMode()
|
||||
const { slug = 'home' } = await paramsPromise
|
||||
// Decode to support slugs with special characters
|
||||
const decodedSlug = decodeURIComponent(slug)
|
||||
const url = '/' + decodedSlug
|
||||
let page: RequiredDataFromCollectionSlug<'pages'> | null
|
||||
|
||||
page = await queryPageBySlug({
|
||||
slug: decodedSlug,
|
||||
})
|
||||
|
||||
// Remove this code once your website is seeded
|
||||
if (!page && slug === 'home') {
|
||||
page = homeStatic
|
||||
}
|
||||
|
||||
if (!page) {
|
||||
return <PayloadRedirects url={url} />
|
||||
}
|
||||
|
||||
const { hero, layout } = page
|
||||
|
||||
return (
|
||||
<article className="pt-16 pb-24">
|
||||
<PageClient />
|
||||
{/* Allows redirects for valid pages too */}
|
||||
<PayloadRedirects disableNotFound url={url} />
|
||||
|
||||
{draft && <LivePreviewListener />}
|
||||
|
||||
<RenderHero {...hero} />
|
||||
<RenderBlocks blocks={layout} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: paramsPromise }: Args): Promise<Metadata> {
|
||||
const { slug = 'home' } = await paramsPromise
|
||||
// Decode to support slugs with special characters
|
||||
const decodedSlug = decodeURIComponent(slug)
|
||||
const page = await queryPageBySlug({
|
||||
slug: decodedSlug,
|
||||
})
|
||||
|
||||
return generateMeta({ doc: page })
|
||||
}
|
||||
|
||||
const queryPageBySlug = cache(async ({ slug }: { slug: string }) => {
|
||||
const { isEnabled: draft } = await draftMode()
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'pages',
|
||||
draft,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: draft,
|
||||
where: {
|
||||
slug: {
|
||||
equals: slug,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return result.docs?.[0] || null
|
||||
})
|
||||
224
src/app/(frontend)/globals.css
Normal file
224
src/app/(frontend)/globals.css
Normal file
@ -0,0 +1,224 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@config '../../../tailwind.config.mjs';
|
||||
|
||||
@custom-variant dark (&:is([data-theme='dark'] *));
|
||||
@custom-variant sm (@media (width >= theme(--breakpoint-sm)));
|
||||
@custom-variant md (@media (width >= theme(--breakpoint-md)));
|
||||
@custom-variant lg (@media (width >= theme(--breakpoint-lg)));
|
||||
@custom-variant xl (@media (width >= theme(--breakpoint-xl)));
|
||||
@custom-variant 2xl (@media (width >= theme(--breakpoint-2xl)));
|
||||
|
||||
@layer base {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: unset;
|
||||
font-size: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@source inline("lg:col-span-4");
|
||||
@source inline("lg:col-span-6");
|
||||
@source inline("lg:col-span-8");
|
||||
@source inline("lg:col-span-12");
|
||||
@source inline("border-border");
|
||||
@source inline("bg-card");
|
||||
@source inline("border-error");
|
||||
@source inline("bg-error/30");
|
||||
@source inline("border-success");
|
||||
@source inline("bg-success/30");
|
||||
@source inline("border-warning");
|
||||
@source inline("bg-warning/30");
|
||||
|
||||
@theme {
|
||||
--breakpoint-sm: 40rem;
|
||||
--breakpoint-md: 48rem;
|
||||
--breakpoint-lg: 64rem;
|
||||
--breakpoint-xl: 80rem;
|
||||
--breakpoint-2xl: 86rem;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.container {
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
@variant sm {
|
||||
.container {
|
||||
max-width: var(--breakpoint-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@variant md {
|
||||
.container {
|
||||
max-width: var(--breakpoint-md);
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@variant lg {
|
||||
.container {
|
||||
max-width: var(--breakpoint-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@variant xl {
|
||||
.container {
|
||||
max-width: var(--breakpoint-xl);
|
||||
}
|
||||
}
|
||||
|
||||
@variant 2xl {
|
||||
.container {
|
||||
max-width: var(--breakpoint-2xl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(100% 0 0deg);
|
||||
--foreground: oklch(14.5% 0 0deg);
|
||||
--card: oklch(96.5% 0.005 265deg);
|
||||
--card-foreground: oklch(14.5% 0 0deg);
|
||||
--popover: oklch(100% 0 0deg);
|
||||
--popover-foreground: oklch(14.5% 0 0deg);
|
||||
--primary: oklch(20.5% 0 0deg);
|
||||
--primary-foreground: oklch(98.5% 0 0deg);
|
||||
--secondary: oklch(97% 0 0deg);
|
||||
--secondary-foreground: oklch(20.5% 0 0deg);
|
||||
--muted: oklch(97% 0 0deg);
|
||||
--muted-foreground: oklch(55.6% 0 0deg);
|
||||
--accent: oklch(97% 0 0deg);
|
||||
--accent-foreground: oklch(20.5% 0 0deg);
|
||||
--destructive: oklch(57.7% 0.245 27.325deg);
|
||||
--destructive-foreground: oklch(57.7% 0.245 27.325deg);
|
||||
--border: oklch(92.2% 0 0deg);
|
||||
--input: oklch(92.2% 0 0deg);
|
||||
--ring: oklch(70.8% 0 0deg);
|
||||
--chart-1: oklch(64.6% 0.222 41.116deg);
|
||||
--chart-2: oklch(60% 0.118 184.704deg);
|
||||
--chart-3: oklch(39.8% 0.07 227.392deg);
|
||||
--chart-4: oklch(82.8% 0.189 84.429deg);
|
||||
--chart-5: oklch(76.9% 0.188 70.08deg);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(98.5% 0 0deg);
|
||||
--sidebar-foreground: oklch(14.5% 0 0deg);
|
||||
--sidebar-primary: oklch(20.5% 0 0deg);
|
||||
--sidebar-primary-foreground: oklch(98.5% 0 0deg);
|
||||
--sidebar-accent: oklch(97% 0 0deg);
|
||||
--sidebar-accent-foreground: oklch(20.5% 0 0deg);
|
||||
--sidebar-border: oklch(92.2% 0 0deg);
|
||||
--sidebar-ring: oklch(70.8% 0 0deg);
|
||||
--success: oklch(78% 0.08 200deg);
|
||||
--warning: oklch(89% 0.1 75deg);
|
||||
--error: oklch(75% 0.15 25deg);
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--background: oklch(14.5% 0 0deg);
|
||||
--foreground: oklch(98.5% 0 0deg);
|
||||
--card: oklch(17% 0 0deg);
|
||||
--card-foreground: oklch(98.5% 0 0deg);
|
||||
--popover: oklch(14.5% 0 0deg);
|
||||
--popover-foreground: oklch(98.5% 0 0deg);
|
||||
--primary: oklch(98.5% 0 0deg);
|
||||
--primary-foreground: oklch(20.5% 0 0deg);
|
||||
--secondary: oklch(26.9% 0 0deg);
|
||||
--secondary-foreground: oklch(98.5% 0 0deg);
|
||||
--muted: oklch(26.9% 0 0deg);
|
||||
--muted-foreground: oklch(70.8% 0 0deg);
|
||||
--accent: oklch(26.9% 0 0deg);
|
||||
--accent-foreground: oklch(98.5% 0 0deg);
|
||||
--destructive: oklch(39.6% 0.141 25.723deg);
|
||||
--destructive-foreground: oklch(63.7% 0.237 25.331deg);
|
||||
--border: oklch(26.9% 0 0deg);
|
||||
--input: oklch(26.9% 0 0deg);
|
||||
--ring: oklch(43.9% 0 0deg);
|
||||
--chart-1: oklch(48.8% 0.243 264.376deg);
|
||||
--chart-2: oklch(69.6% 0.17 162.48deg);
|
||||
--chart-3: oklch(76.9% 0.188 70.08deg);
|
||||
--chart-4: oklch(62.7% 0.265 303.9deg);
|
||||
--chart-5: oklch(64.5% 0.246 16.439deg);
|
||||
--sidebar: oklch(20.5% 0 0deg);
|
||||
--sidebar-foreground: oklch(98.5% 0 0deg);
|
||||
--sidebar-primary: oklch(48.8% 0.243 264.376deg);
|
||||
--sidebar-primary-foreground: oklch(98.5% 0 0deg);
|
||||
--sidebar-accent: oklch(26.9% 0 0deg);
|
||||
--sidebar-accent-foreground: oklch(98.5% 0 0deg);
|
||||
--sidebar-border: oklch(26.9% 0 0deg);
|
||||
--sidebar-ring: oklch(43.9% 0 0deg);
|
||||
--success: oklch(28% 0.1 200deg);
|
||||
--warning: oklch(35% 0.08 70deg);
|
||||
--error: oklch(45% 0.1 25deg);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-error: var(--error);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground min-h-[100vh] flex flex-col;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
html[data-theme='dark'],
|
||||
html[data-theme='light'] {
|
||||
opacity: initial;
|
||||
}
|
||||
53
src/app/(frontend)/layout.tsx
Normal file
53
src/app/(frontend)/layout.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import { GeistMono } from 'geist/font/mono'
|
||||
import { GeistSans } from 'geist/font/sans'
|
||||
import React from 'react'
|
||||
|
||||
import { AdminBar } from '@/components/AdminBar'
|
||||
import { Footer } from '@/Footer/Component'
|
||||
import { Header } from '@/Header/Component'
|
||||
import { Providers } from '@/providers'
|
||||
import { InitTheme } from '@/providers/Theme/InitTheme'
|
||||
import { mergeOpenGraph } from '@/utilities/mergeOpenGraph'
|
||||
import { draftMode } from 'next/headers'
|
||||
|
||||
import './globals.css'
|
||||
import { getServerSideURL } from '@/utilities/getURL'
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const { isEnabled } = await draftMode()
|
||||
|
||||
return (
|
||||
<html className={cn(GeistSans.variable, GeistMono.variable)} lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<InitTheme />
|
||||
<link href="/favicon.ico" rel="icon" sizes="32x32" />
|
||||
<link href="/favicon.svg" rel="icon" type="image/svg+xml" />
|
||||
</head>
|
||||
<body>
|
||||
<Providers>
|
||||
<AdminBar
|
||||
adminBarProps={{
|
||||
preview: isEnabled,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(getServerSideURL()),
|
||||
openGraph: mergeOpenGraph(),
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
creator: '@payloadcms',
|
||||
},
|
||||
}
|
||||
7
src/app/(frontend)/next/exit-preview/route.ts
Normal file
7
src/app/(frontend)/next/exit-preview/route.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { draftMode } from 'next/headers'
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const draft = await draftMode()
|
||||
draft.disable()
|
||||
return new Response('Draft mode is disabled')
|
||||
}
|
||||
56
src/app/(frontend)/next/preview/route.ts
Normal file
56
src/app/(frontend)/next/preview/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import type { CollectionSlug, PayloadRequest } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { draftMode } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export async function GET(req: NextRequest): Promise<Response> {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
|
||||
const path = searchParams.get('path')
|
||||
const collection = searchParams.get('collection') as CollectionSlug
|
||||
const slug = searchParams.get('slug')
|
||||
const previewSecret = searchParams.get('previewSecret')
|
||||
|
||||
if (previewSecret !== process.env.PREVIEW_SECRET) {
|
||||
return new Response('You are not allowed to preview this page', { status: 403 })
|
||||
}
|
||||
|
||||
if (!path || !collection || !slug) {
|
||||
return new Response('Insufficient search params', { status: 404 })
|
||||
}
|
||||
|
||||
if (!path.startsWith('/')) {
|
||||
return new Response('This endpoint can only be used for relative previews', { status: 500 })
|
||||
}
|
||||
|
||||
let user
|
||||
|
||||
try {
|
||||
user = await payload.auth({
|
||||
req: req as unknown as PayloadRequest,
|
||||
headers: req.headers,
|
||||
})
|
||||
} catch (error) {
|
||||
payload.logger.error({ err: error }, 'Error verifying token for live preview')
|
||||
return new Response('You are not allowed to preview this page', { status: 403 })
|
||||
}
|
||||
|
||||
const draft = await draftMode()
|
||||
|
||||
if (!user) {
|
||||
draft.disable()
|
||||
return new Response('You are not allowed to preview this page', { status: 403 })
|
||||
}
|
||||
|
||||
// You can add additional checks here to see if the user is allowed to preview this page
|
||||
|
||||
draft.enable()
|
||||
|
||||
redirect(path)
|
||||
}
|
||||
31
src/app/(frontend)/next/seed/route.ts
Normal file
31
src/app/(frontend)/next/seed/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { createLocalReq, getPayload } from 'payload'
|
||||
import { seed } from '@/endpoints/seed'
|
||||
import config from '@payload-config'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export const maxDuration = 60 // This function can run for a maximum of 60 seconds
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
const payload = await getPayload({ config })
|
||||
const requestHeaders = await headers()
|
||||
|
||||
// Authenticate by passing request headers
|
||||
const { user } = await payload.auth({ headers: requestHeaders })
|
||||
|
||||
if (!user) {
|
||||
return new Response('Action forbidden.', { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a Payload request object to pass to the Local API for transactions
|
||||
// At this point you should pass in a user, locale, and any other context you need for the Local API
|
||||
const payloadReq = await createLocalReq({ user }, payload)
|
||||
|
||||
await seed({ payload, req: payloadReq })
|
||||
|
||||
return Response.json({ success: true })
|
||||
} catch (e) {
|
||||
payload.logger.error({ err: e, message: 'Error seeding data' })
|
||||
return new Response('Error seeding data.', { status: 500 })
|
||||
}
|
||||
}
|
||||
18
src/app/(frontend)/not-found.tsx
Normal file
18
src/app/(frontend)/not-found.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="container py-28">
|
||||
<div className="prose max-w-none">
|
||||
<h1 style={{ marginBottom: 0 }}>404</h1>
|
||||
<p className="mb-4">This page could not be found.</p>
|
||||
</div>
|
||||
<Button asChild variant="default">
|
||||
<Link href="/">Go home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
src/app/(frontend)/page.tsx
Normal file
5
src/app/(frontend)/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import PageTemplate, { generateMetadata } from './[slug]/page'
|
||||
|
||||
export default PageTemplate
|
||||
|
||||
export { generateMetadata }
|
||||
15
src/app/(frontend)/posts/[slug]/page.client.tsx
Normal file
15
src/app/(frontend)/posts/[slug]/page.client.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
import { useHeaderTheme } from '@/providers/HeaderTheme'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
const PageClient: React.FC = () => {
|
||||
/* Force the header to be dark mode while we have an image behind it */
|
||||
const { setHeaderTheme } = useHeaderTheme()
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderTheme('dark')
|
||||
}, [setHeaderTheme])
|
||||
return <React.Fragment />
|
||||
}
|
||||
|
||||
export default PageClient
|
||||
108
src/app/(frontend)/posts/[slug]/page.tsx
Normal file
108
src/app/(frontend)/posts/[slug]/page.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { RelatedPosts } from '@/blocks/RelatedPosts/Component'
|
||||
import { PayloadRedirects } from '@/components/PayloadRedirects'
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import { draftMode } from 'next/headers'
|
||||
import React, { cache } from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
import type { Post } from '@/payload-types'
|
||||
|
||||
import { PostHero } from '@/heros/PostHero'
|
||||
import { generateMeta } from '@/utilities/generateMeta'
|
||||
import PageClient from './page.client'
|
||||
import { LivePreviewListener } from '@/components/LivePreviewListener'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
draft: false,
|
||||
limit: 1000,
|
||||
overrideAccess: false,
|
||||
pagination: false,
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
})
|
||||
|
||||
const params = posts.docs.map(({ slug }) => {
|
||||
return { slug }
|
||||
})
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
slug?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function Post({ params: paramsPromise }: Args) {
|
||||
const { isEnabled: draft } = await draftMode()
|
||||
const { slug = '' } = await paramsPromise
|
||||
// Decode to support slugs with special characters
|
||||
const decodedSlug = decodeURIComponent(slug)
|
||||
const url = '/posts/' + decodedSlug
|
||||
const post = await queryPostBySlug({ slug: decodedSlug })
|
||||
|
||||
if (!post) return <PayloadRedirects url={url} />
|
||||
|
||||
return (
|
||||
<article className="pt-16 pb-16">
|
||||
<PageClient />
|
||||
|
||||
{/* Allows redirects for valid pages too */}
|
||||
<PayloadRedirects disableNotFound url={url} />
|
||||
|
||||
{draft && <LivePreviewListener />}
|
||||
|
||||
<PostHero post={post} />
|
||||
|
||||
<div className="flex flex-col items-center gap-4 pt-8">
|
||||
<div className="container">
|
||||
<RichText className="max-w-[48rem] mx-auto" data={post.content} enableGutter={false} />
|
||||
{post.relatedPosts && post.relatedPosts.length > 0 && (
|
||||
<RelatedPosts
|
||||
className="mt-12 max-w-[52rem] lg:grid lg:grid-cols-subgrid col-start-1 col-span-3 grid-rows-[2fr]"
|
||||
docs={post.relatedPosts.filter((post) => typeof post === 'object')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: paramsPromise }: Args): Promise<Metadata> {
|
||||
const { slug = '' } = await paramsPromise
|
||||
// Decode to support slugs with special characters
|
||||
const decodedSlug = decodeURIComponent(slug)
|
||||
const post = await queryPostBySlug({ slug: decodedSlug })
|
||||
|
||||
return generateMeta({ doc: post })
|
||||
}
|
||||
|
||||
const queryPostBySlug = cache(async ({ slug }: { slug: string }) => {
|
||||
const { isEnabled: draft } = await draftMode()
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'posts',
|
||||
draft,
|
||||
limit: 1,
|
||||
overrideAccess: draft,
|
||||
pagination: false,
|
||||
where: {
|
||||
slug: {
|
||||
equals: slug,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return result.docs?.[0] || null
|
||||
})
|
||||
15
src/app/(frontend)/posts/page.client.tsx
Normal file
15
src/app/(frontend)/posts/page.client.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
import { useHeaderTheme } from '@/providers/HeaderTheme'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
const PageClient: React.FC = () => {
|
||||
/* Force the header to be dark mode while we have an image behind it */
|
||||
const { setHeaderTheme } = useHeaderTheme()
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderTheme('light')
|
||||
}, [setHeaderTheme])
|
||||
return <React.Fragment />
|
||||
}
|
||||
|
||||
export default PageClient
|
||||
63
src/app/(frontend)/posts/page.tsx
Normal file
63
src/app/(frontend)/posts/page.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import type { Metadata } from 'next/types'
|
||||
|
||||
import { CollectionArchive } from '@/components/CollectionArchive'
|
||||
import { PageRange } from '@/components/PageRange'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import React from 'react'
|
||||
import PageClient from './page.client'
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
export const revalidate = 600
|
||||
|
||||
export default async function Page() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
depth: 1,
|
||||
limit: 12,
|
||||
overrideAccess: false,
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
categories: true,
|
||||
meta: true,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-24">
|
||||
<PageClient />
|
||||
<div className="container mb-16">
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<h1>Posts</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mb-8">
|
||||
<PageRange
|
||||
collection="posts"
|
||||
currentPage={posts.page}
|
||||
limit={12}
|
||||
totalDocs={posts.totalDocs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CollectionArchive posts={posts.docs} />
|
||||
|
||||
<div className="container">
|
||||
{posts.totalPages > 1 && posts.page && (
|
||||
<Pagination page={posts.page} totalPages={posts.totalPages} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return {
|
||||
title: `Payload Website Template Posts`,
|
||||
}
|
||||
}
|
||||
15
src/app/(frontend)/posts/page/[pageNumber]/page.client.tsx
Normal file
15
src/app/(frontend)/posts/page/[pageNumber]/page.client.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
import { useHeaderTheme } from '@/providers/HeaderTheme'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
const PageClient: React.FC = () => {
|
||||
/* Force the header to be dark mode while we have an image behind it */
|
||||
const { setHeaderTheme } = useHeaderTheme()
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderTheme('light')
|
||||
}, [setHeaderTheme])
|
||||
return <React.Fragment />
|
||||
}
|
||||
|
||||
export default PageClient
|
||||
88
src/app/(frontend)/posts/page/[pageNumber]/page.tsx
Normal file
88
src/app/(frontend)/posts/page/[pageNumber]/page.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import type { Metadata } from 'next/types'
|
||||
|
||||
import { CollectionArchive } from '@/components/CollectionArchive'
|
||||
import { PageRange } from '@/components/PageRange'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import React from 'react'
|
||||
import PageClient from './page.client'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const revalidate = 600
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
pageNumber: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function Page({ params: paramsPromise }: Args) {
|
||||
const { pageNumber } = await paramsPromise
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const sanitizedPageNumber = Number(pageNumber)
|
||||
|
||||
if (!Number.isInteger(sanitizedPageNumber)) notFound()
|
||||
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
depth: 1,
|
||||
limit: 12,
|
||||
page: sanitizedPageNumber,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-24">
|
||||
<PageClient />
|
||||
<div className="container mb-16">
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<h1>Posts</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mb-8">
|
||||
<PageRange
|
||||
collection="posts"
|
||||
currentPage={posts.page}
|
||||
limit={12}
|
||||
totalDocs={posts.totalDocs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CollectionArchive posts={posts.docs} />
|
||||
|
||||
<div className="container">
|
||||
{posts?.page && posts?.totalPages > 1 && (
|
||||
<Pagination page={posts.page} totalPages={posts.totalPages} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: paramsPromise }: Args): Promise<Metadata> {
|
||||
const { pageNumber } = await paramsPromise
|
||||
return {
|
||||
title: `Payload Website Template Posts Page ${pageNumber || ''}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { totalDocs } = await payload.count({
|
||||
collection: 'posts',
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const totalPages = Math.ceil(totalDocs / 10)
|
||||
|
||||
const pages: { pageNumber: string }[] = []
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push({ pageNumber: String(i) })
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
15
src/app/(frontend)/search/page.client.tsx
Normal file
15
src/app/(frontend)/search/page.client.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
import { useHeaderTheme } from '@/providers/HeaderTheme'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
const PageClient: React.FC = () => {
|
||||
/* Force the header to be dark mode while we have an image behind it */
|
||||
const { setHeaderTheme } = useHeaderTheme()
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderTheme('light')
|
||||
}, [setHeaderTheme])
|
||||
return <React.Fragment />
|
||||
}
|
||||
|
||||
export default PageClient
|
||||
88
src/app/(frontend)/search/page.tsx
Normal file
88
src/app/(frontend)/search/page.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import type { Metadata } from 'next/types'
|
||||
|
||||
import { CollectionArchive } from '@/components/CollectionArchive'
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import React from 'react'
|
||||
import { Search } from '@/search/Component'
|
||||
import PageClient from './page.client'
|
||||
import { CardPostData } from '@/components/Card'
|
||||
|
||||
type Args = {
|
||||
searchParams: Promise<{
|
||||
q: string
|
||||
}>
|
||||
}
|
||||
export default async function Page({ searchParams: searchParamsPromise }: Args) {
|
||||
const { q: query } = await searchParamsPromise
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const posts = await payload.find({
|
||||
collection: 'search',
|
||||
depth: 1,
|
||||
limit: 12,
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
categories: true,
|
||||
meta: true,
|
||||
},
|
||||
// pagination: false reduces overhead if you don't need totalDocs
|
||||
pagination: false,
|
||||
...(query
|
||||
? {
|
||||
where: {
|
||||
or: [
|
||||
{
|
||||
title: {
|
||||
like: query,
|
||||
},
|
||||
},
|
||||
{
|
||||
'meta.description': {
|
||||
like: query,
|
||||
},
|
||||
},
|
||||
{
|
||||
'meta.title': {
|
||||
like: query,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: {
|
||||
like: query,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-24">
|
||||
<PageClient />
|
||||
<div className="container mb-16">
|
||||
<div className="prose dark:prose-invert max-w-none text-center">
|
||||
<h1 className="mb-8 lg:mb-16">Search</h1>
|
||||
|
||||
<div className="max-w-[50rem] mx-auto">
|
||||
<Search />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{posts.totalDocs > 0 ? (
|
||||
<CollectionArchive posts={posts.docs as CardPostData[]} />
|
||||
) : (
|
||||
<div className="container">No results found.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return {
|
||||
title: `Payload Website Template Search`,
|
||||
}
|
||||
}
|
||||
24
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
24
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
/* 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 { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import { importMap } from '../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
|
||||
24
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
24
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
/* 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 { RootPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import { importMap } from '../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
|
||||
79
src/app/(payload)/admin/importMap.js
Normal file
79
src/app/(payload)/admin/importMap.js
Normal file
@ -0,0 +1,79 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OverviewComponent as OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { SlugField as SlugField_2b8867833a34864a02ddf429b0728a40 } from '@payloadcms/next/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { FolderTableCell as FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
import { FolderField as FolderField_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||
import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||
import { FolderTypeField as FolderTypeField_2b8867833a34864a02ddf429b0728a40 } from '@payloadcms/next/client'
|
||||
import { RowLabel as RowLabel_ec255a65fa6fa8d1faeb09cf35284224 } from '@/Header/RowLabel'
|
||||
import { RowLabel as RowLabel_1f6ff6ff633e3695d348f4f3c58f1466 } from '@/Footer/RowLabel'
|
||||
import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/components/BeforeDashboard'
|
||||
import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
export const importMap = {
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient':
|
||||
FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/plugin-seo/client#OverviewComponent':
|
||||
OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaTitleComponent':
|
||||
MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaImageComponent':
|
||||
MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaDescriptionComponent':
|
||||
MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#PreviewComponent':
|
||||
PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/next/client#SlugField': SlugField_2b8867833a34864a02ddf429b0728a40,
|
||||
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
|
||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/next/rsc#FolderTableCell': FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
'@payloadcms/next/rsc#FolderField': FolderField_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
'@payloadcms/plugin-search/client#LinkToDoc': LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
'@payloadcms/plugin-search/client#ReindexButton': ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
'@payloadcms/next/client#FolderTypeField': FolderTypeField_2b8867833a34864a02ddf429b0728a40,
|
||||
'@/Header/RowLabel#RowLabel': RowLabel_ec255a65fa6fa8d1faeb09cf35284224,
|
||||
'@/Footer/RowLabel#RowLabel': RowLabel_1f6ff6ff633e3695d348f4f3c58f1466,
|
||||
'@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6,
|
||||
'@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e,
|
||||
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
}
|
||||
20
src/app/(payload)/api/[...slug]/route.ts
Normal file
20
src/app/(payload)/api/[...slug]/route.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/* 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 {
|
||||
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)
|
||||
7
src/app/(payload)/api/graphql-playground/route.ts
Normal file
7
src/app/(payload)/api/graphql-playground/route.ts
Normal 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 '@payloadcms/next/css'
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||
8
src/app/(payload)/api/graphql/route.ts
Normal file
8
src/app/(payload)/api/graphql/route.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/* 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
src/app/(payload)/custom.scss
Normal file
0
src/app/(payload)/custom.scss
Normal file
31
src/app/(payload)/layout.tsx
Normal file
31
src/app/(payload)/layout.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
/* 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 React from 'react'
|
||||
|
||||
import { importMap } from './admin/importMap.js'
|
||||
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
|
||||
65
src/blocks/ArchiveBlock/Component.tsx
Normal file
65
src/blocks/ArchiveBlock/Component.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import type { Post, ArchiveBlock as ArchiveBlockProps } from '@/payload-types'
|
||||
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
import { CollectionArchive } from '@/components/CollectionArchive'
|
||||
|
||||
export const ArchiveBlock: React.FC<
|
||||
ArchiveBlockProps & {
|
||||
id?: string
|
||||
}
|
||||
> = async (props) => {
|
||||
const { id, categories, introContent, limit: limitFromProps, populateBy, selectedDocs } = props
|
||||
|
||||
const limit = limitFromProps || 3
|
||||
|
||||
let posts: Post[] = []
|
||||
|
||||
if (populateBy === 'collection') {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const flattenedCategories = categories?.map((category) => {
|
||||
if (typeof category === 'object') return category.id
|
||||
else return category
|
||||
})
|
||||
|
||||
const fetchedPosts = await payload.find({
|
||||
collection: 'posts',
|
||||
depth: 1,
|
||||
limit,
|
||||
...(flattenedCategories && flattenedCategories.length > 0
|
||||
? {
|
||||
where: {
|
||||
categories: {
|
||||
in: flattenedCategories,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
posts = fetchedPosts.docs
|
||||
} else {
|
||||
if (selectedDocs?.length) {
|
||||
const filteredSelectedPosts = selectedDocs.map((post) => {
|
||||
if (typeof post.value === 'object') return post.value
|
||||
}) as Post[]
|
||||
|
||||
posts = filteredSelectedPosts
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-16" id={`block-${id}`}>
|
||||
{introContent && (
|
||||
<div className="container mb-16">
|
||||
<RichText className="ms-0 max-w-[48rem]" data={introContent} enableGutter={false} />
|
||||
</div>
|
||||
)}
|
||||
<CollectionArchive posts={posts} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
src/blocks/ArchiveBlock/config.ts
Normal file
94
src/blocks/ArchiveBlock/config.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const Archive: Block = {
|
||||
slug: 'archive',
|
||||
interfaceName: 'ArchiveBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'introContent',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: 'Intro Content',
|
||||
},
|
||||
{
|
||||
name: 'populateBy',
|
||||
type: 'select',
|
||||
defaultValue: 'collection',
|
||||
options: [
|
||||
{
|
||||
label: 'Collection',
|
||||
value: 'collection',
|
||||
},
|
||||
{
|
||||
label: 'Individual Selection',
|
||||
value: 'selection',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'relationTo',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'collection',
|
||||
},
|
||||
defaultValue: 'posts',
|
||||
label: 'Collections To Show',
|
||||
options: [
|
||||
{
|
||||
label: 'Posts',
|
||||
value: 'posts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'collection',
|
||||
},
|
||||
hasMany: true,
|
||||
label: 'Categories To Show',
|
||||
relationTo: 'categories',
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'collection',
|
||||
step: 1,
|
||||
},
|
||||
defaultValue: 10,
|
||||
label: 'Limit',
|
||||
},
|
||||
{
|
||||
name: 'selectedDocs',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'selection',
|
||||
},
|
||||
hasMany: true,
|
||||
label: 'Selection',
|
||||
relationTo: ['posts'],
|
||||
},
|
||||
],
|
||||
labels: {
|
||||
plural: 'Archives',
|
||||
singular: 'Archive',
|
||||
},
|
||||
}
|
||||
26
src/blocks/Banner/Component.tsx
Normal file
26
src/blocks/Banner/Component.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { BannerBlock as BannerBlockProps } from 'src/payload-types'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
} & BannerBlockProps
|
||||
|
||||
export const BannerBlock: React.FC<Props> = ({ className, content, style }) => {
|
||||
return (
|
||||
<div className={cn('mx-auto my-8 w-full', className)}>
|
||||
<div
|
||||
className={cn('border py-3 px-6 flex items-center rounded', {
|
||||
'border-border bg-card': style === 'info',
|
||||
'border-error bg-error/30': style === 'error',
|
||||
'border-success bg-success/30': style === 'success',
|
||||
'border-warning bg-warning/30': style === 'warning',
|
||||
})}
|
||||
>
|
||||
<RichText data={content} enableGutter={false} enableProse={false} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/blocks/Banner/config.ts
Normal file
37
src/blocks/Banner/config.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const Banner: Block = {
|
||||
slug: 'banner',
|
||||
fields: [
|
||||
{
|
||||
name: 'style',
|
||||
type: 'select',
|
||||
defaultValue: 'info',
|
||||
options: [
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Warning', value: 'warning' },
|
||||
{ label: 'Error', value: 'error' },
|
||||
{ label: 'Success', value: 'success' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
interfaceName: 'BannerBlock',
|
||||
}
|
||||
23
src/blocks/CallToAction/Component.tsx
Normal file
23
src/blocks/CallToAction/Component.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { CallToActionBlock as CTABlockProps } from '@/payload-types'
|
||||
|
||||
import RichText from '@/components/RichText'
|
||||
import { CMSLink } from '@/components/Link'
|
||||
|
||||
export const CallToActionBlock: React.FC<CTABlockProps> = ({ links, richText }) => {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="bg-card rounded border-border border p-4 flex flex-col gap-8 md:flex-row md:justify-between md:items-center">
|
||||
<div className="max-w-[48rem] flex items-center">
|
||||
{richText && <RichText className="mb-0" data={richText} enableGutter={false} />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-8">
|
||||
{(links || []).map(({ link }, i) => {
|
||||
return <CMSLink key={i} size="lg" {...link} />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
src/blocks/CallToAction/config.ts
Normal file
42
src/blocks/CallToAction/config.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { linkGroup } from '../../fields/linkGroup'
|
||||
|
||||
export const CallToAction: Block = {
|
||||
slug: 'cta',
|
||||
interfaceName: 'CallToActionBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
},
|
||||
linkGroup({
|
||||
appearances: ['default', 'outline'],
|
||||
overrides: {
|
||||
maxRows: 2,
|
||||
},
|
||||
}),
|
||||
],
|
||||
labels: {
|
||||
plural: 'Calls to Action',
|
||||
singular: 'Call to Action',
|
||||
},
|
||||
}
|
||||
33
src/blocks/Code/Component.client.tsx
Normal file
33
src/blocks/Code/Component.client.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
import { Highlight, themes } from 'prism-react-renderer'
|
||||
import React from 'react'
|
||||
import { CopyButton } from './CopyButton'
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export const Code: React.FC<Props> = ({ code, language = '' }) => {
|
||||
if (!code) return null
|
||||
|
||||
return (
|
||||
<Highlight code={code} language={language} theme={themes.vsDark}>
|
||||
{({ getLineProps, getTokenProps, tokens }) => (
|
||||
<pre className="bg-black p-4 border text-xs border-border rounded overflow-x-auto">
|
||||
{tokens.map((line, i) => (
|
||||
<div key={i} {...getLineProps({ className: 'table-row', line })}>
|
||||
<span className="table-cell select-none text-right text-white/25">{i + 1}</span>
|
||||
<span className="table-cell pl-4">
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<CopyButton code={code} />
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
)
|
||||
}
|
||||
21
src/blocks/Code/Component.tsx
Normal file
21
src/blocks/Code/Component.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Code } from './Component.client'
|
||||
|
||||
export type CodeBlockProps = {
|
||||
code: string
|
||||
language?: string
|
||||
blockType: 'code'
|
||||
}
|
||||
|
||||
type Props = CodeBlockProps & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const CodeBlock: React.FC<Props> = ({ className, code, language }) => {
|
||||
return (
|
||||
<div className={[className, 'not-prose'].filter(Boolean).join(' ')}>
|
||||
<Code code={code} language={language} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/blocks/Code/CopyButton.tsx
Normal file
33
src/blocks/Code/CopyButton.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyIcon } from '@payloadcms/ui/icons/Copy'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function CopyButton({ code }: { code: string }) {
|
||||
const [text, setText] = useState('Copy')
|
||||
|
||||
function updateCopyStatus() {
|
||||
if (text === 'Copy') {
|
||||
setText(() => 'Copied!')
|
||||
setTimeout(() => {
|
||||
setText(() => 'Copy')
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end align-middle">
|
||||
<Button
|
||||
className="flex gap-1"
|
||||
variant={'secondary'}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(code)
|
||||
updateCopyStatus()
|
||||
}}
|
||||
>
|
||||
<p>{text}</p>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/blocks/Code/config.ts
Normal file
33
src/blocks/Code/config.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const Code: Block = {
|
||||
slug: 'code',
|
||||
interfaceName: 'CodeBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'language',
|
||||
type: 'select',
|
||||
defaultValue: 'typescript',
|
||||
options: [
|
||||
{
|
||||
label: 'Typescript',
|
||||
value: 'typescript',
|
||||
},
|
||||
{
|
||||
label: 'Javascript',
|
||||
value: 'javascript',
|
||||
},
|
||||
{
|
||||
label: 'CSS',
|
||||
value: 'css',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
type: 'code',
|
||||
label: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
43
src/blocks/Content/Component.tsx
Normal file
43
src/blocks/Content/Component.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
import type { ContentBlock as ContentBlockProps } from '@/payload-types'
|
||||
|
||||
import { CMSLink } from '../../components/Link'
|
||||
|
||||
export const ContentBlock: React.FC<ContentBlockProps> = (props) => {
|
||||
const { columns } = props
|
||||
|
||||
const colsSpanClasses = {
|
||||
full: '12',
|
||||
half: '6',
|
||||
oneThird: '4',
|
||||
twoThirds: '8',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container my-16">
|
||||
<div className="grid grid-cols-4 lg:grid-cols-12 gap-y-8 gap-x-16">
|
||||
{columns &&
|
||||
columns.length > 0 &&
|
||||
columns.map((col, index) => {
|
||||
const { enableLink, link, richText, size } = col
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(`col-span-4 lg:col-span-${colsSpanClasses[size!]}`, {
|
||||
'md:col-span-2': size !== 'full',
|
||||
})}
|
||||
key={index}
|
||||
>
|
||||
{richText && <RichText data={richText} enableGutter={false} />}
|
||||
|
||||
{enableLink && <CMSLink {...link} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
src/blocks/Content/config.ts
Normal file
79
src/blocks/Content/config.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import type { Block, Field } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { link } from '@/fields/link'
|
||||
|
||||
const columnFields: Field[] = [
|
||||
{
|
||||
name: 'size',
|
||||
type: 'select',
|
||||
defaultValue: 'oneThird',
|
||||
options: [
|
||||
{
|
||||
label: 'One Third',
|
||||
value: 'oneThird',
|
||||
},
|
||||
{
|
||||
label: 'Half',
|
||||
value: 'half',
|
||||
},
|
||||
{
|
||||
label: 'Two Thirds',
|
||||
value: 'twoThirds',
|
||||
},
|
||||
{
|
||||
label: 'Full',
|
||||
value: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
},
|
||||
{
|
||||
name: 'enableLink',
|
||||
type: 'checkbox',
|
||||
},
|
||||
link({
|
||||
overrides: {
|
||||
admin: {
|
||||
condition: (_data, siblingData) => {
|
||||
return Boolean(siblingData?.enableLink)
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
export const Content: Block = {
|
||||
slug: 'content',
|
||||
interfaceName: 'ContentBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'columns',
|
||||
type: 'array',
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
},
|
||||
fields: columnFields,
|
||||
},
|
||||
],
|
||||
}
|
||||
45
src/blocks/Form/Checkbox/index.tsx
Normal file
45
src/blocks/Form/Checkbox/index.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import type { CheckboxField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
|
||||
import { Checkbox as CheckboxUi } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Checkbox: React.FC<
|
||||
CheckboxField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
const props = register(name, { required: required })
|
||||
const { setValue } = useFormContext()
|
||||
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckboxUi
|
||||
defaultChecked={defaultValue}
|
||||
id={name}
|
||||
{...props}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue(props.name, checked)
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={name}>
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
163
src/blocks/Form/Component.tsx
Normal file
163
src/blocks/Form/Component.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
import type { FormFieldBlock, Form as FormType } from '@payloadcms/plugin-form-builder/types'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useForm, FormProvider } from 'react-hook-form'
|
||||
import RichText from '@/components/RichText'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { fields } from './fields'
|
||||
import { getClientSideURL } from '@/utilities/getURL'
|
||||
|
||||
export type FormBlockType = {
|
||||
blockName?: string
|
||||
blockType?: 'formBlock'
|
||||
enableIntro: boolean
|
||||
form: FormType
|
||||
introContent?: DefaultTypedEditorState
|
||||
}
|
||||
|
||||
export const FormBlock: React.FC<
|
||||
{
|
||||
id?: string
|
||||
} & FormBlockType
|
||||
> = (props) => {
|
||||
const {
|
||||
enableIntro,
|
||||
form: formFromProps,
|
||||
form: { id: formID, confirmationMessage, confirmationType, redirect, submitButtonLabel } = {},
|
||||
introContent,
|
||||
} = props
|
||||
|
||||
const formMethods = useForm({
|
||||
defaultValues: formFromProps.fields,
|
||||
})
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
register,
|
||||
} = formMethods
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>()
|
||||
const [error, setError] = useState<{ message: string; status?: string } | undefined>()
|
||||
const router = useRouter()
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormFieldBlock[]) => {
|
||||
let loadingTimerID: ReturnType<typeof setTimeout>
|
||||
const submitForm = async () => {
|
||||
setError(undefined)
|
||||
|
||||
const dataToSend = Object.entries(data).map(([name, value]) => ({
|
||||
field: name,
|
||||
value,
|
||||
}))
|
||||
|
||||
// delay loading indicator by 1s
|
||||
loadingTimerID = setTimeout(() => {
|
||||
setIsLoading(true)
|
||||
}, 1000)
|
||||
|
||||
try {
|
||||
const req = await fetch(`${getClientSideURL()}/api/form-submissions`, {
|
||||
body: JSON.stringify({
|
||||
form: formID,
|
||||
submissionData: dataToSend,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const res = await req.json()
|
||||
|
||||
clearTimeout(loadingTimerID)
|
||||
|
||||
if (req.status >= 400) {
|
||||
setIsLoading(false)
|
||||
|
||||
setError({
|
||||
message: res.errors?.[0]?.message || 'Internal Server Error',
|
||||
status: res.status,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
setHasSubmitted(true)
|
||||
|
||||
if (confirmationType === 'redirect' && redirect) {
|
||||
const { url } = redirect
|
||||
|
||||
const redirectUrl = url
|
||||
|
||||
if (redirectUrl) router.push(redirectUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
setIsLoading(false)
|
||||
setError({
|
||||
message: 'Something went wrong.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void submitForm()
|
||||
},
|
||||
[router, formID, redirect, confirmationType],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="container lg:max-w-[48rem]">
|
||||
{enableIntro && introContent && !hasSubmitted && (
|
||||
<RichText className="mb-8 lg:mb-12" data={introContent} enableGutter={false} />
|
||||
)}
|
||||
<div className="p-4 lg:p-6 border border-border rounded-[0.8rem]">
|
||||
<FormProvider {...formMethods}>
|
||||
{!isLoading && hasSubmitted && confirmationType === 'message' && (
|
||||
<RichText data={confirmationMessage} />
|
||||
)}
|
||||
{isLoading && !hasSubmitted && <p>Loading, please wait...</p>}
|
||||
{error && <div>{`${error.status || '500'}: ${error.message || ''}`}</div>}
|
||||
{!hasSubmitted && (
|
||||
<form id={formID} onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4 last:mb-0">
|
||||
{formFromProps &&
|
||||
formFromProps.fields &&
|
||||
formFromProps.fields?.map((field, index) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Field: React.FC<any> = fields?.[field.blockType as keyof typeof fields]
|
||||
if (Field) {
|
||||
return (
|
||||
<div className="mb-6 last:mb-0" key={index}>
|
||||
<Field
|
||||
form={formFromProps}
|
||||
{...field}
|
||||
{...formMethods}
|
||||
control={control}
|
||||
errors={errors}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button form={formID} type="submit" variant="default">
|
||||
{submitButtonLabel}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/blocks/Form/Country/index.tsx
Normal file
65
src/blocks/Form/Country/index.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import type { CountryField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import { countryOptions } from './options'
|
||||
|
||||
export const Country: React.FC<
|
||||
CountryField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label className="" htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = countryOptions.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{countryOptions.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
982
src/blocks/Form/Country/options.ts
Normal file
982
src/blocks/Form/Country/options.ts
Normal file
@ -0,0 +1,982 @@
|
||||
export const countryOptions = [
|
||||
{
|
||||
label: 'Afghanistan',
|
||||
value: 'AF',
|
||||
},
|
||||
{
|
||||
label: 'Åland Islands',
|
||||
value: 'AX',
|
||||
},
|
||||
{
|
||||
label: 'Albania',
|
||||
value: 'AL',
|
||||
},
|
||||
{
|
||||
label: 'Algeria',
|
||||
value: 'DZ',
|
||||
},
|
||||
{
|
||||
label: 'American Samoa',
|
||||
value: 'AS',
|
||||
},
|
||||
{
|
||||
label: 'Andorra',
|
||||
value: 'AD',
|
||||
},
|
||||
{
|
||||
label: 'Angola',
|
||||
value: 'AO',
|
||||
},
|
||||
{
|
||||
label: 'Anguilla',
|
||||
value: 'AI',
|
||||
},
|
||||
{
|
||||
label: 'Antarctica',
|
||||
value: 'AQ',
|
||||
},
|
||||
{
|
||||
label: 'Antigua and Barbuda',
|
||||
value: 'AG',
|
||||
},
|
||||
{
|
||||
label: 'Argentina',
|
||||
value: 'AR',
|
||||
},
|
||||
{
|
||||
label: 'Armenia',
|
||||
value: 'AM',
|
||||
},
|
||||
{
|
||||
label: 'Aruba',
|
||||
value: 'AW',
|
||||
},
|
||||
{
|
||||
label: 'Australia',
|
||||
value: 'AU',
|
||||
},
|
||||
{
|
||||
label: 'Austria',
|
||||
value: 'AT',
|
||||
},
|
||||
{
|
||||
label: 'Azerbaijan',
|
||||
value: 'AZ',
|
||||
},
|
||||
{
|
||||
label: 'Bahamas',
|
||||
value: 'BS',
|
||||
},
|
||||
{
|
||||
label: 'Bahrain',
|
||||
value: 'BH',
|
||||
},
|
||||
{
|
||||
label: 'Bangladesh',
|
||||
value: 'BD',
|
||||
},
|
||||
{
|
||||
label: 'Barbados',
|
||||
value: 'BB',
|
||||
},
|
||||
{
|
||||
label: 'Belarus',
|
||||
value: 'BY',
|
||||
},
|
||||
{
|
||||
label: 'Belgium',
|
||||
value: 'BE',
|
||||
},
|
||||
{
|
||||
label: 'Belize',
|
||||
value: 'BZ',
|
||||
},
|
||||
{
|
||||
label: 'Benin',
|
||||
value: 'BJ',
|
||||
},
|
||||
{
|
||||
label: 'Bermuda',
|
||||
value: 'BM',
|
||||
},
|
||||
{
|
||||
label: 'Bhutan',
|
||||
value: 'BT',
|
||||
},
|
||||
{
|
||||
label: 'Bolivia',
|
||||
value: 'BO',
|
||||
},
|
||||
{
|
||||
label: 'Bosnia and Herzegovina',
|
||||
value: 'BA',
|
||||
},
|
||||
{
|
||||
label: 'Botswana',
|
||||
value: 'BW',
|
||||
},
|
||||
{
|
||||
label: 'Bouvet Island',
|
||||
value: 'BV',
|
||||
},
|
||||
{
|
||||
label: 'Brazil',
|
||||
value: 'BR',
|
||||
},
|
||||
{
|
||||
label: 'British Indian Ocean Territory',
|
||||
value: 'IO',
|
||||
},
|
||||
{
|
||||
label: 'Brunei Darussalam',
|
||||
value: 'BN',
|
||||
},
|
||||
{
|
||||
label: 'Bulgaria',
|
||||
value: 'BG',
|
||||
},
|
||||
{
|
||||
label: 'Burkina Faso',
|
||||
value: 'BF',
|
||||
},
|
||||
{
|
||||
label: 'Burundi',
|
||||
value: 'BI',
|
||||
},
|
||||
{
|
||||
label: 'Cambodia',
|
||||
value: 'KH',
|
||||
},
|
||||
{
|
||||
label: 'Cameroon',
|
||||
value: 'CM',
|
||||
},
|
||||
{
|
||||
label: 'Canada',
|
||||
value: 'CA',
|
||||
},
|
||||
{
|
||||
label: 'Cape Verde',
|
||||
value: 'CV',
|
||||
},
|
||||
{
|
||||
label: 'Cayman Islands',
|
||||
value: 'KY',
|
||||
},
|
||||
{
|
||||
label: 'Central African Republic',
|
||||
value: 'CF',
|
||||
},
|
||||
{
|
||||
label: 'Chad',
|
||||
value: 'TD',
|
||||
},
|
||||
{
|
||||
label: 'Chile',
|
||||
value: 'CL',
|
||||
},
|
||||
{
|
||||
label: 'China',
|
||||
value: 'CN',
|
||||
},
|
||||
{
|
||||
label: 'Christmas Island',
|
||||
value: 'CX',
|
||||
},
|
||||
{
|
||||
label: 'Cocos (Keeling) Islands',
|
||||
value: 'CC',
|
||||
},
|
||||
{
|
||||
label: 'Colombia',
|
||||
value: 'CO',
|
||||
},
|
||||
{
|
||||
label: 'Comoros',
|
||||
value: 'KM',
|
||||
},
|
||||
{
|
||||
label: 'Congo',
|
||||
value: 'CG',
|
||||
},
|
||||
{
|
||||
label: 'Congo, The Democratic Republic of the',
|
||||
value: 'CD',
|
||||
},
|
||||
{
|
||||
label: 'Cook Islands',
|
||||
value: 'CK',
|
||||
},
|
||||
{
|
||||
label: 'Costa Rica',
|
||||
value: 'CR',
|
||||
},
|
||||
{
|
||||
label: "Cote D'Ivoire",
|
||||
value: 'CI',
|
||||
},
|
||||
{
|
||||
label: 'Croatia',
|
||||
value: 'HR',
|
||||
},
|
||||
{
|
||||
label: 'Cuba',
|
||||
value: 'CU',
|
||||
},
|
||||
{
|
||||
label: 'Cyprus',
|
||||
value: 'CY',
|
||||
},
|
||||
{
|
||||
label: 'Czech Republic',
|
||||
value: 'CZ',
|
||||
},
|
||||
{
|
||||
label: 'Denmark',
|
||||
value: 'DK',
|
||||
},
|
||||
{
|
||||
label: 'Djibouti',
|
||||
value: 'DJ',
|
||||
},
|
||||
{
|
||||
label: 'Dominica',
|
||||
value: 'DM',
|
||||
},
|
||||
{
|
||||
label: 'Dominican Republic',
|
||||
value: 'DO',
|
||||
},
|
||||
{
|
||||
label: 'Ecuador',
|
||||
value: 'EC',
|
||||
},
|
||||
{
|
||||
label: 'Egypt',
|
||||
value: 'EG',
|
||||
},
|
||||
{
|
||||
label: 'El Salvador',
|
||||
value: 'SV',
|
||||
},
|
||||
{
|
||||
label: 'Equatorial Guinea',
|
||||
value: 'GQ',
|
||||
},
|
||||
{
|
||||
label: 'Eritrea',
|
||||
value: 'ER',
|
||||
},
|
||||
{
|
||||
label: 'Estonia',
|
||||
value: 'EE',
|
||||
},
|
||||
{
|
||||
label: 'Ethiopia',
|
||||
value: 'ET',
|
||||
},
|
||||
{
|
||||
label: 'Falkland Islands (Malvinas)',
|
||||
value: 'FK',
|
||||
},
|
||||
{
|
||||
label: 'Faroe Islands',
|
||||
value: 'FO',
|
||||
},
|
||||
{
|
||||
label: 'Fiji',
|
||||
value: 'FJ',
|
||||
},
|
||||
{
|
||||
label: 'Finland',
|
||||
value: 'FI',
|
||||
},
|
||||
{
|
||||
label: 'France',
|
||||
value: 'FR',
|
||||
},
|
||||
{
|
||||
label: 'French Guiana',
|
||||
value: 'GF',
|
||||
},
|
||||
{
|
||||
label: 'French Polynesia',
|
||||
value: 'PF',
|
||||
},
|
||||
{
|
||||
label: 'French Southern Territories',
|
||||
value: 'TF',
|
||||
},
|
||||
{
|
||||
label: 'Gabon',
|
||||
value: 'GA',
|
||||
},
|
||||
{
|
||||
label: 'Gambia',
|
||||
value: 'GM',
|
||||
},
|
||||
{
|
||||
label: 'Georgia',
|
||||
value: 'GE',
|
||||
},
|
||||
{
|
||||
label: 'Germany',
|
||||
value: 'DE',
|
||||
},
|
||||
{
|
||||
label: 'Ghana',
|
||||
value: 'GH',
|
||||
},
|
||||
{
|
||||
label: 'Gibraltar',
|
||||
value: 'GI',
|
||||
},
|
||||
{
|
||||
label: 'Greece',
|
||||
value: 'GR',
|
||||
},
|
||||
{
|
||||
label: 'Greenland',
|
||||
value: 'GL',
|
||||
},
|
||||
{
|
||||
label: 'Grenada',
|
||||
value: 'GD',
|
||||
},
|
||||
{
|
||||
label: 'Guadeloupe',
|
||||
value: 'GP',
|
||||
},
|
||||
{
|
||||
label: 'Guam',
|
||||
value: 'GU',
|
||||
},
|
||||
{
|
||||
label: 'Guatemala',
|
||||
value: 'GT',
|
||||
},
|
||||
{
|
||||
label: 'Guernsey',
|
||||
value: 'GG',
|
||||
},
|
||||
{
|
||||
label: 'Guinea',
|
||||
value: 'GN',
|
||||
},
|
||||
{
|
||||
label: 'Guinea-Bissau',
|
||||
value: 'GW',
|
||||
},
|
||||
{
|
||||
label: 'Guyana',
|
||||
value: 'GY',
|
||||
},
|
||||
{
|
||||
label: 'Haiti',
|
||||
value: 'HT',
|
||||
},
|
||||
{
|
||||
label: 'Heard Island and Mcdonald Islands',
|
||||
value: 'HM',
|
||||
},
|
||||
{
|
||||
label: 'Holy See (Vatican City State)',
|
||||
value: 'VA',
|
||||
},
|
||||
{
|
||||
label: 'Honduras',
|
||||
value: 'HN',
|
||||
},
|
||||
{
|
||||
label: 'Hong Kong',
|
||||
value: 'HK',
|
||||
},
|
||||
{
|
||||
label: 'Hungary',
|
||||
value: 'HU',
|
||||
},
|
||||
{
|
||||
label: 'Iceland',
|
||||
value: 'IS',
|
||||
},
|
||||
{
|
||||
label: 'India',
|
||||
value: 'IN',
|
||||
},
|
||||
{
|
||||
label: 'Indonesia',
|
||||
value: 'ID',
|
||||
},
|
||||
{
|
||||
label: 'Iran, Islamic Republic Of',
|
||||
value: 'IR',
|
||||
},
|
||||
{
|
||||
label: 'Iraq',
|
||||
value: 'IQ',
|
||||
},
|
||||
{
|
||||
label: 'Ireland',
|
||||
value: 'IE',
|
||||
},
|
||||
{
|
||||
label: 'Isle of Man',
|
||||
value: 'IM',
|
||||
},
|
||||
{
|
||||
label: 'Israel',
|
||||
value: 'IL',
|
||||
},
|
||||
{
|
||||
label: 'Italy',
|
||||
value: 'IT',
|
||||
},
|
||||
{
|
||||
label: 'Jamaica',
|
||||
value: 'JM',
|
||||
},
|
||||
{
|
||||
label: 'Japan',
|
||||
value: 'JP',
|
||||
},
|
||||
{
|
||||
label: 'Jersey',
|
||||
value: 'JE',
|
||||
},
|
||||
{
|
||||
label: 'Jordan',
|
||||
value: 'JO',
|
||||
},
|
||||
{
|
||||
label: 'Kazakhstan',
|
||||
value: 'KZ',
|
||||
},
|
||||
{
|
||||
label: 'Kenya',
|
||||
value: 'KE',
|
||||
},
|
||||
{
|
||||
label: 'Kiribati',
|
||||
value: 'KI',
|
||||
},
|
||||
{
|
||||
label: "Democratic People's Republic of Korea",
|
||||
value: 'KP',
|
||||
},
|
||||
{
|
||||
label: 'Korea, Republic of',
|
||||
value: 'KR',
|
||||
},
|
||||
{
|
||||
label: 'Kosovo',
|
||||
value: 'XK',
|
||||
},
|
||||
{
|
||||
label: 'Kuwait',
|
||||
value: 'KW',
|
||||
},
|
||||
{
|
||||
label: 'Kyrgyzstan',
|
||||
value: 'KG',
|
||||
},
|
||||
{
|
||||
label: "Lao People's Democratic Republic",
|
||||
value: 'LA',
|
||||
},
|
||||
{
|
||||
label: 'Latvia',
|
||||
value: 'LV',
|
||||
},
|
||||
{
|
||||
label: 'Lebanon',
|
||||
value: 'LB',
|
||||
},
|
||||
{
|
||||
label: 'Lesotho',
|
||||
value: 'LS',
|
||||
},
|
||||
{
|
||||
label: 'Liberia',
|
||||
value: 'LR',
|
||||
},
|
||||
{
|
||||
label: 'Libyan Arab Jamahiriya',
|
||||
value: 'LY',
|
||||
},
|
||||
{
|
||||
label: 'Liechtenstein',
|
||||
value: 'LI',
|
||||
},
|
||||
{
|
||||
label: 'Lithuania',
|
||||
value: 'LT',
|
||||
},
|
||||
{
|
||||
label: 'Luxembourg',
|
||||
value: 'LU',
|
||||
},
|
||||
{
|
||||
label: 'Macao',
|
||||
value: 'MO',
|
||||
},
|
||||
{
|
||||
label: 'Macedonia, The Former Yugoslav Republic of',
|
||||
value: 'MK',
|
||||
},
|
||||
{
|
||||
label: 'Madagascar',
|
||||
value: 'MG',
|
||||
},
|
||||
{
|
||||
label: 'Malawi',
|
||||
value: 'MW',
|
||||
},
|
||||
{
|
||||
label: 'Malaysia',
|
||||
value: 'MY',
|
||||
},
|
||||
{
|
||||
label: 'Maldives',
|
||||
value: 'MV',
|
||||
},
|
||||
{
|
||||
label: 'Mali',
|
||||
value: 'ML',
|
||||
},
|
||||
{
|
||||
label: 'Malta',
|
||||
value: 'MT',
|
||||
},
|
||||
{
|
||||
label: 'Marshall Islands',
|
||||
value: 'MH',
|
||||
},
|
||||
{
|
||||
label: 'Martinique',
|
||||
value: 'MQ',
|
||||
},
|
||||
{
|
||||
label: 'Mauritania',
|
||||
value: 'MR',
|
||||
},
|
||||
{
|
||||
label: 'Mauritius',
|
||||
value: 'MU',
|
||||
},
|
||||
{
|
||||
label: 'Mayotte',
|
||||
value: 'YT',
|
||||
},
|
||||
{
|
||||
label: 'Mexico',
|
||||
value: 'MX',
|
||||
},
|
||||
{
|
||||
label: 'Micronesia, Federated States of',
|
||||
value: 'FM',
|
||||
},
|
||||
{
|
||||
label: 'Moldova, Republic of',
|
||||
value: 'MD',
|
||||
},
|
||||
{
|
||||
label: 'Monaco',
|
||||
value: 'MC',
|
||||
},
|
||||
{
|
||||
label: 'Mongolia',
|
||||
value: 'MN',
|
||||
},
|
||||
{
|
||||
label: 'Montenegro',
|
||||
value: 'ME',
|
||||
},
|
||||
{
|
||||
label: 'Montserrat',
|
||||
value: 'MS',
|
||||
},
|
||||
{
|
||||
label: 'Morocco',
|
||||
value: 'MA',
|
||||
},
|
||||
{
|
||||
label: 'Mozambique',
|
||||
value: 'MZ',
|
||||
},
|
||||
{
|
||||
label: 'Myanmar',
|
||||
value: 'MM',
|
||||
},
|
||||
{
|
||||
label: 'Namibia',
|
||||
value: 'NA',
|
||||
},
|
||||
{
|
||||
label: 'Nauru',
|
||||
value: 'NR',
|
||||
},
|
||||
{
|
||||
label: 'Nepal',
|
||||
value: 'NP',
|
||||
},
|
||||
{
|
||||
label: 'Netherlands',
|
||||
value: 'NL',
|
||||
},
|
||||
{
|
||||
label: 'Netherlands Antilles',
|
||||
value: 'AN',
|
||||
},
|
||||
{
|
||||
label: 'New Caledonia',
|
||||
value: 'NC',
|
||||
},
|
||||
{
|
||||
label: 'New Zealand',
|
||||
value: 'NZ',
|
||||
},
|
||||
{
|
||||
label: 'Nicaragua',
|
||||
value: 'NI',
|
||||
},
|
||||
{
|
||||
label: 'Niger',
|
||||
value: 'NE',
|
||||
},
|
||||
{
|
||||
label: 'Nigeria',
|
||||
value: 'NG',
|
||||
},
|
||||
{
|
||||
label: 'Niue',
|
||||
value: 'NU',
|
||||
},
|
||||
{
|
||||
label: 'Norfolk Island',
|
||||
value: 'NF',
|
||||
},
|
||||
{
|
||||
label: 'Northern Mariana Islands',
|
||||
value: 'MP',
|
||||
},
|
||||
{
|
||||
label: 'Norway',
|
||||
value: 'NO',
|
||||
},
|
||||
{
|
||||
label: 'Oman',
|
||||
value: 'OM',
|
||||
},
|
||||
{
|
||||
label: 'Pakistan',
|
||||
value: 'PK',
|
||||
},
|
||||
{
|
||||
label: 'Palau',
|
||||
value: 'PW',
|
||||
},
|
||||
{
|
||||
label: 'Palestinian Territory, Occupied',
|
||||
value: 'PS',
|
||||
},
|
||||
{
|
||||
label: 'Panama',
|
||||
value: 'PA',
|
||||
},
|
||||
{
|
||||
label: 'Papua New Guinea',
|
||||
value: 'PG',
|
||||
},
|
||||
{
|
||||
label: 'Paraguay',
|
||||
value: 'PY',
|
||||
},
|
||||
{
|
||||
label: 'Peru',
|
||||
value: 'PE',
|
||||
},
|
||||
{
|
||||
label: 'Philippines',
|
||||
value: 'PH',
|
||||
},
|
||||
{
|
||||
label: 'Pitcairn',
|
||||
value: 'PN',
|
||||
},
|
||||
{
|
||||
label: 'Poland',
|
||||
value: 'PL',
|
||||
},
|
||||
{
|
||||
label: 'Portugal',
|
||||
value: 'PT',
|
||||
},
|
||||
{
|
||||
label: 'Puerto Rico',
|
||||
value: 'PR',
|
||||
},
|
||||
{
|
||||
label: 'Qatar',
|
||||
value: 'QA',
|
||||
},
|
||||
{
|
||||
label: 'Reunion',
|
||||
value: 'RE',
|
||||
},
|
||||
{
|
||||
label: 'Romania',
|
||||
value: 'RO',
|
||||
},
|
||||
{
|
||||
label: 'Russian Federation',
|
||||
value: 'RU',
|
||||
},
|
||||
{
|
||||
label: 'Rwanda',
|
||||
value: 'RW',
|
||||
},
|
||||
{
|
||||
label: 'Saint Helena',
|
||||
value: 'SH',
|
||||
},
|
||||
{
|
||||
label: 'Saint Kitts and Nevis',
|
||||
value: 'KN',
|
||||
},
|
||||
{
|
||||
label: 'Saint Lucia',
|
||||
value: 'LC',
|
||||
},
|
||||
{
|
||||
label: 'Saint Pierre and Miquelon',
|
||||
value: 'PM',
|
||||
},
|
||||
{
|
||||
label: 'Saint Vincent and the Grenadines',
|
||||
value: 'VC',
|
||||
},
|
||||
{
|
||||
label: 'Samoa',
|
||||
value: 'WS',
|
||||
},
|
||||
{
|
||||
label: 'San Marino',
|
||||
value: 'SM',
|
||||
},
|
||||
{
|
||||
label: 'Sao Tome and Principe',
|
||||
value: 'ST',
|
||||
},
|
||||
{
|
||||
label: 'Saudi Arabia',
|
||||
value: 'SA',
|
||||
},
|
||||
{
|
||||
label: 'Senegal',
|
||||
value: 'SN',
|
||||
},
|
||||
{
|
||||
label: 'Serbia',
|
||||
value: 'RS',
|
||||
},
|
||||
{
|
||||
label: 'Seychelles',
|
||||
value: 'SC',
|
||||
},
|
||||
{
|
||||
label: 'Sierra Leone',
|
||||
value: 'SL',
|
||||
},
|
||||
{
|
||||
label: 'Singapore',
|
||||
value: 'SG',
|
||||
},
|
||||
{
|
||||
label: 'Slovakia',
|
||||
value: 'SK',
|
||||
},
|
||||
{
|
||||
label: 'Slovenia',
|
||||
value: 'SI',
|
||||
},
|
||||
{
|
||||
label: 'Solomon Islands',
|
||||
value: 'SB',
|
||||
},
|
||||
{
|
||||
label: 'Somalia',
|
||||
value: 'SO',
|
||||
},
|
||||
{
|
||||
label: 'South Africa',
|
||||
value: 'ZA',
|
||||
},
|
||||
{
|
||||
label: 'South Georgia and the South Sandwich Islands',
|
||||
value: 'GS',
|
||||
},
|
||||
{
|
||||
label: 'Spain',
|
||||
value: 'ES',
|
||||
},
|
||||
{
|
||||
label: 'Sri Lanka',
|
||||
value: 'LK',
|
||||
},
|
||||
{
|
||||
label: 'Sudan',
|
||||
value: 'SD',
|
||||
},
|
||||
{
|
||||
label: 'Suriname',
|
||||
value: 'SR',
|
||||
},
|
||||
{
|
||||
label: 'Svalbard and Jan Mayen',
|
||||
value: 'SJ',
|
||||
},
|
||||
{
|
||||
label: 'Swaziland',
|
||||
value: 'SZ',
|
||||
},
|
||||
{
|
||||
label: 'Sweden',
|
||||
value: 'SE',
|
||||
},
|
||||
{
|
||||
label: 'Switzerland',
|
||||
value: 'CH',
|
||||
},
|
||||
{
|
||||
label: 'Syrian Arab Republic',
|
||||
value: 'SY',
|
||||
},
|
||||
{
|
||||
label: 'Taiwan',
|
||||
value: 'TW',
|
||||
},
|
||||
{
|
||||
label: 'Tajikistan',
|
||||
value: 'TJ',
|
||||
},
|
||||
{
|
||||
label: 'Tanzania, United Republic of',
|
||||
value: 'TZ',
|
||||
},
|
||||
{
|
||||
label: 'Thailand',
|
||||
value: 'TH',
|
||||
},
|
||||
{
|
||||
label: 'Timor-Leste',
|
||||
value: 'TL',
|
||||
},
|
||||
{
|
||||
label: 'Togo',
|
||||
value: 'TG',
|
||||
},
|
||||
{
|
||||
label: 'Tokelau',
|
||||
value: 'TK',
|
||||
},
|
||||
{
|
||||
label: 'Tonga',
|
||||
value: 'TO',
|
||||
},
|
||||
{
|
||||
label: 'Trinidad and Tobago',
|
||||
value: 'TT',
|
||||
},
|
||||
{
|
||||
label: 'Tunisia',
|
||||
value: 'TN',
|
||||
},
|
||||
{
|
||||
label: 'Turkey',
|
||||
value: 'TR',
|
||||
},
|
||||
{
|
||||
label: 'Turkmenistan',
|
||||
value: 'TM',
|
||||
},
|
||||
{
|
||||
label: 'Turks and Caicos Islands',
|
||||
value: 'TC',
|
||||
},
|
||||
{
|
||||
label: 'Tuvalu',
|
||||
value: 'TV',
|
||||
},
|
||||
{
|
||||
label: 'Uganda',
|
||||
value: 'UG',
|
||||
},
|
||||
{
|
||||
label: 'Ukraine',
|
||||
value: 'UA',
|
||||
},
|
||||
{
|
||||
label: 'United Arab Emirates',
|
||||
value: 'AE',
|
||||
},
|
||||
{
|
||||
label: 'United Kingdom',
|
||||
value: 'GB',
|
||||
},
|
||||
{
|
||||
label: 'United States',
|
||||
value: 'US',
|
||||
},
|
||||
{
|
||||
label: 'United States Minor Outlying Islands',
|
||||
value: 'UM',
|
||||
},
|
||||
{
|
||||
label: 'Uruguay',
|
||||
value: 'UY',
|
||||
},
|
||||
{
|
||||
label: 'Uzbekistan',
|
||||
value: 'UZ',
|
||||
},
|
||||
{
|
||||
label: 'Vanuatu',
|
||||
value: 'VU',
|
||||
},
|
||||
{
|
||||
label: 'Venezuela',
|
||||
value: 'VE',
|
||||
},
|
||||
{
|
||||
label: 'Viet Nam',
|
||||
value: 'VN',
|
||||
},
|
||||
{
|
||||
label: 'Virgin Islands, British',
|
||||
value: 'VG',
|
||||
},
|
||||
{
|
||||
label: 'Virgin Islands, U.S.',
|
||||
value: 'VI',
|
||||
},
|
||||
{
|
||||
label: 'Wallis and Futuna',
|
||||
value: 'WF',
|
||||
},
|
||||
{
|
||||
label: 'Western Sahara',
|
||||
value: 'EH',
|
||||
},
|
||||
{
|
||||
label: 'Yemen',
|
||||
value: 'YE',
|
||||
},
|
||||
{
|
||||
label: 'Zambia',
|
||||
value: 'ZM',
|
||||
},
|
||||
{
|
||||
label: 'Zimbabwe',
|
||||
value: 'ZW',
|
||||
},
|
||||
]
|
||||
38
src/blocks/Form/Email/index.tsx
Normal file
38
src/blocks/Form/Email/index.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import type { EmailField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Email: React.FC<
|
||||
EmailField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
type="text"
|
||||
{...register(name, { pattern: /^\S[^\s@]*@\S+$/, required })}
|
||||
/>
|
||||
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
15
src/blocks/Form/Error/index.tsx
Normal file
15
src/blocks/Form/Error/index.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
|
||||
export const Error = ({ name }: { name: string }) => {
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext()
|
||||
return (
|
||||
<div className="mt-2 text-red-500 text-sm">
|
||||
{(errors[name]?.message as string) || 'This field is required'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
src/blocks/Form/Message/index.tsx
Normal file
13
src/blocks/Form/Message/index.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import RichText from '@/components/RichText'
|
||||
import React from 'react'
|
||||
|
||||
import { Width } from '../Width'
|
||||
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const Message: React.FC<{ message: DefaultTypedEditorState }> = ({ message }) => {
|
||||
return (
|
||||
<Width className="my-12" width="100">
|
||||
{message && <RichText data={message} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
36
src/blocks/Form/Number/index.tsx
Normal file
36
src/blocks/Form/Number/index.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
export const Number: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
type="number"
|
||||
{...register(name, { required })}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
63
src/blocks/Form/Select/index.tsx
Normal file
63
src/blocks/Form/Select/index.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import type { SelectField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select as SelectComponent,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Select: React.FC<
|
||||
SelectField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, options, required, width, defaultValue }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = options.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<SelectComponent onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</SelectComponent>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user