feat: migrate website-creator from Next.js+Payload to Astro+Tina CMS
Major changes: - Replace Payload CMS with Tina CMS (self-hosted) - Add Astro DB for consent logging (PDPA compliant) - Update Tailwind v3 to v4 (@tailwindcss/vite plugin) - Add astro-tina-starter template - Rewrite consent template for Astro (ConsentBanner.astro, Astro DB, Nano Stores) - Add install-tina-backend.sh for self-hosted Tina per customer - Rename convert-astro.sh to migrate-tina.sh - Add AGENTS.md template for generated websites - Delete all Payload/Next.js files Technical updates: - Astro DB using defineDb with eq operators for queries - Tailwind v4 with @theme block - Tina CMS local development mode - Proper Astro API routes for consent Research-verified with official documentation (April 2026)
This commit is contained in:
@@ -142,7 +142,7 @@ Output:
|
|||||||
- meta_description: 150-160 chars
|
- meta_description: 150-160 chars
|
||||||
- slug: Auto-generated (Thai-friendly)
|
- slug: Auto-generated (Thai-friendly)
|
||||||
- images: Saved to website repo
|
- images: Saved to website repo
|
||||||
- astro_ready: true (content collections format)
|
- payload_cms_ready: true (REST API format)
|
||||||
```
|
```
|
||||||
|
|
||||||
**X/Twitter Thread:**
|
**X/Twitter Thread:**
|
||||||
@@ -415,10 +415,14 @@ def save_image_for_channel(image_data: bytes, topic: str, channel: str) -> str:
|
|||||||
### **Website-Creator Integration:**
|
### **Website-Creator Integration:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def publish_blog_to_astro(article_md: str, website_repo: str) -> Dict:
|
def publish_blog_to_payload(
|
||||||
|
article_md: str,
|
||||||
|
website_url: str,
|
||||||
|
payload_token: str
|
||||||
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Publish blog post to Astro content collections
|
Publish blog post to Payload CMS via REST API
|
||||||
Returns deployment status
|
Returns publication status
|
||||||
"""
|
"""
|
||||||
# Parse frontmatter
|
# Parse frontmatter
|
||||||
frontmatter = parse_frontmatter(article_md)
|
frontmatter = parse_frontmatter(article_md)
|
||||||
@@ -428,42 +432,38 @@ def publish_blog_to_astro(article_md: str, website_repo: str) -> Dict:
|
|||||||
|
|
||||||
# Generate slug
|
# Generate slug
|
||||||
slug = generate_slug(frontmatter['title'], lang)
|
slug = generate_slug(frontmatter['title'], lang)
|
||||||
|
if lang == 'th':
|
||||||
|
slug = f'th/{slug}'
|
||||||
|
|
||||||
# Determine output path
|
# Convert markdown to Lexical JSON
|
||||||
output_path = os.path.join(
|
content = markdown_to_lexical(article_md)
|
||||||
website_repo,
|
|
||||||
'src/content/blog',
|
# Prepare Payload document
|
||||||
f'({lang})',
|
payload_doc = {
|
||||||
f'{slug}.md'
|
'title': frontmatter['title'],
|
||||||
|
'slug': slug,
|
||||||
|
'content': content,
|
||||||
|
'status': 'draft',
|
||||||
|
'description': frontmatter.get('description', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send to Payload CMS API
|
||||||
|
response = requests.post(
|
||||||
|
f'{website_url}/api/posts',
|
||||||
|
headers={'Authorization': f'Bearer {payload_token}'},
|
||||||
|
json=payload_doc
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure directory exists
|
result = response.json()
|
||||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
||||||
|
|
||||||
# Write article
|
# Return publication info
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(article_md)
|
|
||||||
|
|
||||||
# Copy images if any
|
|
||||||
if 'images' in frontmatter:
|
|
||||||
for img in frontmatter['images']:
|
|
||||||
# Copy from temp location to website repo
|
|
||||||
dest_path = os.path.join(website_repo, 'public', img['src'].lstrip('/'))
|
|
||||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
|
||||||
shutil.copy(img['local_path'], dest_path)
|
|
||||||
|
|
||||||
# Git commit and push
|
|
||||||
subprocess.run(['git', 'add', '.'], cwd=website_repo, check=True)
|
|
||||||
subprocess.run(['git', 'commit', '-m', f'Add blog post: {slug}'], cwd=website_repo, check=True)
|
|
||||||
subprocess.run(['git', 'push', 'origin', 'main'], cwd=website_repo, check=True)
|
|
||||||
|
|
||||||
# Return deployment info
|
|
||||||
return {
|
return {
|
||||||
'published': True,
|
'published': True,
|
||||||
|
'id': result.get('id'),
|
||||||
'slug': slug,
|
'slug': slug,
|
||||||
'language': lang,
|
'language': lang,
|
||||||
'path': output_path,
|
'admin_url': f'{website_url}/admin/collections/posts/{result.get("id")}',
|
||||||
'deployment_url': f"https://your-domain.com/blog/{slug}" if lang == 'en' else f"https://your-domain.com/th/{slug}"
|
'api_url': f'{website_url}/api/posts',
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Auto-Publish to Astro Content Collections
|
Auto-Publish to Payload CMS
|
||||||
|
|
||||||
Publishes blog posts to Astro content collections,
|
Publishes blog posts to Payload CMS collections via REST API,
|
||||||
commits to git, and triggers auto-deploy.
|
commits to git, and triggers auto-deploy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -11,40 +11,117 @@ import sys
|
|||||||
import subprocess
|
import subprocess
|
||||||
import argparse
|
import argparse
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional, List
|
||||||
|
|
||||||
|
|
||||||
class AstroPublisher:
|
class PayloadPublisher:
|
||||||
"""Publish blog posts to Astro content collections"""
|
"""Publish blog posts to Payload CMS via REST API"""
|
||||||
|
|
||||||
def __init__(self, website_repo: str):
|
def __init__(self, website_url: str, website_repo: str = None):
|
||||||
"""
|
"""
|
||||||
Initialize Astro publisher
|
Initialize Payload publisher
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
website_repo: Path to Astro website repository
|
website_url: URL of the website (e.g., https://example.com)
|
||||||
|
website_repo: Optional path to website repository for git sync
|
||||||
"""
|
"""
|
||||||
|
self.website_url = website_url.rstrip("/")
|
||||||
|
self.api_base = f"{self.website_url}/api"
|
||||||
self.website_repo = website_repo
|
self.website_repo = website_repo
|
||||||
self.content_dir = os.path.join(website_repo, 'src/content/blog')
|
self.collection = "posts"
|
||||||
self.images_dir = os.path.join(website_repo, 'public/images/blog')
|
|
||||||
|
def _make_request(
|
||||||
|
self, endpoint: str, method: str = "GET", data: dict = None, token: str = None
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Make HTTP request to Payload CMS API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint path (e.g., '/posts' or '/globals/site')
|
||||||
|
method: HTTP method
|
||||||
|
data: JSON data to send
|
||||||
|
token: Bearer token for authentication
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response JSON as dict
|
||||||
|
"""
|
||||||
|
url = f"{self.api_base}{endpoint}"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
request_data = None
|
||||||
|
if data:
|
||||||
|
request_data = json.dumps(data).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=request_data, headers=headers, method=method
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as response:
|
||||||
|
response_body = response.read().decode("utf-8")
|
||||||
|
if response_body:
|
||||||
|
return json.loads(response_body)
|
||||||
|
return {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode("utf-8") if e.fp else "{}"
|
||||||
|
try:
|
||||||
|
error_data = json.loads(error_body)
|
||||||
|
return {"error": error_data.get("message", str(e)), "status": e.code}
|
||||||
|
except:
|
||||||
|
return {"error": str(e), "status": e.code}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"error": f"Connection failed: {e.reason}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
def get_token(self, email: str, password: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Authenticate and get access token
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: User email
|
||||||
|
password: User password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Access token or None
|
||||||
|
"""
|
||||||
|
result = self._make_request(
|
||||||
|
"/users/login", method="POST", data={"email": email, "password": password}
|
||||||
|
)
|
||||||
|
|
||||||
|
if "token" in result:
|
||||||
|
return result["token"]
|
||||||
|
elif "error" in result:
|
||||||
|
print(f" ✗ Auth failed: {result['error']}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def detect_language(self, content: str) -> str:
|
def detect_language(self, content: str) -> str:
|
||||||
"""Detect if content is Thai or English"""
|
"""Detect if content is Thai or English"""
|
||||||
thai_chars = sum(1 for c in content if '\u0E00' <= c <= '\u0E7F')
|
thai_chars = sum(1 for c in content if "\u0e00" <= c <= "\u0e7f")
|
||||||
total_chars = len(content)
|
total_chars = len(content)
|
||||||
thai_ratio = thai_chars / total_chars if total_chars > 0 else 0
|
thai_ratio = thai_chars / total_chars if total_chars > 0 else 0
|
||||||
return 'th' if thai_ratio > 0.3 else 'en'
|
return "th" if thai_ratio > 0.3 else "en"
|
||||||
|
|
||||||
def generate_slug(self, title: str, lang: str = 'en') -> str:
|
def generate_slug(self, title: str, lang: str = "en") -> str:
|
||||||
"""Generate URL-friendly slug"""
|
"""Generate URL-friendly slug"""
|
||||||
# Remove special characters
|
# Remove special characters
|
||||||
slug = re.sub(r'[^\w\s-]', '', title.lower())
|
slug = re.sub(r"[^\w\s-]", "", title.lower())
|
||||||
# Replace whitespace with hyphens
|
# Replace whitespace with hyphens
|
||||||
slug = re.sub(r'[-\s]+', '-', slug)
|
slug = re.sub(r"[-\s]+", "-", slug)
|
||||||
# Remove leading/trailing hyphens
|
# Remove leading/trailing hyphens
|
||||||
slug = slug.strip('-_')
|
slug = slug.strip("-_")
|
||||||
# Limit length
|
# Limit length
|
||||||
return slug[:100]
|
return slug[:100]
|
||||||
|
|
||||||
@@ -52,12 +129,12 @@ class AstroPublisher:
|
|||||||
"""Parse frontmatter from markdown content"""
|
"""Parse frontmatter from markdown content"""
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
if not content.startswith('---'):
|
if not content.startswith("---"):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Extract frontmatter
|
# Extract frontmatter
|
||||||
parts = content.split('---', 2)
|
parts = content.split("---", 2)
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
frontmatter = yaml.safe_load(parts[1])
|
frontmatter = yaml.safe_load(parts[1])
|
||||||
return frontmatter or {}
|
return frontmatter or {}
|
||||||
@@ -66,14 +143,133 @@ class AstroPublisher:
|
|||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def publish(self, markdown_content: str, images: list = None, use_git: bool = False) -> Dict:
|
def markdown_to_lexical(self, markdown: str) -> Dict:
|
||||||
"""
|
"""
|
||||||
Publish blog post to Astro content collections
|
Convert markdown content to Lexical JSON format
|
||||||
|
|
||||||
|
This creates a basic Lexical editor state from markdown.
|
||||||
|
For full fidelity, consider using a proper markdown-to-lexical library.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
markdown: Raw markdown content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lexical JSON object
|
||||||
|
"""
|
||||||
|
# Basic markdown to Lexical conversion
|
||||||
|
# This creates a simple paragraph-based structure
|
||||||
|
lines = markdown.split("\n")
|
||||||
|
children = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
children.append(
|
||||||
|
{"type": "paragraph", "children": [{"type": "text", "text": ""}]}
|
||||||
|
)
|
||||||
|
elif line.startswith("# "):
|
||||||
|
# H1
|
||||||
|
children.append(
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"tag": "h1",
|
||||||
|
"children": [{"type": "text", "text": line[2:]}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif line.startswith("## "):
|
||||||
|
# H2
|
||||||
|
children.append(
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"tag": "h2",
|
||||||
|
"children": [{"type": "text", "text": line[3:]}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif line.startswith("### "):
|
||||||
|
# H3
|
||||||
|
children.append(
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"tag": "h3",
|
||||||
|
"children": [{"type": "text", "text": line[4:]}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif line.startswith("- ") or line.startswith("* "):
|
||||||
|
# Unordered list item
|
||||||
|
children.append(
|
||||||
|
{
|
||||||
|
"type": "list",
|
||||||
|
"listType": "bullet",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "listitem",
|
||||||
|
"children": [{"type": "text", "text": line[2:]}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif line.startswith("1. ") or line.startswith("1) "):
|
||||||
|
# Ordered list item
|
||||||
|
children.append(
|
||||||
|
{
|
||||||
|
"type": "list",
|
||||||
|
"listType": "number",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "listitem",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": re.sub(r"^\d+[.\)]\s*", "", line),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif line.startswith("> "):
|
||||||
|
# Blockquote
|
||||||
|
children.append(
|
||||||
|
{"type": "quote", "children": [{"type": "text", "text": line[2:]}]}
|
||||||
|
)
|
||||||
|
elif line.startswith("```"):
|
||||||
|
# Code block (simplified)
|
||||||
|
children.append(
|
||||||
|
{"type": "paragraph", "children": [{"type": "text", "text": line}]}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Regular paragraph
|
||||||
|
# Handle bold and italic inline
|
||||||
|
text = line
|
||||||
|
children.append(
|
||||||
|
{"type": "paragraph", "children": [{"type": "text", "text": text}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"root": {
|
||||||
|
"type": "root",
|
||||||
|
"format": "",
|
||||||
|
"indent": 0,
|
||||||
|
"version": 1,
|
||||||
|
"children": children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
markdown_content: str,
|
||||||
|
images: List[str] = None,
|
||||||
|
use_git: bool = False,
|
||||||
|
payload_token: str = None,
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Publish blog post to Payload CMS
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
markdown_content: Full markdown with frontmatter
|
markdown_content: Full markdown with frontmatter
|
||||||
images: List of image paths to copy
|
images: List of image paths to upload
|
||||||
use_git: Whether to git commit and push (default: False - direct write only)
|
use_git: Whether to git commit and push (default: False)
|
||||||
|
payload_token: Payload CMS access token
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Publication result
|
Publication result
|
||||||
@@ -83,123 +279,311 @@ class AstroPublisher:
|
|||||||
frontmatter = self.parse_frontmatter(markdown_content)
|
frontmatter = self.parse_frontmatter(markdown_content)
|
||||||
|
|
||||||
# Get required fields
|
# Get required fields
|
||||||
title = frontmatter.get('title', 'Untitled')
|
title = frontmatter.get("title", "Untitled")
|
||||||
slug = frontmatter.get('slug') or self.generate_slug(title)
|
slug = frontmatter.get("slug") or self.generate_slug(title)
|
||||||
lang = frontmatter.get('lang') or self.detect_language(markdown_content)
|
lang = frontmatter.get("lang") or self.detect_language(markdown_content)
|
||||||
|
status = frontmatter.get("status", "draft")
|
||||||
|
description = frontmatter.get("description", "")
|
||||||
|
|
||||||
# Determine output path
|
# Extract markdown body (after frontmatter)
|
||||||
lang_folder = f'({lang})'
|
body_content = markdown_content
|
||||||
output_dir = os.path.join(self.content_dir, lang_folder)
|
if markdown_content.startswith("---"):
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
parts = markdown_content.split("---", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
body_content = parts[2].strip()
|
||||||
|
|
||||||
output_path = os.path.join(output_dir, f'{slug}.md')
|
# Convert markdown to Lexical
|
||||||
|
lexical_content = self.markdown_to_lexical(body_content)
|
||||||
|
|
||||||
# Write markdown file (ALWAYS do this)
|
# Prepare Payload CMS document
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
payload_doc = {
|
||||||
f.write(markdown_content)
|
"title": title,
|
||||||
|
"slug": slug,
|
||||||
|
"content": lexical_content,
|
||||||
|
"status": status,
|
||||||
|
"publishedAt": datetime.now().isoformat()
|
||||||
|
if status == "published"
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
print(f"\n✓ Saved: {output_path}")
|
if description:
|
||||||
|
payload_doc["description"] = description
|
||||||
|
|
||||||
# Copy images if provided
|
# Add language prefix to slug for Thai content
|
||||||
|
if lang == "th":
|
||||||
|
payload_doc["slug"] = f"th/{slug}"
|
||||||
|
|
||||||
|
print(f"\n📝 Publishing to Payload CMS")
|
||||||
|
print(f" Title: {title}")
|
||||||
|
print(f" Slug: {payload_doc['slug']}")
|
||||||
|
print(f" Language: {lang}")
|
||||||
|
print(f" Status: {status}")
|
||||||
|
|
||||||
|
# Send to Payload CMS API
|
||||||
|
if payload_token:
|
||||||
|
headers = {"Authorization": f"Bearer {payload_token}"}
|
||||||
|
else:
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
# Check if post already exists
|
||||||
|
check_result = self._make_request(
|
||||||
|
f"/{self.collection}?where[slug][equals]={payload_doc['slug']}",
|
||||||
|
method="GET",
|
||||||
|
token=payload_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_doc = None
|
||||||
|
if check_result.get("docs") and len(check_result["docs"]) > 0:
|
||||||
|
existing_doc = check_result["docs"][0]
|
||||||
|
print(f" ℹ️ Post already exists with ID: {existing_doc['id']}")
|
||||||
|
|
||||||
|
# Create or update the post
|
||||||
|
if existing_doc:
|
||||||
|
# Update existing
|
||||||
|
result = self._make_request(
|
||||||
|
f"/{self.collection}/{existing_doc['id']}",
|
||||||
|
method="PATCH",
|
||||||
|
data=payload_doc,
|
||||||
|
token=payload_token,
|
||||||
|
)
|
||||||
|
action = "updated"
|
||||||
|
else:
|
||||||
|
# Create new
|
||||||
|
result = self._make_request(
|
||||||
|
f"/{self.collection}",
|
||||||
|
method="POST",
|
||||||
|
data=payload_doc,
|
||||||
|
token=payload_token,
|
||||||
|
)
|
||||||
|
action = "created"
|
||||||
|
|
||||||
|
if "error" in result and "id" not in result:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.get("error", "Unknown error"),
|
||||||
|
"status_code": result.get("status", 500),
|
||||||
|
}
|
||||||
|
|
||||||
|
doc_id = result.get("doc", result.get("id"))
|
||||||
|
print(f" ✓ Post {action}: {doc_id}")
|
||||||
|
|
||||||
|
# Upload images if provided (to media collection)
|
||||||
|
uploaded_images = []
|
||||||
if images:
|
if images:
|
||||||
images_output = os.path.join(self.images_dir, slug)
|
|
||||||
os.makedirs(images_output, exist_ok=True)
|
|
||||||
|
|
||||||
for img_path in images:
|
for img_path in images:
|
||||||
if os.path.exists(img_path):
|
if os.path.exists(img_path):
|
||||||
import shutil
|
uploaded = self._upload_media(img_path, payload_token)
|
||||||
shutil.copy(img_path, images_output)
|
if uploaded:
|
||||||
print(f" ✓ Copied image: {os.path.basename(img_path)}")
|
uploaded_images.append(uploaded)
|
||||||
|
print(f" ✓ Uploaded image: {os.path.basename(img_path)}")
|
||||||
|
|
||||||
# Git commit and push (OPTIONAL - only if requested and Gitea configured)
|
# Git commit and push (OPTIONAL)
|
||||||
git_result = None
|
git_result = None
|
||||||
if use_git:
|
if use_git and self.website_repo:
|
||||||
git_result = self.git_commit_and_push(slug, lang)
|
git_result = self._git_commit_and_push(payload_doc["slug"], lang)
|
||||||
else:
|
elif use_git:
|
||||||
print(f" ✓ Direct write complete (no git)")
|
print(f" ℹ️ Direct write complete (no git repo configured)")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
"success": True,
|
||||||
'slug': slug,
|
"id": doc_id,
|
||||||
'language': lang,
|
"slug": payload_doc["slug"],
|
||||||
'path': output_path,
|
"language": lang,
|
||||||
'git_result': git_result,
|
"action": action,
|
||||||
'method': 'direct_write' if not use_git else 'git_push'
|
"api_url": f"{self.api_base}/{self.collection}",
|
||||||
|
"admin_url": f"{self.website_url}/admin/collections/{self.collection}/{doc_id}",
|
||||||
|
"git_result": git_result,
|
||||||
|
"method": "api" + (" + git" if use_git else ""),
|
||||||
|
"images_uploaded": len(uploaded_images),
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {"success": False, "error": str(e)}
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
def _upload_media(self, file_path: str, token: str = None) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Upload media file to Payload CMS media collection
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the image file
|
||||||
|
token: Bearer token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Uploaded media document or None
|
||||||
|
"""
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
mime_type, _ = mimetypes.guess_type(file_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
file_data = f.read()
|
||||||
|
|
||||||
|
# Build multipart form data
|
||||||
|
boundary = "----FormBoundary7MA4YWxkTrZu0gW"
|
||||||
|
body_parts = []
|
||||||
|
|
||||||
|
# Add filename field
|
||||||
|
body_parts.append(f"--{boundary}\r\n".encode())
|
||||||
|
body_parts.append(
|
||||||
|
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode()
|
||||||
|
)
|
||||||
|
body_parts.append(
|
||||||
|
f"Content-Type: {mime_type or 'application/octet-stream'}\r\n\r\n".encode()
|
||||||
|
)
|
||||||
|
body_parts.append(file_data)
|
||||||
|
body_parts.append(b"\r\n")
|
||||||
|
|
||||||
|
# Add alt text
|
||||||
|
body_parts.append(f"--{boundary}\r\n".encode())
|
||||||
|
body_parts.append(
|
||||||
|
f'Content-Disposition: form-data; name="alt"\r\n\r\n'.encode()
|
||||||
|
)
|
||||||
|
body_parts.append(filename.encode())
|
||||||
|
body_parts.append(b"\r\n")
|
||||||
|
|
||||||
|
# Close boundary
|
||||||
|
body_parts.append(f"--{boundary}--\r\n".encode())
|
||||||
|
|
||||||
|
body = b"".join(body_parts)
|
||||||
|
|
||||||
|
url = f"{self.api_base}/media"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
}
|
}
|
||||||
|
|
||||||
def git_commit_and_push(self, slug: str, lang: str) -> Dict:
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Failed to upload {filename}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _git_commit_and_push(self, slug: str, lang: str) -> Dict:
|
||||||
"""Commit and push changes to git"""
|
"""Commit and push changes to git"""
|
||||||
|
if not self.website_repo:
|
||||||
|
return {"success": False, "error": "No git repository configured"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if git repo
|
# Check if git repo
|
||||||
if not os.path.exists(os.path.join(self.website_repo, '.git')):
|
if not os.path.exists(os.path.join(self.website_repo, ".git")):
|
||||||
return {'success': False, 'error': 'Not a git repository'}
|
return {"success": False, "error": "Not a git repository"}
|
||||||
|
|
||||||
# Git add
|
# Git add
|
||||||
subprocess.run(['git', 'add', '.'], cwd=self.website_repo, check=True, capture_output=True)
|
subprocess.run(
|
||||||
|
["git", "add", "."],
|
||||||
|
cwd=self.website_repo,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Git commit
|
# Git commit
|
||||||
message = f"Add blog post: {slug} ({lang})"
|
message = f"Add blog post: {slug} ({lang})"
|
||||||
subprocess.run(['git', 'commit', '-m', message], cwd=self.website_repo, check=True, capture_output=True)
|
result = subprocess.run(
|
||||||
|
["git", "commit", "-m", message],
|
||||||
|
cwd=self.website_repo,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
# Check if there's nothing to commit
|
||||||
|
if "nothing to commit" in result.stderr.decode():
|
||||||
|
print(f" ℹ️ Nothing to commit (no changes)")
|
||||||
|
return {"success": True, "message": "nothing to commit"}
|
||||||
|
return {"success": False, "error": result.stderr.decode()}
|
||||||
|
|
||||||
# Git push
|
# Git push
|
||||||
subprocess.run(['git', 'push'], cwd=self.website_repo, check=True, capture_output=True)
|
subprocess.run(
|
||||||
|
["git", "push"], cwd=self.website_repo, check=True, capture_output=True
|
||||||
|
)
|
||||||
|
|
||||||
print(f"✓ Committed: {message}")
|
print(f" ✓ Committed: {message}")
|
||||||
print(f"✓ Pushed to remote")
|
print(f" ✓ Pushed to remote")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
"success": True,
|
||||||
'commit_message': message,
|
"commit_message": message,
|
||||||
'triggered_deploy': True
|
"triggered_deploy": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f"✗ Git error: {e.stderr.decode() if e.stderr else str(e)}")
|
print(f" ✗ Git error: {e.stderr.decode() if e.stderr else str(e)}")
|
||||||
return {'success': False, 'error': 'Git operation failed'}
|
return {"success": False, "error": "Git operation failed"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"✗ Error: {e}")
|
print(f" ✗ Error: {e}")
|
||||||
return {'success': False, 'error': str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Test Astro publisher"""
|
"""Main entry point"""
|
||||||
parser = argparse.ArgumentParser(description='Publish to Astro')
|
parser = argparse.ArgumentParser(description="Publish to Payload CMS")
|
||||||
parser.add_argument('--file', required=True, help='Markdown file to publish')
|
parser.add_argument("--file", required=True, help="Markdown file to publish")
|
||||||
parser.add_argument('--website-repo', required=True, help='Path to website repo')
|
parser.add_argument(
|
||||||
parser.add_argument('--image', action='append', help='Image files to copy')
|
"--website-url", required=True, help="Website URL (e.g., https://example.com)"
|
||||||
parser.add_argument('--use-git', action='store_true', help='Use git commit/push (default: direct write only)')
|
)
|
||||||
|
parser.add_argument("--website-repo", help="Path to website repo (for git sync)")
|
||||||
|
parser.add_argument("--email", help="Payload CMS email for authentication")
|
||||||
|
parser.add_argument("--password", help="Payload CMS password for authentication")
|
||||||
|
parser.add_argument(
|
||||||
|
"--token", help="Payload CMS access token (alternative to email/password)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--image", action="append", help="Image files to upload")
|
||||||
|
parser.add_argument(
|
||||||
|
"--use-git", action="store_true", help="Use git commit/push (default: False)"
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
print(f"\n📝 Publishing to Astro\n")
|
print(f"\n📝 Publishing to Payload CMS\n")
|
||||||
|
|
||||||
|
# Get authentication token
|
||||||
|
payload_token = args.token
|
||||||
|
if not payload_token and args.email and args.password:
|
||||||
|
# First, try to get token via the website's login endpoint
|
||||||
|
# For now, require token directly - in future could add auto-login
|
||||||
|
print("⚠️ Token-based auth required. Use --token or set PAYLOAD_TOKEN env var.")
|
||||||
|
payload_token = os.environ.get("PAYLOAD_TOKEN")
|
||||||
|
|
||||||
|
if not payload_token:
|
||||||
|
print("❌ Error: --token required or set PAYLOAD_TOKEN environment variable")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Read markdown file
|
# Read markdown file
|
||||||
with open(args.file, 'r', encoding='utf-8') as f:
|
with open(args.file, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
# Publish (default: direct write, no git)
|
# Publish
|
||||||
publisher = AstroPublisher(args.website_repo)
|
publisher = PayloadPublisher(args.website_url, args.website_repo)
|
||||||
result = publisher.publish(content, args.image, use_git=args.use_git)
|
result = publisher.publish(
|
||||||
|
content, images=args.image, use_git=args.use_git, payload_token=payload_token
|
||||||
|
)
|
||||||
|
|
||||||
if result['success']:
|
if result["success"]:
|
||||||
print(f"\n✅ Published successfully!")
|
print(f"\n✅ Published successfully!")
|
||||||
|
print(f" ID: {result['id']}")
|
||||||
print(f" Slug: {result['slug']}")
|
print(f" Slug: {result['slug']}")
|
||||||
print(f" Language: {result['language']}")
|
print(f" Language: {result['language']}")
|
||||||
print(f" Path: {result['path']}")
|
|
||||||
print(f" Method: {result['method']}")
|
print(f" Method: {result['method']}")
|
||||||
|
print(f" Admin: {result['admin_url']}")
|
||||||
|
|
||||||
if result.get('git_result') and result['git_result'].get('success'):
|
if result.get("images_uploaded", 0) > 0:
|
||||||
|
print(f" Images: {result['images_uploaded']} uploaded")
|
||||||
|
|
||||||
|
if result.get("git_result") and result["git_result"].get("success"):
|
||||||
print(f" ✓ Committed and pushed to Gitea")
|
print(f" ✓ Committed and pushed to Gitea")
|
||||||
print(f" ✓ Deployment triggered")
|
print(f" ✓ Deployment triggered")
|
||||||
else:
|
else:
|
||||||
print(f"\n❌ Publication failed: {result.get('error')}")
|
print(f"\n❌ Publication failed: {result.get('error')}")
|
||||||
|
if result.get("status_code"):
|
||||||
|
print(f" Status code: {result['status_code']}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -160,12 +160,12 @@ output:
|
|||||||
encoding: "utf-8"
|
encoding: "utf-8"
|
||||||
line_endings: "unix"
|
line_endings: "unix"
|
||||||
|
|
||||||
astro_integration:
|
payload_cms_integration:
|
||||||
content_collection: "src/content/blog"
|
collection: "posts"
|
||||||
language_folders:
|
language_prefix:
|
||||||
thai: "(th)"
|
thai: "th/"
|
||||||
english: "(en)"
|
english: ""
|
||||||
image_folder: "public/images/blog/{slug}/"
|
image_collection: "media"
|
||||||
|
|
||||||
publishing:
|
publishing:
|
||||||
auto_publish: "optional (user_choice)"
|
auto_publish: "optional (user_choice)"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,290 +0,0 @@
|
|||||||
---
|
|
||||||
name: payload-lexical-integration
|
|
||||||
description: แนวทางการรวม Payload CMS Lexical richText content กับ design system components — อธิบายว่าทำไม design skill output กับ Payload content ถึงอยู่คนละ layer และวิธี integrate มันเข้าด้วยกัน
|
|
||||||
category: software-development
|
|
||||||
---
|
|
||||||
|
|
||||||
# Payload Lexical Integration
|
|
||||||
|
|
||||||
## ปัญหา
|
|
||||||
|
|
||||||
เวลาใช้ design skill (ui-ux-pro-max) กับ Payload CMS มักเกิดความสับสน:
|
|
||||||
|
|
||||||
- Design skill ให้โค้ดแบบไหน?
|
|
||||||
- Payload Lexical เก็บ content ยังไง?
|
|
||||||
- ทำไม content ไม่แสดงหลังสร้าง fields เสร็จ?
|
|
||||||
|
|
||||||
## สิ่งที่ต้องเข้าใจก่อน
|
|
||||||
|
|
||||||
### Two Layers — แยกกันทำ
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ DESIGN LAYER (ui-ux-pro-max, ckm:design, ckm:ui-styling)│
|
|
||||||
│ • Component structure (Hero, Card, Navbar) │
|
|
||||||
│ • Color tokens, typography, spacing │
|
|
||||||
│ • Animation specs (150-300ms, ease-out) │
|
|
||||||
│ • Layout grid, responsive breakpoints │
|
|
||||||
│ • Interaction states │
|
|
||||||
│ │
|
|
||||||
│ Output: React + Tailwind code — "ภาชนะ" ไม่ใช่ "เนื้อหา"│
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ CONTENT LAYER (Payload CMS) │
|
|
||||||
│ • ข้อความ + format (bold, italic, link) │
|
|
||||||
│ • Headings (H1-H6) │
|
|
||||||
│ • Lists, blockquotes, code blocks │
|
|
||||||
│ • Images, links │
|
|
||||||
│ • Tables │
|
|
||||||
│ │
|
|
||||||
│ Output: Lexical JSON — "เนื้อหา" ไม่ใช่ "ภาชนะ" │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Design skill สร้าง "ภาชนะ" — Payload สร้าง "เนื้อหา" — ต้องรวมกันตอน render**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ขั้นตอน
|
|
||||||
|
|
||||||
```
|
|
||||||
[1] Design Phase
|
|
||||||
ui-ux-pro-max → Component structure, tokens, animations
|
|
||||||
Output: Component skeleton (ไม่มี content)
|
|
||||||
↓
|
|
||||||
[2] Payload Phase
|
|
||||||
สร้าง Collections + richText Fields
|
|
||||||
Output: Content structure ใน Payload
|
|
||||||
↓
|
|
||||||
[3] Content Phase
|
|
||||||
พิมพ์ content ใน /admin (Lexical visual editor)
|
|
||||||
Output: Lexical JSON
|
|
||||||
↓
|
|
||||||
[4] Integration Phase
|
|
||||||
ครอบ Payload content ด้วย Design components
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1: Payload Collection
|
|
||||||
|
|
||||||
กำหนด content fields ตาม section:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/collections/Posts.ts
|
|
||||||
const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
fields: [
|
|
||||||
{ name: 'title', type: 'text', required: true },
|
|
||||||
{ name: 'slug', type: 'text', required: true },
|
|
||||||
{ name: 'heroContent', type: 'richText' }, // content สำหรับ Hero
|
|
||||||
{ name: 'features', type: 'array',
|
|
||||||
fields: [
|
|
||||||
{ name: 'heading', type: 'text' },
|
|
||||||
{ name: 'content', type: 'richText' }, // content ในแต่ละ card
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{ name: 'testimonial', type: 'richText' },
|
|
||||||
{ name: 'featuredImage', type: 'upload', relationTo: 'media' },
|
|
||||||
{ name: 'status', type: 'select', options: [...], defaultValue: 'draft' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2: สร้าง Payload Helpers
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/lib/payload-helpers.ts
|
|
||||||
import { getPayload } from 'payload'
|
|
||||||
import config from '@/payload.config'
|
|
||||||
|
|
||||||
export async function getPost(slug: string) {
|
|
||||||
const p = await getPayload({ config })
|
|
||||||
const { docs } = await p.find({
|
|
||||||
collection: 'posts',
|
|
||||||
where: { slug: { equals: slug } },
|
|
||||||
depth: 2,
|
|
||||||
})
|
|
||||||
return docs[0] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllPosts() {
|
|
||||||
const p = await getPayload({ config })
|
|
||||||
return p.find({
|
|
||||||
collection: 'posts',
|
|
||||||
where: { status: { equals: 'published' } },
|
|
||||||
depth: 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3: Integration — Design Component + RichText
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// src/app/(frontend)/posts/[slug]/page.tsx
|
|
||||||
import { getPost } from '@/lib/payload-helpers'
|
|
||||||
import { RichText } from '@payloadcms/richtext-lexical'
|
|
||||||
|
|
||||||
// Design tokens จาก ui-ux-pro-max
|
|
||||||
const tokens = {
|
|
||||||
hero: 'text-5xl md:text-7xl font-bold tracking-tight',
|
|
||||||
section: 'py-20 px-6 max-w-7xl mx-auto',
|
|
||||||
card: 'rounded-2xl border border-slate-200 p-6 shadow-sm',
|
|
||||||
animate: 'animate-fade-in duration-300 ease-out',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Design component ครอบ Payload richText
|
|
||||||
function HeroSection({ title, content }: { title: string; content: any }) {
|
|
||||||
return (
|
|
||||||
<section className={`${tokens.section} text-center`}>
|
|
||||||
<h1 className={`${tokens.hero} mb-6`}>{title}</h1>
|
|
||||||
{content && (
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
{/* Payload content → RichText → design wrapper */}
|
|
||||||
<RichText data={content} className="prose prose-lg" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeatureCard({ heading, content }: { heading: string; content: any }) {
|
|
||||||
return (
|
|
||||||
<div className={`${tokens.card} ${tokens.animate}`}>
|
|
||||||
<h3 className="text-xl font-semibold mb-3">{heading}</h3>
|
|
||||||
{content && <RichText data={content} className="prose prose-sm" />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function PostPage({ params }: { params: { slug: string } }) {
|
|
||||||
const post = await getPost(params.slug)
|
|
||||||
if (!post) return <div>Not found</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen">
|
|
||||||
<HeroSection title={post.title} content={post.heroContent} />
|
|
||||||
|
|
||||||
{post.features?.length > 0 && (
|
|
||||||
<section className={`${tokens.section} bg-slate-50`}>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{post.features.map((f: any, i: number) => (
|
|
||||||
<FeatureCard key={i} heading={f.heading} content={f.content} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Animation
|
|
||||||
|
|
||||||
Animation apply ที่ **wrapper element** ไม่ใช่ที่ content — เพราะ Lexical JSON เก็บแค่ content structure ไม่เก็บ animation metadata
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ✅ ถูก — animation ที่ wrapper
|
|
||||||
<div className="animate-hero-in">
|
|
||||||
<RichText data={post.content} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// ❌ ผิด — พยายามใส่ animation ใน Lexical JSON
|
|
||||||
```
|
|
||||||
|
|
||||||
Design skill จะให้ animation spec เป็น CSS class — แค่ apply ที่ element ที่ wrap `<RichText>`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tailwind Typography Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm add @tailwindcss/typography
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// tailwind.config.ts
|
|
||||||
plugins: [require('@tailwindcss/typography')],
|
|
||||||
```
|
|
||||||
|
|
||||||
ใช้ class `prose` กับ `<RichText>`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<RichText data={post.content} className="prose prose-lg max-w-none" />
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Payload Config: เปิด Lexical Editor
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// payload.config.ts
|
|
||||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
editor: lexicalEditor(), // ← ต้องมีถึงจะใช้ visual editor ได้
|
|
||||||
// ...
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Mistakes
|
|
||||||
|
|
||||||
### 1. Design skill ให้ hardcode content
|
|
||||||
|
|
||||||
Design skill อาจให้แบบนี้:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ❌ สิ่งที่ design skill อาจให้มา
|
|
||||||
<div className="hero">
|
|
||||||
<h1>Welcome to Our Site</h1> // hardcode
|
|
||||||
<p>Amazing content here...</p> // hardcode
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
ต้องแปลงเป็น:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ✅
|
|
||||||
<div className="hero animate-hero-in">
|
|
||||||
<h1>{post.title}</h1>
|
|
||||||
{post.heroContent && (
|
|
||||||
<RichText data={post.heroContent} className="prose" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. ลืม lexicalEditor() ใน payload.config
|
|
||||||
|
|
||||||
ถ้าไม่มี `editor: lexicalEditor()` → visual editor จะไม่ขึ้น
|
|
||||||
|
|
||||||
### 3. ลืม Tailwind typography plugin
|
|
||||||
|
|
||||||
ถ้าไม่มี `@tailwindcss/typography` → richText output จะไม่มี styling
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## สรุป: ใครทำอะไร
|
|
||||||
|
|
||||||
| Design Layer ทำ | Payload Layer ทำ | Integration ทำ |
|
|
||||||
|-----------------|------------------|----------------|
|
|
||||||
| Component structure | Content storage | ครอบ `RichText` ด้วย design component |
|
|
||||||
| Color/tokens | richText fields | Apply design tokens กับ Payload output |
|
|
||||||
| Typography system | Visual editor (/admin) | Style richText output ด้วย prose class |
|
|
||||||
| Animation specs | Content rendering | Wrap output ด้วย animation classes |
|
|
||||||
| Layout grid | SEO fields (via plugin) | Layout คงที่ + content จาก Payload |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- `website-creator` — workflow หลักในการสร้างเว็บด้วย Next.js + Payload
|
|
||||||
- `payload` — Payload CMS skill (fields, hooks, queries, plugins)
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
---
|
|
||||||
name: payload-nextjs-turbopack-fix
|
|
||||||
description: Fix Payload CMS white screen / module load errors when using Next.js 16 with Turbopack
|
|
||||||
tags: [payload, nextjs, turbopack, troubleshooting, white-screen]
|
|
||||||
category: software-development
|
|
||||||
---
|
|
||||||
|
|
||||||
# Payload CMS + Next.js 16 Turbopack White Screen Fix
|
|
||||||
|
|
||||||
## Symptom
|
|
||||||
|
|
||||||
Payload CMS admin shows white screen or "initializing" forever. Console/network tab shows:
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Failed to load external module @payloadcms/db-mongodb-XXXXXXXXXXXX
|
|
||||||
ResolveMessage: Cannot find module '@payloadcms/db-mongodb-XXXXXXXXXXXX'
|
|
||||||
```
|
|
||||||
|
|
||||||
Or server returns HTTP 500 on `/admin/create-first-user` or `/admin`.
|
|
||||||
|
|
||||||
## Root Cause
|
|
||||||
|
|
||||||
**Next.js 16 defaults to Turbopack in dev mode.** Payload CMS 3.x (specifically `@payloadcms/db-mongodb`) is NOT compatible with Turbopack's module resolution — it uses Webpack-specific module IDs that Turbopack can't resolve.
|
|
||||||
|
|
||||||
## Fix Steps
|
|
||||||
|
|
||||||
### Step 1: Verify MongoDB is running
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ss -tlnp | grep -E '27019|27017'
|
|
||||||
pgrep -a mongo
|
|
||||||
```
|
|
||||||
|
|
||||||
MongoDB must be running on the expected port. Check `.env` for `MONGODB_URL`.
|
|
||||||
|
|
||||||
### Step 2: Remove Next.js 16-only experimental options from next.config.ts
|
|
||||||
|
|
||||||
When downgrading from Next 16 → 15, remove any `experimental.turbo` config that was added for Next 16. In Next.js 15 this option doesn't exist and generates a warning:
|
|
||||||
```ts
|
|
||||||
// WRONG in Next.js 15 — 'turbo' is not a known ExperimentalConfig key
|
|
||||||
experimental: {
|
|
||||||
turbo: undefined,
|
|
||||||
},
|
|
||||||
|
|
||||||
// CORRECT — remove experimental.turbo entirely for Next.js 15
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Downgrade Next.js to 15.x (15.5.x)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/moreminimore-next
|
|
||||||
bun add next@15.5.15 react@19.0.0 react-dom@19.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Next.js 15 uses Webpack by default in dev mode, which is fully compatible with Payload CMS.
|
|
||||||
|
|
||||||
**Why not just disable Turbopack?**
|
|
||||||
- Next.js 16 has NO `--no-turbo` flag (error: unknown option)
|
|
||||||
- `NEXT_TURBOPACK=0` env var does NOT disable Turbopack in Next 16 (still starts with Turbopack)
|
|
||||||
- `experimental.turbo: undefined` in next.config.ts does NOT disable it in Next 16
|
|
||||||
- Downgrade to Next.js 15.x is the only viable option
|
|
||||||
|
|
||||||
### Step 3: Verify version
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat node_modules/next/package.json | grep '"version"'
|
|
||||||
```
|
|
||||||
|
|
||||||
Should show `15.5.x` (not `16.x`).
|
|
||||||
|
|
||||||
### Step 4: Clear cache and restart
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pkill -9 -f next 2>/dev/null
|
|
||||||
rm -rf .next
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Verify admin loads
|
|
||||||
|
|
||||||
Navigate to `http://localhost:3000/admin` — should show Payload login screen.
|
|
||||||
|
|
||||||
## Compatibility Matrix
|
|
||||||
|
|
||||||
| Next.js | Bundler | Payload CMS | Status |
|
|
||||||
|---------|---------|-------------|--------|
|
|
||||||
| 16.x | Turbopack (default) | 3.x | BROKEN |
|
|
||||||
| 16.x | Webpack (flag) | 3.x | No flag available |
|
|
||||||
| 15.5.x | Webpack (default) | 3.x | WORKS |
|
|
||||||
| 14.x | Webpack | 3.x | WORKS |
|
|
||||||
|
|
||||||
## Additional Dev Server Issues (Lessons Learned)
|
|
||||||
|
|
||||||
### Server crashes after "Ready in Xms"
|
|
||||||
|
|
||||||
Even with Next.js 15.5.15, the dev server may crash silently right after "Ready" message. Two known causes:
|
|
||||||
|
|
||||||
**1. `output: 'standalone'` in next.config.ts**
|
|
||||||
|
|
||||||
This causes Next.js to crash immediately after starting in dev mode. Remove it:
|
|
||||||
```ts
|
|
||||||
// WRONG — causes crash after "Ready" in dev mode
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
output: 'standalone', // REMOVE THIS
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORRECT — no output option in dev
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
// (no output key)
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. `NEXT_TURBOPACK=0` in dev script**
|
|
||||||
|
|
||||||
This env var can cause issues even on Next.js 15. Remove it:
|
|
||||||
```json
|
|
||||||
// WRONG
|
|
||||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation NEXT_TURBOPACK=0 next dev"
|
|
||||||
|
|
||||||
// CORRECT
|
|
||||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev"
|
|
||||||
```
|
|
||||||
|
|
||||||
Restart with clean `.next` cache after making changes:
|
|
||||||
```bash
|
|
||||||
pkill -9 -f next; sleep 1
|
|
||||||
rm -rf .next
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server starts but port 3000 shows nothing / 404
|
|
||||||
|
|
||||||
If `ss -tlnp | grep 3000` shows the port is listening but the site returns 404:
|
|
||||||
1. Check if there's a compiled `.next` cache from a previous version — always `rm -rf .next` before restarting
|
|
||||||
2. Verify MongoDB is running: `pgrep -a mongo`
|
|
||||||
3. Check server logs: `cat /tmp/moredev.log`
|
|
||||||
|
|
||||||
## Blog Posts Migration (Astro MD → Payload CMS)
|
|
||||||
|
|
||||||
Script location: `src/scripts/migrate-posts.ts`
|
|
||||||
|
|
||||||
Key approach:
|
|
||||||
- Use **absolute paths** for `configPath` and `blogDir` (avoid relative path resolution issues with ESM)
|
|
||||||
- Use **dynamic imports** for Payload config to avoid bundling issues
|
|
||||||
- Store content as plain text (strip markdown syntax with regex replacements)
|
|
||||||
- Check for existing posts by slug before creating (idempotent)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/kunthawat/moreminimore-next
|
|
||||||
npx tsx src/scripts/migrate-posts.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## What to check if still broken
|
|
||||||
|
|
||||||
1. **sharp module**: If you see `Failed to load external module sharp-XXX`, check `node_modules/sharp` exists:
|
|
||||||
```bash
|
|
||||||
ls node_modules/sharp
|
|
||||||
```
|
|
||||||
If missing: `bun add sharp`
|
|
||||||
|
|
||||||
2. **MongoDB connection**: Ensure `MONGODB_URL` in `.env` matches running mongod port
|
|
||||||
|
|
||||||
3. **Port conflict**: If port 3000 is in use:
|
|
||||||
```bash
|
|
||||||
pkill -9 -f next; pkill -9 -f bun
|
|
||||||
ss -tlnp | grep 3000
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Dev server process shows "Killed" but server is still running**:
|
|
||||||
The `bun run dev` foreground process may get killed by the shell even when the Next.js server starts successfully. Always check port 3000 directly:
|
|
||||||
```bash
|
|
||||||
ss -tlnp | grep 3000
|
|
||||||
pgrep -a next-server
|
|
||||||
```
|
|
||||||
If port 3000 is listening, the server IS running — ignore the "Killed" message.
|
|
||||||
|
|
||||||
5. **TypeScript lint errors from node_modules**: The `next lint` output shows many TS errors from `node_modules/` (e.g., `@types/react`, `next/dist/...`). These are non-blocking noise — they don't prevent the dev server from running or the admin from loading. Ignore them.
|
|
||||||
|
|
||||||
## Key Takeaway
|
|
||||||
|
|
||||||
Next.js 16 + Turbopack is incompatible with Payload CMS 3.x database adapters. Always downgrade to Next.js 15.5.x when using Payload with MongoDB adapter.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
---
|
|
||||||
name: payload-v3-admin-init
|
|
||||||
description: Create the first admin user in Payload CMS v3 via an internal API route. Solves the missing onInit hook problem.
|
|
||||||
category: devops
|
|
||||||
---
|
|
||||||
|
|
||||||
# Payload v3 — Create Admin User via API Route
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
No admin user exists in Payload CMS. Login page at `/admin` shows email/password form but no user was created on first boot.
|
|
||||||
|
|
||||||
## Key Finding: No `onInit` Hook in Payload v3
|
|
||||||
Payload v3 `buildConfig()` does NOT have an `onInit` hook. The v2 pattern `hooks: { init: [...] }` does not exist. Adding it causes TypeScript errors.
|
|
||||||
|
|
||||||
## Solution: Create Admin via API Route
|
|
||||||
|
|
||||||
**File:** `src/app/api/create-admin/route.ts`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { getPayload } from 'payload'
|
|
||||||
import config from '@/payload.config'
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
const p = await getPayload({ config })
|
|
||||||
|
|
||||||
const existing = await p.find({ collection: 'users', limit: 1 })
|
|
||||||
if (existing.totalDocs > 0) {
|
|
||||||
return NextResponse.json({ message: 'Admin already exists', email: existing.docs[0].email })
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await p.create({
|
|
||||||
collection: 'users',
|
|
||||||
data: {
|
|
||||||
email: 'admin@dealplustech.co.th',
|
|
||||||
password: 'DealPlus2026!',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, email: result.email })
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: err.message }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then call:
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3001/api/create-admin
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Errors
|
|
||||||
|
|
||||||
| Error | Cause | Fix |
|
|
||||||
|-------|-------|-----|
|
|
||||||
| `the payload config is required for getPayload to work` | Used `getPayload({ mongoURL })` instead of `getPayload({ config })` | Pass `config` import |
|
|
||||||
| `GET /api/users` returns 403 | Auth required — cannot list users without being logged in | Use internal API route instead |
|
|
||||||
| `onInit` in `buildConfig()` TypeScript error | Hook doesn't exist in v3 | Remove it, use API route |
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
After creating, visit `/admin` and login with the credentials set in the API route.
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
---
|
|
||||||
name: payload
|
|
||||||
description: Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Payload CMS Application Development
|
|
||||||
|
|
||||||
Payload is a Next.js native CMS with TypeScript-first architecture, providing admin panel, database management, REST/GraphQL APIs, authentication, and file storage.
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
| Task | Solution | Details |
|
|
||||||
| ------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Auto-generate slugs | `slugField()` | [FIELDS.md#slug-field-helper](reference/FIELDS.md#slug-field-helper) |
|
|
||||||
| Restrict content by user | Access control with query | [ACCESS-CONTROL.md#row-level-security-with-complex-queries](reference/ACCESS-CONTROL.md#row-level-security-with-complex-queries) |
|
|
||||||
| Local API user ops | `user` + `overrideAccess: false` | [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api) |
|
|
||||||
| Draft/publish workflow | `versions: { drafts: true }` | [COLLECTIONS.md#versioning--drafts](reference/COLLECTIONS.md#versioning--drafts) |
|
|
||||||
| Computed fields | `virtual: true` with afterRead | [FIELDS.md#virtual-fields](reference/FIELDS.md#virtual-fields) |
|
|
||||||
| Conditional fields | `admin.condition` | [FIELDS.md#conditional-fields](reference/FIELDS.md#conditional-fields) |
|
|
||||||
| Custom field validation | `validate` function | [FIELDS.md#validation](reference/FIELDS.md#validation) |
|
|
||||||
| Filter relationship list | `filterOptions` on field | [FIELDS.md#relationship](reference/FIELDS.md#relationship) |
|
|
||||||
| Select specific fields | `select` parameter | [QUERIES.md#field-selection](reference/QUERIES.md#field-selection) |
|
|
||||||
| Auto-set author/dates | beforeChange hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
|
|
||||||
| Prevent hook loops | `req.context` check | [HOOKS.md#context](reference/HOOKS.md#context) |
|
|
||||||
| Cascading deletes | beforeDelete hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
|
|
||||||
| Geospatial queries | `point` field with `near`/`within` | [FIELDS.md#point-geolocation](reference/FIELDS.md#point-geolocation) |
|
|
||||||
| Reverse relationships | `join` field type | [FIELDS.md#join-fields](reference/FIELDS.md#join-fields) |
|
|
||||||
| Next.js revalidation | Context control in afterChange | [HOOKS.md#nextjs-revalidation-with-context-control](reference/HOOKS.md#nextjs-revalidation-with-context-control) |
|
|
||||||
| Query by relationship | Nested property syntax | [QUERIES.md#nested-properties](reference/QUERIES.md#nested-properties) |
|
|
||||||
| Complex queries | AND/OR logic | [QUERIES.md#andor-logic](reference/QUERIES.md#andor-logic) |
|
|
||||||
| Transactions | Pass `req` to operations | [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations) |
|
|
||||||
| Background jobs | Jobs queue with tasks | [ADVANCED.md#jobs-queue](reference/ADVANCED.md#jobs-queue) |
|
|
||||||
| Custom API routes | Collection custom endpoints | [ADVANCED.md#custom-endpoints](reference/ADVANCED.md#custom-endpoints) |
|
|
||||||
| Cloud storage | Storage adapter plugins | [ADAPTERS.md#storage-adapters](reference/ADAPTERS.md#storage-adapters) |
|
|
||||||
| Multi-language | `localization` config + `localized: true` | [ADVANCED.md#localization](reference/ADVANCED.md#localization) |
|
|
||||||
| Create plugin | `(options) => (config) => Config` | [PLUGIN-DEVELOPMENT.md#plugin-architecture](reference/PLUGIN-DEVELOPMENT.md#plugin-architecture) |
|
|
||||||
| Plugin package setup | Package structure with SWC | [PLUGIN-DEVELOPMENT.md#plugin-package-structure](reference/PLUGIN-DEVELOPMENT.md#plugin-package-structure) |
|
|
||||||
| Add fields to collection | Map collections, spread fields | [PLUGIN-DEVELOPMENT.md#adding-fields-to-collections](reference/PLUGIN-DEVELOPMENT.md#adding-fields-to-collections) |
|
|
||||||
| Plugin hooks | Preserve existing hooks in array | [PLUGIN-DEVELOPMENT.md#adding-hooks](reference/PLUGIN-DEVELOPMENT.md#adding-hooks) |
|
|
||||||
| Check field type | Type guard functions | [FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md) |
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx create-payload-app@latest my-app
|
|
||||||
cd my-app
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Minimal Config
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Essential Patterns
|
|
||||||
|
|
||||||
### Basic Collection
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For more collection patterns (auth, upload, drafts, live preview), see [COLLECTIONS.md](reference/COLLECTIONS.md).
|
|
||||||
|
|
||||||
### Common Fields
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Text field
|
|
||||||
{ name: 'title', type: 'text', required: true }
|
|
||||||
|
|
||||||
// Relationship
|
|
||||||
{ name: 'author', type: 'relationship', relationTo: 'users', required: true }
|
|
||||||
|
|
||||||
// Rich text
|
|
||||||
{ name: 'content', type: 'richText', required: true }
|
|
||||||
|
|
||||||
// Select
|
|
||||||
{ name: 'status', type: 'select', options: ['draft', 'published'], defaultValue: 'draft' }
|
|
||||||
|
|
||||||
// Upload
|
|
||||||
{ name: 'image', type: 'upload', relationTo: 'media' }
|
|
||||||
```
|
|
||||||
|
|
||||||
For all field types (array, blocks, point, join, virtual, conditional, etc.), see [FIELDS.md](reference/FIELDS.md).
|
|
||||||
|
|
||||||
### Hook Example
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
hooks: {
|
|
||||||
beforeChange: [
|
|
||||||
async ({ data, operation }) => {
|
|
||||||
if (operation === 'create') {
|
|
||||||
data.slug = slugify(data.title)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fields: [{ name: 'title', type: 'text' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For all hook patterns, see [HOOKS.md](reference/HOOKS.md). For access control, see [ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md).
|
|
||||||
|
|
||||||
### Access Control with Type Safety
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
import type { User } from '@/payload-types'
|
|
||||||
|
|
||||||
// Type-safe access control
|
|
||||||
export const adminOnly: Access = ({ req }) => {
|
|
||||||
const user = req.user as User
|
|
||||||
return user?.roles?.includes('admin') || false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row-level access control
|
|
||||||
export const ownPostsOnly: Access = ({ req }) => {
|
|
||||||
const user = req.user as User
|
|
||||||
if (!user) return false
|
|
||||||
if (user.roles?.includes('admin')) return true
|
|
||||||
|
|
||||||
return {
|
|
||||||
author: { equals: user.id },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Query Example
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Local API
|
|
||||||
const posts = await payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
where: {
|
|
||||||
status: { equals: 'published' },
|
|
||||||
'author.name': { contains: 'john' },
|
|
||||||
},
|
|
||||||
depth: 2,
|
|
||||||
limit: 10,
|
|
||||||
sort: '-createdAt',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Query with populated relationships
|
|
||||||
const post = await payload.findByID({
|
|
||||||
collection: 'posts',
|
|
||||||
id: '123',
|
|
||||||
depth: 2, // Populates relationships (default is 2)
|
|
||||||
})
|
|
||||||
// Returns: { author: { id: "user123", name: "John" } }
|
|
||||||
|
|
||||||
// Without depth, relationships return IDs only
|
|
||||||
const post = await payload.findByID({
|
|
||||||
collection: 'posts',
|
|
||||||
id: '123',
|
|
||||||
depth: 0,
|
|
||||||
})
|
|
||||||
// Returns: { author: "user123" }
|
|
||||||
```
|
|
||||||
|
|
||||||
For all query operators and REST/GraphQL examples, see [QUERIES.md](reference/QUERIES.md).
|
|
||||||
|
|
||||||
### Getting Payload Instance
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// 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>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Pitfalls
|
|
||||||
|
|
||||||
### 1. Local API Access Control (CRITICAL)
|
|
||||||
|
|
||||||
**By default, Local API operations bypass ALL access control**, even when passing a user.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ❌ 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
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**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)
|
|
||||||
|
|
||||||
See [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api).
|
|
||||||
|
|
||||||
### 2. Transaction Failures in Hooks
|
|
||||||
|
|
||||||
**Nested operations in hooks without `req` break transaction atomicity.**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ❌ 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
|
|
||||||
})
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations).
|
|
||||||
|
|
||||||
### 3. Infinite Hook Loops
|
|
||||||
|
|
||||||
**Hooks triggering operations that trigger the same hooks create infinite loops.**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ❌ 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,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See [HOOKS.md#context](reference/HOOKS.md#context).
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```txt
|
|
||||||
src/
|
|
||||||
├── app/
|
|
||||||
│ ├── (frontend)/
|
|
||||||
│ │ └── page.tsx
|
|
||||||
│ └── (payload)/
|
|
||||||
│ └── admin/[[...segments]]/page.tsx
|
|
||||||
├── collections/
|
|
||||||
│ ├── Posts.ts
|
|
||||||
│ ├── Media.ts
|
|
||||||
│ └── Users.ts
|
|
||||||
├── globals/
|
|
||||||
│ └── Header.ts
|
|
||||||
├── components/
|
|
||||||
│ └── CustomField.tsx
|
|
||||||
├── hooks/
|
|
||||||
│ └── slugify.ts
|
|
||||||
└── payload.config.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type Generation
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// payload.config.ts
|
|
||||||
export default buildConfig({
|
|
||||||
typescript: {
|
|
||||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
|
||||||
},
|
|
||||||
// ...
|
|
||||||
})
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
import type { Post, User } from '@/payload-types'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Gotchas
|
|
||||||
|
|
||||||
1. **Local API bypasses access control** unless you pass `overrideAccess: false`
|
|
||||||
2. **Missing `req` in nested operations** breaks transaction atomicity
|
|
||||||
3. **Hook loops** — operations in hooks can re-trigger the same hooks; use `req.context` flags
|
|
||||||
4. **Field-level access** returns boolean only, no query constraints
|
|
||||||
5. **Relationship depth** defaults to 2; set `depth: 0` for IDs only
|
|
||||||
6. **Draft status** — `_status` field is auto-injected when drafts are enabled
|
|
||||||
7. **Types are stale** until you run `generate:types`
|
|
||||||
8. **MongoDB transactions** require replica set configuration
|
|
||||||
9. **SQLite transactions** are disabled by default; enable with `transactionOptions: {}`
|
|
||||||
10. **Point fields** are not supported in SQLite
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Default to restrictive access, gradually add permissions
|
|
||||||
- Use `overrideAccess: false` when passing `user` to Local API
|
|
||||||
- Field-level access only returns boolean (no query constraints)
|
|
||||||
- Never trust client-provided data
|
|
||||||
- Use `saveToJWT: true` for roles to avoid database lookups
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- Index frequently queried fields
|
|
||||||
- Use `select` to limit returned fields
|
|
||||||
- Set `maxDepth` on relationships to prevent over-fetching
|
|
||||||
- Prefer query constraints over async operations in access control
|
|
||||||
- Cache expensive operations in `req.context`
|
|
||||||
|
|
||||||
### Data Integrity
|
|
||||||
|
|
||||||
- Always pass `req` to nested operations in hooks
|
|
||||||
- Use context flags to prevent infinite hook loops
|
|
||||||
- Enable transactions for MongoDB (requires replica set) and Postgres
|
|
||||||
- Use `beforeValidate` for data formatting
|
|
||||||
- Use `beforeChange` for business logic
|
|
||||||
|
|
||||||
### Type Safety
|
|
||||||
|
|
||||||
- Run `generate:types` after schema changes
|
|
||||||
- Import types from generated `payload-types.ts`
|
|
||||||
- Type your user object: `import type { User } from '@/payload-types'`
|
|
||||||
- Use `as const` for field options
|
|
||||||
- Use field type guards for runtime type checking
|
|
||||||
|
|
||||||
### Organization
|
|
||||||
|
|
||||||
- Keep collections in separate files
|
|
||||||
- Extract access control to `access/` directory
|
|
||||||
- Extract hooks to `hooks/` directory
|
|
||||||
- Use reusable field factories for common patterns
|
|
||||||
- Document complex access control with comments
|
|
||||||
|
|
||||||
## Reference Documentation
|
|
||||||
|
|
||||||
- **[FIELDS.md](reference/FIELDS.md)** - All field types, validation, admin options
|
|
||||||
- **[FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md)** - Type guards for runtime field type checking and narrowing
|
|
||||||
- **[COLLECTIONS.md](reference/COLLECTIONS.md)** - Collection configs, auth, upload, drafts, live preview
|
|
||||||
- **[HOOKS.md](reference/HOOKS.md)** - Collection hooks, field hooks, context patterns
|
|
||||||
- **[ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md)** - Collection, field, global access control, RBAC, multi-tenant
|
|
||||||
- **[ACCESS-CONTROL-ADVANCED.md](reference/ACCESS-CONTROL-ADVANCED.md)** - Context-aware, time-based, subscription-based access, factory functions, templates
|
|
||||||
- **[QUERIES.md](reference/QUERIES.md)** - Query operators, Local/REST/GraphQL APIs
|
|
||||||
- **[ENDPOINTS.md](reference/ENDPOINTS.md)** - Custom API endpoints: authentication, helpers, request/response patterns
|
|
||||||
- **[ADAPTERS.md](reference/ADAPTERS.md)** - Database, storage, email adapters, transactions
|
|
||||||
- **[ADVANCED.md](reference/ADVANCED.md)** - Authentication, jobs, endpoints, components, plugins, localization
|
|
||||||
- **[PLUGIN-DEVELOPMENT.md](reference/PLUGIN-DEVELOPMENT.md)** - Plugin architecture, monorepo structure, patterns, best practices
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- llms-full.txt: <https://payloadcms.com/llms-full.txt>
|
|
||||||
- Docs: <https://payloadcms.com/docs>
|
|
||||||
- GitHub: <https://github.com/payloadcms/payload>
|
|
||||||
- Examples: <https://github.com/payloadcms/payload/tree/main/examples>
|
|
||||||
- Templates: <https://github.com/payloadcms/payload/tree/main/templates>
|
|
||||||
@@ -1,704 +0,0 @@
|
|||||||
# Payload CMS Access Control - Advanced Patterns
|
|
||||||
|
|
||||||
Advanced access control patterns including context-aware access, time-based restrictions, factory functions, and production templates.
|
|
||||||
|
|
||||||
## Context-Aware Access Patterns
|
|
||||||
|
|
||||||
### Locale-Specific Access
|
|
||||||
|
|
||||||
Control access based on user locale for internationalized content.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage in collection
|
|
||||||
export const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
access: {
|
|
||||||
read: localeSpecificAccess,
|
|
||||||
},
|
|
||||||
fields: [{ name: 'title', type: 'text', localized: true }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: `docs/access-control/overview.mdx` (req.locale argument)
|
|
||||||
|
|
||||||
### Device-Specific Access
|
|
||||||
|
|
||||||
Restrict access based on device type or user agent.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
export const MobileContent: CollectionConfig = {
|
|
||||||
slug: 'mobile-content',
|
|
||||||
access: {
|
|
||||||
read: mobileOnlyAccess,
|
|
||||||
},
|
|
||||||
fields: [{ name: 'title', type: 'text' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: Synthesized (headers pattern)
|
|
||||||
|
|
||||||
### IP-Based Access
|
|
||||||
|
|
||||||
Restrict access from specific IP addresses (requires middleware/proxy headers).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
fields: [{ name: 'content', type: 'richText' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: Requires your server to pass IP address via headers (common with proxies/load balancers).
|
|
||||||
|
|
||||||
**Source**: Synthesized (headers pattern)
|
|
||||||
|
|
||||||
## Time-Based Access Patterns
|
|
||||||
|
|
||||||
### Today's Records Only
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
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(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: `test/access-control/config.ts` (query constraint patterns)
|
|
||||||
|
|
||||||
### Recent Records (Last N Days)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
fields: [{ name: 'message', type: 'text' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scheduled Content (Publish Date Range)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
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 } }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: Synthesized (query constraint + date patterns)
|
|
||||||
|
|
||||||
## Subscription-Based Access
|
|
||||||
|
|
||||||
### Active Subscription Required
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
export const PremiumContent: CollectionConfig = {
|
|
||||||
slug: 'premium-content',
|
|
||||||
access: {
|
|
||||||
read: activeSubscriptionAccess,
|
|
||||||
},
|
|
||||||
fields: [{ name: 'title', type: 'text' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Subscription Tier-Based Access
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
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'),
|
|
||||||
},
|
|
||||||
fields: [{ name: 'feature', type: 'text' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: Synthesized (async + cross-collection pattern)
|
|
||||||
|
|
||||||
## Factory Functions
|
|
||||||
|
|
||||||
Reusable functions that generate access control configurations.
|
|
||||||
|
|
||||||
### createRoleBasedAccess
|
|
||||||
|
|
||||||
Generate access control for specific roles.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
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'])
|
|
||||||
|
|
||||||
export const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
access: {
|
|
||||||
create: adminOrEditor,
|
|
||||||
update: adminOrEditor,
|
|
||||||
delete: moderatorAccess,
|
|
||||||
},
|
|
||||||
fields: [{ name: 'title', type: 'text' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: `test/access-control/config.ts`
|
|
||||||
|
|
||||||
### createOrgScopedAccess
|
|
||||||
|
|
||||||
Generate organization-scoped access with optional admin bypass.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
export const Projects: CollectionConfig = {
|
|
||||||
slug: 'projects',
|
|
||||||
access: {
|
|
||||||
read: orgScoped,
|
|
||||||
update: orgScoped,
|
|
||||||
delete: strictOrgScoped,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'title', type: 'text' },
|
|
||||||
{ name: 'organizationId', type: 'text', required: true },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: `test/access-control/config.ts`
|
|
||||||
|
|
||||||
### createTeamBasedAccess
|
|
||||||
|
|
||||||
Generate team-scoped access with configurable field name.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
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 || [] },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage with custom field name
|
|
||||||
const projectTeamAccess = createTeamBasedAccess('projectTeam')
|
|
||||||
|
|
||||||
export const Tasks: CollectionConfig = {
|
|
||||||
slug: 'tasks',
|
|
||||||
access: {
|
|
||||||
read: projectTeamAccess,
|
|
||||||
update: projectTeamAccess,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'title', type: 'text' },
|
|
||||||
{ name: 'projectTeam', type: 'text', required: true },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: Synthesized (org pattern variation)
|
|
||||||
|
|
||||||
### createTimeLimitedAccess
|
|
||||||
|
|
||||||
Generate access limited to records within specified days.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
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(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage: Users see 90 days, admins see all
|
|
||||||
export const ActivityLogs: CollectionConfig = {
|
|
||||||
slug: 'activity-logs',
|
|
||||||
access: {
|
|
||||||
read: createTimeLimitedAccess(90),
|
|
||||||
},
|
|
||||||
fields: [{ name: 'action', type: 'text' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: Synthesized (time + query pattern)
|
|
||||||
|
|
||||||
## Configuration Templates
|
|
||||||
|
|
||||||
Complete collection configurations for common scenarios.
|
|
||||||
|
|
||||||
### Basic Authenticated Collection
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const BasicCollection: CollectionConfig = {
|
|
||||||
slug: 'basic-collection',
|
|
||||||
access: {
|
|
||||||
create: ({ req: { user } }) => Boolean(user),
|
|
||||||
read: ({ req: { user } }) => Boolean(user),
|
|
||||||
update: ({ req: { user } }) => Boolean(user),
|
|
||||||
delete: ({ req: { user } }) => Boolean(user),
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'title', type: 'text', required: true },
|
|
||||||
{ name: 'content', type: 'richText' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: `docs/access-control/collections.mdx`
|
|
||||||
|
|
||||||
### Public + Authenticated Collection
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
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' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: `templates/website/src/collections/Posts/index.ts`
|
|
||||||
|
|
||||||
### Multi-User/Self-Service Collection
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: `templates/website/src/collections/Users/index.ts`
|
|
||||||
|
|
||||||
## Debugging Tips
|
|
||||||
|
|
||||||
### Log Access Check Execution
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Measure Async Operation Timing
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const timedAsyncAccess: Access = async ({ req }) => {
|
|
||||||
const start = Date.now()
|
|
||||||
|
|
||||||
const result = await fetch('https://auth-service.example.com/validate', {
|
|
||||||
headers: { userId: req.user?.id },
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`Access check took ${Date.now() - start}ms`)
|
|
||||||
|
|
||||||
return result.ok
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Access Without User
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// 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)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Source**: Synthesized (debugging best practices)
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Async Operations Impact
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ❌ 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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ❌ 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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ❌ Slow: Complex access on array fields
|
|
||||||
const arrayField: ArrayField = {
|
|
||||||
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
|
|
||||||
const optimizedArrayField: ArrayField = {
|
|
||||||
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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ❌ 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 } }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Performance Best Practices:**
|
|
||||||
|
|
||||||
1. **Minimize Async Operations**: Use query constraints over async lookups when possible
|
|
||||||
2. **Cache Expensive Checks**: Store results in `req.context` for reuse
|
|
||||||
3. **Index Query Fields**: Ensure fields in query constraints are indexed
|
|
||||||
4. **Avoid Complex Logic in Array Fields**: Simple boolean checks preferred
|
|
||||||
5. **Use Query Constraints**: Let database filter rather than loading all records
|
|
||||||
|
|
||||||
**Source**: Synthesized (operational best practices)
|
|
||||||
|
|
||||||
## Enhanced Best Practices
|
|
||||||
|
|
||||||
Comprehensive security and implementation guidelines:
|
|
||||||
|
|
||||||
1. **Default Deny**: Start with restrictive access, gradually add permissions
|
|
||||||
2. **Type Guards**: Use TypeScript for user type safety and better IDE support
|
|
||||||
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. **Rate Limit External Calls**: Protect against DoS on external validation services
|
|
||||||
13. **Handle Errors Gracefully**: Access functions should return `false` on error, not throw
|
|
||||||
14. **Use Environment Vars**: Store configuration (IPs, API keys) in env vars
|
|
||||||
15. **Test Local API**: Remember to set `overrideAccess: false` when testing
|
|
||||||
16. **Consider Performance**: Measure impact of async operations on login time
|
|
||||||
17. **Version Control**: Track access control changes in git history
|
|
||||||
18. **Principle of Least Privilege**: Grant minimum access required for functionality
|
|
||||||
|
|
||||||
**Sources**: `docs/access-control/*.mdx`, synthesized best practices
|
|
||||||
@@ -1,697 +0,0 @@
|
|||||||
# Payload CMS Access Control Reference
|
|
||||||
|
|
||||||
Complete reference for access control patterns across collections, fields, and globals.
|
|
||||||
|
|
||||||
## At a Glance
|
|
||||||
|
|
||||||
| Feature | Scope | Returns | Use Case |
|
|
||||||
| --------------------- | --------------------------------------------------------- | ---------------------- | ---------------------------------- |
|
|
||||||
| **Collection Access** | create, read, update, delete, admin, unlock, readVersions | boolean \| Where query | Document-level permissions |
|
|
||||||
| **Field Access** | create, read, update | boolean only | Field-level visibility/editability |
|
|
||||||
| **Global Access** | read, update, readVersions | boolean \| Where query | Global document permissions |
|
|
||||||
|
|
||||||
## Three Layers of Access Control
|
|
||||||
|
|
||||||
Payload provides three distinct access control layers:
|
|
||||||
|
|
||||||
1. **Collection-Level**: Controls operations on entire documents (create, read, update, delete, admin, unlock, readVersions)
|
|
||||||
2. **Field-Level**: Controls access to individual fields (create, read, update)
|
|
||||||
3. **Global-Level**: Controls access to global documents (read, update, readVersions)
|
|
||||||
|
|
||||||
## Return Value Types
|
|
||||||
|
|
||||||
Access control functions can return:
|
|
||||||
|
|
||||||
- **Boolean**: `true` (allow) or `false` (deny)
|
|
||||||
- **Query Constraint**: `Where` object for row-level security (collection-level only)
|
|
||||||
|
|
||||||
Field-level access does NOT support query constraints - only boolean returns.
|
|
||||||
|
|
||||||
## Operation Decision Tree
|
|
||||||
|
|
||||||
```txt
|
|
||||||
User makes request
|
|
||||||
│
|
|
||||||
├─ Collection access check
|
|
||||||
│ ├─ Returns false? → Deny entire operation
|
|
||||||
│ ├─ Returns true? → Continue
|
|
||||||
│ └─ Returns Where? → Apply query constraint
|
|
||||||
│
|
|
||||||
├─ Field access check (if applicable)
|
|
||||||
│ ├─ Returns false? → Field omitted from result
|
|
||||||
│ └─ Returns true? → Include field
|
|
||||||
│
|
|
||||||
└─ Operation completed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Collection Access Control
|
|
||||||
|
|
||||||
### Basic Patterns
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig, 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
|
|
||||||
},
|
|
||||||
|
|
||||||
// Admin panel visibility
|
|
||||||
admin: ({ req: { user } }) => {
|
|
||||||
return user?.roles?.includes('admin') || user?.roles?.includes('editor')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'title', type: 'text' },
|
|
||||||
{ name: 'status', type: 'select', options: ['draft', 'published'] },
|
|
||||||
{ name: 'author', type: 'relationship', relationTo: 'users' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Role-Based Access Control (RBAC) Pattern
|
|
||||||
|
|
||||||
Payload does NOT provide a roles system by default. The following is a commonly accepted pattern for implementing role-based access control in auth collections:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const Users: CollectionConfig = {
|
|
||||||
slug: 'users',
|
|
||||||
auth: true,
|
|
||||||
fields: [
|
|
||||||
{ name: 'name', type: 'text', required: true },
|
|
||||||
{ name: 'email', type: 'email', required: true },
|
|
||||||
{
|
|
||||||
name: 'roles',
|
|
||||||
type: 'select',
|
|
||||||
hasMany: true,
|
|
||||||
options: ['admin', 'editor', 'user'],
|
|
||||||
defaultValue: ['user'],
|
|
||||||
required: true,
|
|
||||||
// Save roles to JWT for access control without database lookups
|
|
||||||
saveToJWT: true,
|
|
||||||
access: {
|
|
||||||
// Only admins can update roles
|
|
||||||
update: ({ req: { user } }) => user?.roles?.includes('admin'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important Notes:**
|
|
||||||
|
|
||||||
1. **Not Built-In**: Payload does not provide a roles system out of the box. You must add a `roles` field to your auth collection.
|
|
||||||
2. **Save to JWT**: Use `saveToJWT: true` to include roles in the JWT token, enabling role checks without database queries.
|
|
||||||
3. **Default Value**: Set a `defaultValue` to automatically assign new users a default role.
|
|
||||||
4. **Access Control**: Restrict who can modify roles (typically only admins).
|
|
||||||
5. **Role Options**: Define your own role hierarchy based on your application needs.
|
|
||||||
|
|
||||||
**Using Roles in Access Control:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
// Check for specific role
|
|
||||||
export const adminOnly: Access = ({ req: { user } }) => {
|
|
||||||
return user?.roles?.includes('admin')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for multiple roles
|
|
||||||
export const adminOrEditor: Access = ({ req: { user } }) => {
|
|
||||||
return Boolean(user?.roles?.some((role) => ['admin', 'editor'].includes(role)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role hierarchy check
|
|
||||||
export const hasMinimumRole: Access = ({ req: { user } }, minRole: string) => {
|
|
||||||
const roleHierarchy = ['user', 'editor', 'admin']
|
|
||||||
const userHighestRole = Math.max(...(user?.roles?.map((r) => roleHierarchy.indexOf(r)) || [-1]))
|
|
||||||
const requiredRoleIndex = roleHierarchy.indexOf(minRole)
|
|
||||||
|
|
||||||
return userHighestRole >= requiredRoleIndex
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reusable Access Functions
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
// Anyone (public)
|
|
||||||
export const anyone: Access = () => true
|
|
||||||
|
|
||||||
// Authenticated only
|
|
||||||
export const authenticated: Access = ({ req: { user } }) => Boolean(user)
|
|
||||||
|
|
||||||
// Authenticated or published content
|
|
||||||
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
|
|
||||||
if (user) return true
|
|
||||||
return { _status: { equals: 'published' } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin only
|
|
||||||
export const admins: Access = ({ req: { user } }) => {
|
|
||||||
return user?.roles?.includes('admin')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin or editor
|
|
||||||
export const adminsOrEditors: Access = ({ req: { user } }) => {
|
|
||||||
return Boolean(user?.roles?.some((role) => ['admin', 'editor'].includes(role)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self or admin
|
|
||||||
export const adminsOrSelf: Access = ({ req: { user } }) => {
|
|
||||||
if (user?.roles?.includes('admin')) return true
|
|
||||||
return { id: { equals: user?.id } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
export const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
access: {
|
|
||||||
create: authenticated,
|
|
||||||
read: authenticatedOrPublished,
|
|
||||||
update: adminsOrEditors,
|
|
||||||
delete: admins,
|
|
||||||
},
|
|
||||||
fields: [{ name: 'title', type: 'text' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Row-Level Security with Complex Queries
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple conditions with AND
|
|
||||||
export const complexAccess: Access = ({ req: { user } }) => {
|
|
||||||
return {
|
|
||||||
and: [
|
|
||||||
{ status: { equals: 'published' } },
|
|
||||||
{ 'author.isActive': { equals: true } },
|
|
||||||
{
|
|
||||||
or: [{ visibility: { equals: 'public' } }, { author: { equals: user?.id } }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Header-Based Access (API Keys)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
export const apiKeyAccess: Access = ({ req }) => {
|
|
||||||
const apiKey = req.headers.get('x-api-key')
|
|
||||||
|
|
||||||
if (!apiKey) return false
|
|
||||||
|
|
||||||
// Validate against stored keys
|
|
||||||
return apiKey === process.env.VALID_API_KEY
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bearer token validation
|
|
||||||
export const bearerTokenAccess: Access = async ({ req }) => {
|
|
||||||
const auth = req.headers.get('authorization')
|
|
||||||
|
|
||||||
if (!auth?.startsWith('Bearer ')) return false
|
|
||||||
|
|
||||||
const token = auth.slice(7)
|
|
||||||
const isValid = await validateToken(token)
|
|
||||||
|
|
||||||
return isValid
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Field Access Control
|
|
||||||
|
|
||||||
Field access does NOT support query constraints - only boolean returns.
|
|
||||||
|
|
||||||
### Basic Field Access
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { NumberField, FieldAccess } from 'payload'
|
|
||||||
|
|
||||||
const salaryReadAccess: FieldAccess = ({ req: { user }, doc }) => {
|
|
||||||
// Self can read own salary
|
|
||||||
if (user?.id === doc?.id) return true
|
|
||||||
// Admin can read all salaries
|
|
||||||
return user?.roles?.includes('admin')
|
|
||||||
}
|
|
||||||
|
|
||||||
const salaryUpdateAccess: FieldAccess = ({ req: { user } }) => {
|
|
||||||
// Only admins can update salary
|
|
||||||
return user?.roles?.includes('admin')
|
|
||||||
}
|
|
||||||
|
|
||||||
const salaryField: NumberField = {
|
|
||||||
name: 'salary',
|
|
||||||
type: 'number',
|
|
||||||
access: {
|
|
||||||
read: salaryReadAccess,
|
|
||||||
update: salaryUpdateAccess,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sibling Data Access
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { ArrayField, FieldAccess } from 'payload'
|
|
||||||
|
|
||||||
const contentReadAccess: FieldAccess = ({ req: { user }, siblingData }) => {
|
|
||||||
// Authenticated users see all
|
|
||||||
if (user) return true
|
|
||||||
// Public sees only if marked public
|
|
||||||
return siblingData?.isPublic === true
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayField: ArrayField = {
|
|
||||||
name: 'sections',
|
|
||||||
type: 'array',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'isPublic',
|
|
||||||
type: 'checkbox',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'content',
|
|
||||||
type: 'text',
|
|
||||||
access: {
|
|
||||||
read: contentReadAccess,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Nested Field Access
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { GroupField, FieldAccess } from 'payload'
|
|
||||||
|
|
||||||
const internalOnlyAccess: FieldAccess = ({ req: { user } }) => {
|
|
||||||
return user?.roles?.includes('admin') || user?.roles?.includes('internal')
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupField: GroupField = {
|
|
||||||
name: 'internalMetadata',
|
|
||||||
type: 'group',
|
|
||||||
access: {
|
|
||||||
read: internalOnlyAccess,
|
|
||||||
update: internalOnlyAccess,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'internalNotes', type: 'textarea' },
|
|
||||||
{ name: 'priority', type: 'select', options: ['low', 'medium', 'high'] },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hiding Admin Fields
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const Users: CollectionConfig = {
|
|
||||||
slug: 'users',
|
|
||||||
auth: true,
|
|
||||||
fields: [
|
|
||||||
{ name: 'name', type: 'text', required: true },
|
|
||||||
{ name: 'email', type: 'email', required: true },
|
|
||||||
{
|
|
||||||
name: 'roles',
|
|
||||||
type: 'select',
|
|
||||||
hasMany: true,
|
|
||||||
options: ['admin', 'editor', 'user'],
|
|
||||||
access: {
|
|
||||||
// Hide from UI, but still saved/queried
|
|
||||||
read: ({ req: { user } }) => user?.roles?.includes('admin'),
|
|
||||||
// Only admins can update roles
|
|
||||||
update: ({ req: { user } }) => user?.roles?.includes('admin'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Global Access Control
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { GlobalConfig, Access } from 'payload'
|
|
||||||
|
|
||||||
const adminOnly: Access = ({ req: { user } }) => {
|
|
||||||
return user?.roles?.includes('admin')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SiteSettings: GlobalConfig = {
|
|
||||||
slug: 'site-settings',
|
|
||||||
access: {
|
|
||||||
read: () => true, // Anyone can read settings
|
|
||||||
update: adminOnly, // Only admins can update
|
|
||||||
readVersions: adminOnly, // Only admins can see version history
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'siteName', type: 'text' },
|
|
||||||
{ name: 'maintenanceMode', type: 'checkbox' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Multi-Tenant Access Control
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access, CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
// Add tenant field to user type
|
|
||||||
interface User {
|
|
||||||
id: string
|
|
||||||
tenantId: string
|
|
||||||
roles?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tenant-scoped access
|
|
||||||
const tenantAccess: Access = ({ req: { user } }) => {
|
|
||||||
// No user = no access
|
|
||||||
if (!user) return false
|
|
||||||
|
|
||||||
// Super admin sees all
|
|
||||||
if (user.roles?.includes('super-admin')) return true
|
|
||||||
|
|
||||||
// Users see only their tenant's data
|
|
||||||
return {
|
|
||||||
tenant: {
|
|
||||||
equals: (user as User).tenantId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
access: {
|
|
||||||
create: tenantAccess,
|
|
||||||
read: tenantAccess,
|
|
||||||
update: tenantAccess,
|
|
||||||
delete: tenantAccess,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'title', type: 'text' },
|
|
||||||
{
|
|
||||||
name: 'tenant',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
access: {
|
|
||||||
// Tenant field hidden from non-admins
|
|
||||||
update: ({ req: { user } }) => user?.roles?.includes('super-admin'),
|
|
||||||
},
|
|
||||||
hooks: {
|
|
||||||
// Auto-set tenant on create
|
|
||||||
beforeChange: [
|
|
||||||
({ req, operation, value }) => {
|
|
||||||
if (operation === 'create' && !value) {
|
|
||||||
return (req.user as User)?.tenantId
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auth Collection Patterns
|
|
||||||
|
|
||||||
### Self or Admin Pattern
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const Users: CollectionConfig = {
|
|
||||||
slug: 'users',
|
|
||||||
auth: true,
|
|
||||||
access: {
|
|
||||||
// Anyone can read user profiles
|
|
||||||
read: () => true,
|
|
||||||
|
|
||||||
// Users can update themselves, admins can update anyone
|
|
||||||
update: ({ req: { user }, id }) => {
|
|
||||||
if (user?.roles?.includes('admin')) return true
|
|
||||||
return user?.id === id
|
|
||||||
},
|
|
||||||
|
|
||||||
// Only admins can delete
|
|
||||||
delete: ({ req: { user } }) => user?.roles?.includes('admin'),
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'name', type: 'text' },
|
|
||||||
{ name: 'email', type: 'email' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Restrict Self-Updates
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig, FieldAccess } from 'payload'
|
|
||||||
|
|
||||||
const preventSelfRoleChange: FieldAccess = ({ req: { user }, id }) => {
|
|
||||||
// Admins can change anyone's roles
|
|
||||||
if (user?.roles?.includes('admin')) return true
|
|
||||||
// Users cannot change their own roles
|
|
||||||
if (user?.id === id) return false
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Users: CollectionConfig = {
|
|
||||||
slug: 'users',
|
|
||||||
auth: true,
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'roles',
|
|
||||||
type: 'select',
|
|
||||||
hasMany: true,
|
|
||||||
options: ['admin', 'editor', 'user'],
|
|
||||||
access: {
|
|
||||||
update: preventSelfRoleChange,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cross-Collection Validation
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
// Check if user is a project member before allowing access
|
|
||||||
export const projectMemberAccess: Access = async ({ req, id }) => {
|
|
||||||
const { user, payload } = req
|
|
||||||
|
|
||||||
if (!user) return false
|
|
||||||
if (user.roles?.includes('admin')) return true
|
|
||||||
|
|
||||||
// Check if document exists and user is member
|
|
||||||
const project = await payload.findByID({
|
|
||||||
collection: 'projects',
|
|
||||||
id: id as string,
|
|
||||||
depth: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
return project.members?.includes(user.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent deletion if document has dependencies
|
|
||||||
export const preventDeleteWithDependencies: Access = async ({ req, id }) => {
|
|
||||||
const { payload } = req
|
|
||||||
|
|
||||||
const dependencyCount = await payload.count({
|
|
||||||
collection: 'related-items',
|
|
||||||
where: {
|
|
||||||
parent: { equals: id },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return dependencyCount === 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Access Control Function Arguments
|
|
||||||
|
|
||||||
### Collection Create
|
|
||||||
|
|
||||||
```ts
|
|
||||||
create: ({ req, data }) => boolean | Where
|
|
||||||
|
|
||||||
// req: PayloadRequest
|
|
||||||
// - req.user: Authenticated user (if any)
|
|
||||||
// - req.payload: Payload instance for queries
|
|
||||||
// - req.headers: Request headers
|
|
||||||
// - req.locale: Current locale
|
|
||||||
// data: The data being created
|
|
||||||
```
|
|
||||||
|
|
||||||
### Collection Read
|
|
||||||
|
|
||||||
```ts
|
|
||||||
read: ({ req, id }) => boolean | Where
|
|
||||||
|
|
||||||
// req: PayloadRequest
|
|
||||||
// id: Document ID being read
|
|
||||||
// - undefined during Access Operation (login check)
|
|
||||||
// - string when reading specific document
|
|
||||||
```
|
|
||||||
|
|
||||||
### Collection Update
|
|
||||||
|
|
||||||
```ts
|
|
||||||
update: ({ req, id, data }) => boolean | Where
|
|
||||||
|
|
||||||
// req: PayloadRequest
|
|
||||||
// id: Document ID being updated
|
|
||||||
// data: New values being applied
|
|
||||||
```
|
|
||||||
|
|
||||||
### Collection Delete
|
|
||||||
|
|
||||||
```ts
|
|
||||||
delete: ({ req, id }) => boolean | Where
|
|
||||||
|
|
||||||
// req: PayloadRequest
|
|
||||||
// id: Document ID being deleted
|
|
||||||
```
|
|
||||||
|
|
||||||
### Field Create
|
|
||||||
|
|
||||||
```ts
|
|
||||||
access: {
|
|
||||||
create: ({ req, data, siblingData }) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// req: PayloadRequest
|
|
||||||
// data: Full document data
|
|
||||||
// siblingData: Adjacent field values at same level
|
|
||||||
```
|
|
||||||
|
|
||||||
### Field Read
|
|
||||||
|
|
||||||
```ts
|
|
||||||
access: {
|
|
||||||
read: ({ req, id, doc, siblingData }) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// req: PayloadRequest
|
|
||||||
// id: Document ID
|
|
||||||
// doc: Full document
|
|
||||||
// siblingData: Adjacent field values
|
|
||||||
```
|
|
||||||
|
|
||||||
### Field Update
|
|
||||||
|
|
||||||
```ts
|
|
||||||
access: {
|
|
||||||
update: ({ req, id, data, doc, siblingData }) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// req: PayloadRequest
|
|
||||||
// id: Document ID
|
|
||||||
// data: New values
|
|
||||||
// doc: Current document
|
|
||||||
// siblingData: Adjacent field values
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
1. **Local API Default**: Access control is **skipped by default** in Local API (`overrideAccess: true`). When passing a `user` parameter, you almost always want to set `overrideAccess: false` to respect that user's permissions:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ❌ WRONG: Passes user but bypasses access control (default behavior)
|
|
||||||
await payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
user: someUser, // User is ignored for access control!
|
|
||||||
})
|
|
||||||
|
|
||||||
// ✅ CORRECT: Respects the user's permissions
|
|
||||||
await payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
user: someUser,
|
|
||||||
overrideAccess: false, // Required to enforce access control
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this matters**: If you pass `user` without `overrideAccess: false`, the operation runs with admin privileges regardless of the user's actual permissions. This is a common security mistake.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
4. **Access Before Hooks**: Access control executes BEFORE hooks run, so hooks cannot modify access behavior.
|
|
||||||
|
|
||||||
5. **Query Constraints**: Only collection-level `read` access supports query constraints. All other operations and field-level access require boolean returns.
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Reusable Functions**: Create named access functions for common patterns
|
|
||||||
2. **Fail Secure**: Default to `false` for sensitive operations
|
|
||||||
3. **Cache Checks**: Use `req.context` to cache expensive validation
|
|
||||||
4. **Type Safety**: Type your user object for better IDE support
|
|
||||||
5. **Test Thoroughly**: Write tests for complex access control logic
|
|
||||||
6. **Document Intent**: Add comments explaining access rules
|
|
||||||
7. **Audit Logs**: Track access control decisions for security review
|
|
||||||
8. **Performance**: Avoid N+1 queries in access functions
|
|
||||||
9. **Error Handling**: Access functions should not throw - return `false` instead
|
|
||||||
10. **Tenant Hooks**: Auto-set tenant fields in `beforeChange` hooks
|
|
||||||
|
|
||||||
## Advanced Patterns
|
|
||||||
|
|
||||||
For advanced access control patterns including context-aware access, time-based restrictions, subscription-based access, factory functions, configuration templates, debugging tips, and performance optimization, see [ACCESS-CONTROL-ADVANCED.md](ACCESS-CONTROL-ADVANCED.md).
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
# Payload CMS Adapters Reference
|
|
||||||
|
|
||||||
Complete reference for database, storage, and email adapters.
|
|
||||||
|
|
||||||
## Database Adapters
|
|
||||||
|
|
||||||
### MongoDB
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
db: mongooseAdapter({
|
|
||||||
url: process.env.DATABASE_URL,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Postgres
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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. Pass `req` to include operations in the same transaction.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionAfterChangeHook } from 'payload'
|
|
||||||
|
|
||||||
const afterChange: CollectionAfterChangeHook = async ({ req, doc }) => {
|
|
||||||
// This will be part of the same transaction
|
|
||||||
await req.payload.create({
|
|
||||||
req, // Pass req to use same transaction
|
|
||||||
collection: 'audit-log',
|
|
||||||
data: { action: 'created', docId: doc.id },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manual transaction control
|
|
||||||
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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: MongoDB requires replicaset for transactions. SQLite requires `transactionOptions: {}` to enable.
|
|
||||||
|
|
||||||
### Threading req Through Operations
|
|
||||||
|
|
||||||
**Critical**: When performing nested operations in hooks, always pass `req` to maintain transaction context. Failing to do so breaks atomicity and can cause partial updates.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionAfterChangeHook } from 'payload'
|
|
||||||
|
|
||||||
// ✅ 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
|
|
||||||
|
|
||||||
**When req is Required:**
|
|
||||||
|
|
||||||
- All mutating operations in hooks (create, update, delete)
|
|
||||||
- Operations that must succeed/fail together
|
|
||||||
- When using MongoDB replica sets or Postgres
|
|
||||||
- Any operation that relies on `req.context` or `req.user`
|
|
||||||
|
|
||||||
**When req is Optional:**
|
|
||||||
|
|
||||||
- Read-only lookups independent of current transaction
|
|
||||||
- Operations with `disableTransaction: true`
|
|
||||||
- Administrative operations with `overrideAccess: true`
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Azure Blob Storage
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { azureStorage } from '@payloadcms/storage-azure'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
plugins: [
|
|
||||||
azureStorage({
|
|
||||||
collections: {
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
|
|
||||||
containerName: process.env.AZURE_STORAGE_CONTAINER_NAME,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Google Cloud Storage
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { gcsStorage } from '@payloadcms/storage-gcs'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
plugins: [
|
|
||||||
gcsStorage({
|
|
||||||
collections: {
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
bucket: process.env.GCS_BUCKET,
|
|
||||||
options: {
|
|
||||||
projectId: process.env.GCS_PROJECT_ID,
|
|
||||||
credentials: JSON.parse(process.env.GCS_CREDENTIALS),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cloudflare R2
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { r2Storage } from '@payloadcms/storage-r2'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
plugins: [
|
|
||||||
r2Storage({
|
|
||||||
collections: {
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
bucket: process.env.R2_BUCKET,
|
|
||||||
config: {
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
|
||||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
|
||||||
},
|
|
||||||
region: 'auto',
|
|
||||||
endpoint: process.env.R2_ENDPOINT,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vercel Blob
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
plugins: [
|
|
||||||
vercelBlobStorage({
|
|
||||||
collections: {
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
token: process.env.BLOB_READ_WRITE_TOKEN,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Uploadthing
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
plugins: [
|
|
||||||
uploadthingStorage({
|
|
||||||
collections: {
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
token: process.env.UPLOADTHING_TOKEN,
|
|
||||||
acl: 'public-read',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Email Adapters
|
|
||||||
|
|
||||||
### Nodemailer (SMTP)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { resendAdapter } from '@payloadcms/email-resend'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
email: resendAdapter({
|
|
||||||
defaultFromAddress: 'noreply@example.com',
|
|
||||||
defaultFromName: 'My App',
|
|
||||||
apiKey: process.env.RESEND_API_KEY,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
# Payload CMS Advanced Features
|
|
||||||
|
|
||||||
Complete reference for authentication, jobs, custom endpoints, components, plugins, and localization.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
### Login
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// REST API
|
|
||||||
const response = await fetch('/api/users/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Local API
|
|
||||||
const result = await payload.login({
|
|
||||||
collection: 'users',
|
|
||||||
data: {
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'password',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Forgot Password
|
|
||||||
|
|
||||||
```ts
|
|
||||||
await payload.forgotPassword({
|
|
||||||
collection: 'users',
|
|
||||||
data: {
|
|
||||||
email: 'user@example.com',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Strategy
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig, Strategy } from 'payload'
|
|
||||||
|
|
||||||
const customStrategy: Strategy = {
|
|
||||||
name: 'custom',
|
|
||||||
authenticate: async ({ payload, headers }) => {
|
|
||||||
const token = headers.get('authorization')?.split(' ')[1]
|
|
||||||
if (!token) return { user: null }
|
|
||||||
|
|
||||||
const user = await verifyToken(token)
|
|
||||||
return { user }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Users: CollectionConfig = {
|
|
||||||
slug: 'users',
|
|
||||||
auth: {
|
|
||||||
strategies: [customStrategy],
|
|
||||||
},
|
|
||||||
fields: [],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Keys
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const APIKeys: CollectionConfig = {
|
|
||||||
slug: 'api-keys',
|
|
||||||
auth: {
|
|
||||||
disableLocalStrategy: true,
|
|
||||||
useAPIKey: true,
|
|
||||||
},
|
|
||||||
fields: [],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Jobs Queue
|
|
||||||
|
|
||||||
Offload long-running or scheduled tasks to background workers.
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { buildConfig } from 'payload'
|
|
||||||
import type { TaskConfig } from 'payload'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
jobs: {
|
|
||||||
tasks: [
|
|
||||||
{
|
|
||||||
slug: 'sendWelcomeEmail',
|
|
||||||
inputSchema: [
|
|
||||||
{ name: 'userEmail', type: 'text', required: true },
|
|
||||||
{ name: 'userName', type: 'text', required: true },
|
|
||||||
],
|
|
||||||
outputSchema: [{ name: 'emailSent', type: 'checkbox', required: true }],
|
|
||||||
retries: 2, // Retry up to 2 times on failure
|
|
||||||
handler: async ({ input, req }) => {
|
|
||||||
await sendEmail({
|
|
||||||
to: input.userEmail,
|
|
||||||
subject: `Welcome ${input.userName}`,
|
|
||||||
})
|
|
||||||
return { output: { emailSent: true } }
|
|
||||||
},
|
|
||||||
} as TaskConfig<'sendWelcomeEmail'>,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Queueing Jobs
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// In a hook or endpoint
|
|
||||||
await req.payload.jobs.queue({
|
|
||||||
task: 'sendWelcomeEmail',
|
|
||||||
input: {
|
|
||||||
userEmail: 'user@example.com',
|
|
||||||
userName: 'John',
|
|
||||||
},
|
|
||||||
waitUntil: new Date('2024-12-31'), // Optional: schedule for future
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Workflows
|
|
||||||
|
|
||||||
Multi-step jobs that run in sequence:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
slug: 'onboardUser',
|
|
||||||
inputSchema: [{ name: 'userId', type: 'text' }],
|
|
||||||
handler: async ({ job, req }) => {
|
|
||||||
const results = await job.runInlineTask({
|
|
||||||
task: async ({ input }) => {
|
|
||||||
// Step 1: Send welcome email
|
|
||||||
await sendEmail(input.userId)
|
|
||||||
return { output: { emailSent: true } }
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await job.runInlineTask({
|
|
||||||
task: async () => {
|
|
||||||
// Step 2: Create onboarding tasks
|
|
||||||
await createTasks()
|
|
||||||
return { output: { tasksCreated: true } }
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Custom Endpoints
|
|
||||||
|
|
||||||
Add custom REST API routes to collections, globals, or root config. See [ENDPOINTS.md](ENDPOINTS.md) for detailed patterns, authentication, helpers, and real-world examples.
|
|
||||||
|
|
||||||
### Root Endpoints
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { buildConfig } from 'payload'
|
|
||||||
import type { Endpoint } from 'payload'
|
|
||||||
|
|
||||||
const helloEndpoint: Endpoint = {
|
|
||||||
path: '/hello',
|
|
||||||
method: 'get',
|
|
||||||
handler: () => {
|
|
||||||
return Response.json({ message: 'Hello!' })
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const greetEndpoint: Endpoint = {
|
|
||||||
path: '/greet/:name',
|
|
||||||
method: 'get',
|
|
||||||
handler: (req) => {
|
|
||||||
return Response.json({
|
|
||||||
message: `Hello ${req.routeParams.name}!`,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
endpoints: [helloEndpoint, greetEndpoint],
|
|
||||||
collections: [],
|
|
||||||
secret: process.env.PAYLOAD_SECRET || '',
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Collection Endpoints
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig, Endpoint } from 'payload'
|
|
||||||
|
|
||||||
const featuredEndpoint: Endpoint = {
|
|
||||||
path: '/featured',
|
|
||||||
method: 'get',
|
|
||||||
handler: async (req) => {
|
|
||||||
const posts = await req.payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
where: { featured: { equals: true } },
|
|
||||||
})
|
|
||||||
return Response.json(posts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
endpoints: [featuredEndpoint],
|
|
||||||
fields: [
|
|
||||||
{ name: 'title', type: 'text' },
|
|
||||||
{ name: 'featured', type: 'checkbox' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Custom Components
|
|
||||||
|
|
||||||
### Field Component (Client)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
'use client'
|
|
||||||
import { useField } from '@payloadcms/ui'
|
|
||||||
import type { TextFieldClientComponent } from 'payload'
|
|
||||||
|
|
||||||
export const CustomField: TextFieldClientComponent = () => {
|
|
||||||
const { value, setValue } = useField()
|
|
||||||
|
|
||||||
return <input value={value || ''} onChange={(e) => setValue(e.target.value)} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom View
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
'use client'
|
|
||||||
import { DefaultTemplate } from '@payloadcms/next/templates'
|
|
||||||
|
|
||||||
export const CustomView = () => {
|
|
||||||
return (
|
|
||||||
<DefaultTemplate>
|
|
||||||
<h1>Custom Dashboard</h1>
|
|
||||||
{/* Your content */}
|
|
||||||
</DefaultTemplate>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Admin Config
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { buildConfig } from 'payload'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
admin: {
|
|
||||||
components: {
|
|
||||||
beforeDashboard: ['/components/BeforeDashboard'],
|
|
||||||
beforeLogin: ['/components/BeforeLogin'],
|
|
||||||
views: {
|
|
||||||
custom: {
|
|
||||||
Component: '/views/Custom',
|
|
||||||
path: '/custom',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
collections: [],
|
|
||||||
secret: process.env.PAYLOAD_SECRET || '',
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Plugins
|
|
||||||
|
|
||||||
### Available Plugins
|
|
||||||
|
|
||||||
- **@payloadcms/plugin-seo** - SEO fields with meta title/description, Open Graph, preview generation
|
|
||||||
- **@payloadcms/plugin-redirects** - Manage URL redirects (301/302) for Next.js apps
|
|
||||||
- **@payloadcms/plugin-nested-docs** - Hierarchical document structures with breadcrumbs
|
|
||||||
- **@payloadcms/plugin-form-builder** - Dynamic form builder with submissions and validation
|
|
||||||
- **@payloadcms/plugin-search** - Full-text search integration (Algolia support)
|
|
||||||
- **@payloadcms/plugin-stripe** - Stripe payments, subscriptions, webhooks
|
|
||||||
- **@payloadcms/plugin-ecommerce** - Complete ecommerce solution (products, variants, carts, orders)
|
|
||||||
- **@payloadcms/plugin-import-export** - Import/export data via CSV
|
|
||||||
- **@payloadcms/plugin-multi-tenant** - Multi-tenancy with tenant isolation
|
|
||||||
- **@payloadcms/plugin-sentry** - Sentry error tracking integration
|
|
||||||
- **@payloadcms/plugin-mcp** - Model Context Protocol for AI integrations
|
|
||||||
|
|
||||||
### Using Plugins
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { buildConfig } from 'payload'
|
|
||||||
import { seoPlugin } from '@payloadcms/plugin-seo'
|
|
||||||
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
plugins: [
|
|
||||||
seoPlugin({
|
|
||||||
collections: ['posts', 'pages'],
|
|
||||||
}),
|
|
||||||
redirectsPlugin({
|
|
||||||
collections: ['pages'],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
collections: [],
|
|
||||||
secret: process.env.PAYLOAD_SECRET || '',
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Creating Plugins
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Config } from 'payload'
|
|
||||||
|
|
||||||
interface PluginOptions {
|
|
||||||
enabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const myPlugin =
|
|
||||||
(options: PluginOptions) =>
|
|
||||||
(config: Config): Config => ({
|
|
||||||
...config,
|
|
||||||
collections: [
|
|
||||||
...(config.collections || []),
|
|
||||||
{
|
|
||||||
slug: 'plugin-collection',
|
|
||||||
fields: [{ name: 'title', type: 'text' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
onInit: async (payload) => {
|
|
||||||
if (config.onInit) await config.onInit(payload)
|
|
||||||
// Plugin initialization
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Localization
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { buildConfig } from 'payload'
|
|
||||||
import type { Field, Payload } from 'payload'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
localization: {
|
|
||||||
locales: ['en', 'es', 'de'],
|
|
||||||
defaultLocale: 'en',
|
|
||||||
fallback: true,
|
|
||||||
},
|
|
||||||
collections: [],
|
|
||||||
secret: process.env.PAYLOAD_SECRET || '',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Localized field
|
|
||||||
const localizedField: TextField = {
|
|
||||||
name: 'title',
|
|
||||||
type: 'text',
|
|
||||||
localized: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query with locale
|
|
||||||
const posts = await payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
locale: 'es',
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## TypeScript Type References
|
|
||||||
|
|
||||||
For complete TypeScript type definitions and signatures, reference these files from the Payload source:
|
|
||||||
|
|
||||||
### Core Configuration Types
|
|
||||||
|
|
||||||
- **[All Commonly-Used Types](https://github.com/payloadcms/payload/blob/main/packages/payload/src/index.ts)** - Check here first for commonly used types and interfaces. All core types are exported from this file.
|
|
||||||
|
|
||||||
### Database & Adapters
|
|
||||||
|
|
||||||
- **[Database Adapter Types](https://github.com/payloadcms/payload/blob/main/packages/payload/src/database/types.ts)** - Base adapter interface
|
|
||||||
- **[MongoDB Adapter](https://github.com/payloadcms/payload/blob/main/packages/db-mongodb/src/index.ts)** - MongoDB-specific options
|
|
||||||
- **[Postgres Adapter](https://github.com/payloadcms/payload/blob/main/packages/db-postgres/src/index.ts)** - Postgres-specific options
|
|
||||||
|
|
||||||
### Rich Text & Plugins
|
|
||||||
|
|
||||||
- **[Lexical Types](https://github.com/payloadcms/payload/blob/main/packages/richtext-lexical/src/exports/server/index.ts)** - Lexical editor configuration
|
|
||||||
|
|
||||||
When users need detailed type information, fetch these URLs to provide complete signatures and optional parameters.
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
# Payload CMS Collections Reference
|
|
||||||
|
|
||||||
Complete reference for collection configurations and patterns.
|
|
||||||
|
|
||||||
## Basic Collection
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
labels: {
|
|
||||||
singular: 'Post',
|
|
||||||
plural: 'Posts',
|
|
||||||
},
|
|
||||||
admin: {
|
|
||||||
useAsTitle: 'title',
|
|
||||||
defaultColumns: ['title', 'author', 'status', 'createdAt'],
|
|
||||||
group: 'Content', // Organize in admin sidebar
|
|
||||||
description: 'Blog posts and articles',
|
|
||||||
listSearchableFields: ['title', 'slug'],
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'title',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
index: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'slug',
|
|
||||||
type: 'text',
|
|
||||||
unique: true,
|
|
||||||
index: true,
|
|
||||||
admin: { position: 'sidebar' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'status',
|
|
||||||
type: 'select',
|
|
||||||
options: ['draft', 'published'],
|
|
||||||
defaultValue: 'draft',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
defaultSort: '-createdAt',
|
|
||||||
timestamps: true,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auth Collection
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const Users: CollectionConfig = {
|
|
||||||
slug: 'users',
|
|
||||||
auth: {
|
|
||||||
tokenExpiration: 7200, // 2 hours
|
|
||||||
verify: true,
|
|
||||||
maxLoginAttempts: 5,
|
|
||||||
lockTime: 600000, // 10 minutes
|
|
||||||
useAPIKey: true,
|
|
||||||
},
|
|
||||||
admin: {
|
|
||||||
useAsTitle: 'email',
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'roles',
|
|
||||||
type: 'select',
|
|
||||||
hasMany: true,
|
|
||||||
options: ['admin', 'editor', 'user'],
|
|
||||||
required: true,
|
|
||||||
defaultValue: ['user'],
|
|
||||||
saveToJWT: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Upload Collection
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'caption',
|
|
||||||
type: 'text',
|
|
||||||
localized: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Live Preview
|
|
||||||
|
|
||||||
Enable real-time content preview during editing.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
const generatePreviewPath = ({
|
|
||||||
slug,
|
|
||||||
collection,
|
|
||||||
req,
|
|
||||||
}: {
|
|
||||||
slug: string
|
|
||||||
collection: string
|
|
||||||
req: any
|
|
||||||
}) => {
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL
|
|
||||||
return `${baseUrl}/api/preview?slug=${slug}&collection=${collection}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Pages: CollectionConfig = {
|
|
||||||
slug: 'pages',
|
|
||||||
admin: {
|
|
||||||
useAsTitle: 'title',
|
|
||||||
// Live preview during editing
|
|
||||||
livePreview: {
|
|
||||||
url: ({ data, req }) =>
|
|
||||||
generatePreviewPath({
|
|
||||||
slug: data?.slug as string,
|
|
||||||
collection: 'pages',
|
|
||||||
req,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// Static preview button
|
|
||||||
preview: (data, { req }) =>
|
|
||||||
generatePreviewPath({
|
|
||||||
slug: data?.slug as string,
|
|
||||||
collection: 'pages',
|
|
||||||
req,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'title', type: 'text' },
|
|
||||||
{ name: 'slug', type: 'text' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Versioning & Drafts
|
|
||||||
|
|
||||||
Payload maintains version history and supports draft/publish workflows.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
// Basic versioning (audit log only)
|
|
||||||
export const Users: CollectionConfig = {
|
|
||||||
slug: 'users',
|
|
||||||
versions: true, // or { maxPerDoc: 100 }
|
|
||||||
fields: [{ name: 'name', type: 'text' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drafts enabled (draft/publish workflow)
|
|
||||||
export const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
versions: {
|
|
||||||
drafts: true, // Enables _status field
|
|
||||||
maxPerDoc: 50,
|
|
||||||
},
|
|
||||||
fields: [{ name: 'title', type: 'text' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full configuration with autosave and scheduled publish
|
|
||||||
export const Pages: CollectionConfig = {
|
|
||||||
slug: 'pages',
|
|
||||||
versions: {
|
|
||||||
drafts: {
|
|
||||||
autosave: true, // Auto-save while editing
|
|
||||||
schedulePublish: true, // Schedule future publish/unpublish
|
|
||||||
validate: false, // Don't validate drafts (default)
|
|
||||||
},
|
|
||||||
maxPerDoc: 100, // Keep last 100 versions (0 = unlimited)
|
|
||||||
},
|
|
||||||
fields: [{ name: 'title', type: 'text' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Draft API Usage
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Create draft
|
|
||||||
await payload.create({
|
|
||||||
collection: 'posts',
|
|
||||||
data: { title: 'Draft Post' },
|
|
||||||
draft: true, // Saves as draft, skips required field validation
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update as draft
|
|
||||||
await payload.update({
|
|
||||||
collection: 'posts',
|
|
||||||
id: '123',
|
|
||||||
data: { title: 'Updated Draft' },
|
|
||||||
draft: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Read with drafts (returns newest draft if available)
|
|
||||||
const post = await payload.findByID({
|
|
||||||
collection: 'posts',
|
|
||||||
id: '123',
|
|
||||||
draft: true, // Returns draft version if exists
|
|
||||||
})
|
|
||||||
|
|
||||||
// Query only published (REST API)
|
|
||||||
// GET /api/posts (returns only _status: 'published')
|
|
||||||
|
|
||||||
// Access control for drafts
|
|
||||||
export const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
versions: { drafts: true },
|
|
||||||
access: {
|
|
||||||
read: ({ req: { user } }) => {
|
|
||||||
// Public can only see published
|
|
||||||
if (!user) return { _status: { equals: 'published' } }
|
|
||||||
// Authenticated can see all
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fields: [{ name: 'title', type: 'text' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Document Status
|
|
||||||
|
|
||||||
The `_status` field is auto-injected when drafts are enabled:
|
|
||||||
|
|
||||||
- `draft` - Never published
|
|
||||||
- `published` - Published with no newer drafts
|
|
||||||
- `changed` - Published but has newer unpublished drafts
|
|
||||||
|
|
||||||
## Globals
|
|
||||||
|
|
||||||
Globals are single-instance documents (not collections).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,634 +0,0 @@
|
|||||||
# Payload Custom API Endpoints Reference
|
|
||||||
|
|
||||||
Custom REST API endpoints extend Payload's auto-generated CRUD operations with custom logic, authentication flows, webhooks, and integrations.
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
### Endpoint Configuration
|
|
||||||
|
|
||||||
| Property | Type | Description |
|
|
||||||
| --------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
|
||||||
| `path` | `string` | Route path after collection/global slug (e.g., `/:id/tracking`) |
|
|
||||||
| `method` | `'get' \| 'post' \| 'put' \| 'patch' \| 'delete'` | HTTP method (lowercase) |
|
|
||||||
| `handler` | `(req: PayloadRequest) => Promise<Response>` | Async function returning Web API Response |
|
|
||||||
| `custom` | `Record<string, any>` | Extension point for plugins/metadata |
|
|
||||||
|
|
||||||
### Request Context
|
|
||||||
|
|
||||||
| Property | Type | Description |
|
|
||||||
| ----------------- | ----------------------- | ------------------------------------------------------ |
|
|
||||||
| `req.user` | `User \| null` | Authenticated user (null if not authenticated) |
|
|
||||||
| `req.payload` | `Payload` | Payload instance for operations (find, create...) |
|
|
||||||
| `req.routeParams` | `Record<string, any>` | Path parameters (e.g., `:id`) |
|
|
||||||
| `req.url` | `string` | Full request URL |
|
|
||||||
| `req.method` | `string` | HTTP method |
|
|
||||||
| `req.headers` | `Headers` | Request headers |
|
|
||||||
| `req.json()` | `() => Promise<any>` | Parse JSON body |
|
|
||||||
| `req.text()` | `() => Promise<string>` | Read body as text |
|
|
||||||
| `req.data` | `any` | Parsed body (after `addDataAndFileToRequest()`) |
|
|
||||||
| `req.file` | `File` | Uploaded file (after `addDataAndFileToRequest()`) |
|
|
||||||
| `req.locale` | `string` | Request locale (after `addLocalesToRequestFromData()`) |
|
|
||||||
| `req.i18n` | `I18n` | i18n instance |
|
|
||||||
| `req.t` | `TFunction` | Translation function |
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Authentication Check
|
|
||||||
|
|
||||||
Custom endpoints are **not authenticated by default**. Check `req.user` to enforce authentication.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { APIError } from 'payload'
|
|
||||||
|
|
||||||
export const authenticatedEndpoint = {
|
|
||||||
path: '/protected',
|
|
||||||
method: 'get',
|
|
||||||
handler: async (req) => {
|
|
||||||
if (!req.user) {
|
|
||||||
throw new APIError('Unauthorized', 401)
|
|
||||||
}
|
|
||||||
|
|
||||||
// User is authenticated
|
|
||||||
return Response.json({ message: 'Access granted' })
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Payload Operations
|
|
||||||
|
|
||||||
Use `req.payload` for database operations with access control and hooks.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const getRelatedPosts = {
|
|
||||||
path: '/:id/related',
|
|
||||||
method: 'get',
|
|
||||||
handler: async (req) => {
|
|
||||||
const { id } = req.routeParams
|
|
||||||
|
|
||||||
// Find related posts
|
|
||||||
const posts = await req.payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
where: {
|
|
||||||
category: {
|
|
||||||
equals: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
limit: 5,
|
|
||||||
sort: '-createdAt',
|
|
||||||
})
|
|
||||||
|
|
||||||
return Response.json(posts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Route Parameters
|
|
||||||
|
|
||||||
Access path parameters via `req.routeParams`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const getTrackingEndpoint = {
|
|
||||||
path: '/:id/tracking',
|
|
||||||
method: 'get',
|
|
||||||
handler: async (req) => {
|
|
||||||
const orderId = req.routeParams.id
|
|
||||||
|
|
||||||
const tracking = await getTrackingInfo(orderId)
|
|
||||||
|
|
||||||
if (!tracking) {
|
|
||||||
return Response.json({ error: 'not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json(tracking)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Request Body Handling
|
|
||||||
|
|
||||||
**Option 1: Manual JSON parsing**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const createEndpoint = {
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Using helper (handles JSON + files)**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { addDataAndFileToRequest } from 'payload'
|
|
||||||
|
|
||||||
export const uploadEndpoint = {
|
|
||||||
path: '/upload',
|
|
||||||
method: 'post',
|
|
||||||
handler: async (req) => {
|
|
||||||
await addDataAndFileToRequest(req)
|
|
||||||
|
|
||||||
// req.data now 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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### CORS Headers
|
|
||||||
|
|
||||||
Use `headersWithCors` helper to apply config CORS settings.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { headersWithCors } from 'payload'
|
|
||||||
|
|
||||||
export const corsEndpoint = {
|
|
||||||
path: '/public-data',
|
|
||||||
method: 'get',
|
|
||||||
handler: async (req) => {
|
|
||||||
const data = await fetchPublicData()
|
|
||||||
|
|
||||||
return Response.json(data, {
|
|
||||||
headers: headersWithCors({
|
|
||||||
headers: new Headers(),
|
|
||||||
req,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
Throw `APIError` with status codes for proper error responses.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { APIError } from 'payload'
|
|
||||||
|
|
||||||
export const validateEndpoint = {
|
|
||||||
path: '/validate',
|
|
||||||
method: 'post',
|
|
||||||
handler: async (req) => {
|
|
||||||
const data = await req.json()
|
|
||||||
|
|
||||||
if (!data.email) {
|
|
||||||
throw new APIError('Email is required', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation passed
|
|
||||||
return Response.json({ valid: true })
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Query Parameters
|
|
||||||
|
|
||||||
Extract query params from URL.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const searchEndpoint = {
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Helper Functions
|
|
||||||
|
|
||||||
### addDataAndFileToRequest
|
|
||||||
|
|
||||||
Parses request body and attaches to `req.data` and `req.file`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { addDataAndFileToRequest } from 'payload'
|
|
||||||
|
|
||||||
export const endpoint = {
|
|
||||||
path: '/process',
|
|
||||||
method: 'post',
|
|
||||||
handler: async (req) => {
|
|
||||||
await addDataAndFileToRequest(req)
|
|
||||||
|
|
||||||
// req.data: parsed JSON or form data
|
|
||||||
// req.file: uploaded file (if multipart)
|
|
||||||
|
|
||||||
console.log(req.data) // { title: 'My Post' }
|
|
||||||
console.log(req.file) // File object or undefined
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Handles:**
|
|
||||||
|
|
||||||
- JSON bodies (`Content-Type: application/json`)
|
|
||||||
- Form data (`Content-Type: multipart/form-data`)
|
|
||||||
- File uploads
|
|
||||||
|
|
||||||
### addLocalesToRequestFromData
|
|
||||||
|
|
||||||
Extracts locale from request data and validates against config.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { addLocalesToRequestFromData } from 'payload'
|
|
||||||
|
|
||||||
export const endpoint = {
|
|
||||||
path: '/translate',
|
|
||||||
method: 'post',
|
|
||||||
handler: async (req) => {
|
|
||||||
await addLocalesToRequestFromData(req)
|
|
||||||
|
|
||||||
// req.locale: validated locale string
|
|
||||||
// req.fallbackLocale: fallback locale string
|
|
||||||
|
|
||||||
const result = await req.payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
locale: req.locale,
|
|
||||||
})
|
|
||||||
|
|
||||||
return Response.json(result)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### headersWithCors
|
|
||||||
|
|
||||||
Applies CORS headers from Payload config.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { headersWithCors } from 'payload'
|
|
||||||
|
|
||||||
export const endpoint = {
|
|
||||||
path: '/data',
|
|
||||||
method: 'get',
|
|
||||||
handler: async (req) => {
|
|
||||||
const data = { message: 'Hello' }
|
|
||||||
|
|
||||||
return Response.json(data, {
|
|
||||||
headers: headersWithCors({
|
|
||||||
headers: new Headers({
|
|
||||||
'Cache-Control': 'public, max-age=3600',
|
|
||||||
}),
|
|
||||||
req,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Real-World Examples
|
|
||||||
|
|
||||||
### Multi-Tenant Login Endpoint
|
|
||||||
|
|
||||||
From `examples/multi-tenant`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { APIError, generatePayloadCookie, headersWithCors } from 'payload'
|
|
||||||
|
|
||||||
export const externalUsersLogin = {
|
|
||||||
path: '/login-external',
|
|
||||||
method: 'post',
|
|
||||||
handler: async (req) => {
|
|
||||||
const { email, password, tenant } = await req.json()
|
|
||||||
|
|
||||||
if (!email || !password || !tenant) {
|
|
||||||
throw new APIError('Missing credentials', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find user with tenant constraint
|
|
||||||
const userQuery = await req.payload.find({
|
|
||||||
collection: 'users',
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ email: { equals: email } },
|
|
||||||
{
|
|
||||||
or: [{ tenants: { equals: tenant } }, { 'tenants.tenant': { equals: tenant } }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!userQuery.docs.length) {
|
|
||||||
throw new APIError('Invalid credentials', 401)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticate user
|
|
||||||
const result = await req.payload.login({
|
|
||||||
collection: 'users',
|
|
||||||
data: { email, password },
|
|
||||||
})
|
|
||||||
|
|
||||||
return Response.json(result, {
|
|
||||||
headers: headersWithCors({
|
|
||||||
headers: new Headers({
|
|
||||||
'Set-Cookie': generatePayloadCookie({
|
|
||||||
collectionAuthConfig: req.payload.config.collections.find((c) => c.slug === 'users')
|
|
||||||
.auth,
|
|
||||||
cookiePrefix: req.payload.config.cookiePrefix,
|
|
||||||
token: result.token,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
req,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Webhook Handler (Stripe)
|
|
||||||
|
|
||||||
From `packages/plugin-ecommerce`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const webhookEndpoint = {
|
|
||||||
path: '/webhooks',
|
|
||||||
method: 'post',
|
|
||||||
handler: async (req) => {
|
|
||||||
const body = await req.text()
|
|
||||||
const signature = req.headers.get('stripe-signature')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
|
|
||||||
|
|
||||||
// Process event
|
|
||||||
switch (event.type) {
|
|
||||||
case 'payment_intent.succeeded':
|
|
||||||
await handlePaymentSuccess(req.payload, event.data.object)
|
|
||||||
break
|
|
||||||
case 'payment_intent.failed':
|
|
||||||
await handlePaymentFailure(req.payload, event.data.object)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({ received: true })
|
|
||||||
} catch (err) {
|
|
||||||
req.payload.logger.error(`Webhook error: ${err.message}`)
|
|
||||||
return Response.json({ error: err.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Preview Endpoint
|
|
||||||
|
|
||||||
From `packages/plugin-import-export`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { addDataAndFileToRequest } from 'payload'
|
|
||||||
|
|
||||||
export const previewEndpoint = {
|
|
||||||
path: '/preview',
|
|
||||||
method: 'post',
|
|
||||||
handler: async (req) => {
|
|
||||||
if (!req.user) {
|
|
||||||
throw new APIError('Unauthorized', 401)
|
|
||||||
}
|
|
||||||
|
|
||||||
await addDataAndFileToRequest(req)
|
|
||||||
|
|
||||||
const { collection, where, limit = 10 } = req.data
|
|
||||||
|
|
||||||
// Validate collection exists
|
|
||||||
const collectionConfig = req.payload.config.collections.find((c) => c.slug === collection)
|
|
||||||
if (!collectionConfig) {
|
|
||||||
throw new APIError('Collection not found', 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preview data
|
|
||||||
const results = await req.payload.find({
|
|
||||||
collection,
|
|
||||||
where,
|
|
||||||
limit,
|
|
||||||
depth: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
docs: results.docs,
|
|
||||||
totalDocs: results.totalDocs,
|
|
||||||
fields: collectionConfig.fields,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reindex Action Endpoint
|
|
||||||
|
|
||||||
From `packages/plugin-search`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const reindexEndpoint = (pluginConfig) => ({
|
|
||||||
path: '/reindex',
|
|
||||||
method: 'post',
|
|
||||||
handler: async (req) => {
|
|
||||||
if (!req.user) {
|
|
||||||
throw new APIError('Unauthorized', 401)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { collection } = req.routeParams
|
|
||||||
|
|
||||||
// Reindex collection
|
|
||||||
const result = await reindexCollection(req.payload, collection, pluginConfig)
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
message: `Reindexed ${result.count} documents`,
|
|
||||||
count: result.count,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Endpoint Placement
|
|
||||||
|
|
||||||
### Collection Endpoints
|
|
||||||
|
|
||||||
Mounted at `/api/{collection-slug}/{path}`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const Orders: CollectionConfig = {
|
|
||||||
slug: 'orders',
|
|
||||||
fields: [
|
|
||||||
/* ... */
|
|
||||||
],
|
|
||||||
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}`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { GlobalConfig } from 'payload'
|
|
||||||
|
|
||||||
export const Settings: GlobalConfig = {
|
|
||||||
slug: 'settings',
|
|
||||||
fields: [
|
|
||||||
/* ... */
|
|
||||||
],
|
|
||||||
endpoints: [
|
|
||||||
{
|
|
||||||
path: '/clear-cache',
|
|
||||||
method: 'post',
|
|
||||||
handler: async (req) => {
|
|
||||||
// Available at: /api/globals/settings/clear-cache
|
|
||||||
await clearCache()
|
|
||||||
return Response.json({ message: 'Cache cleared' })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Patterns
|
|
||||||
|
|
||||||
### Factory Functions
|
|
||||||
|
|
||||||
Create reusable endpoint factories for plugins.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const createWebhookEndpoint = (config) => ({
|
|
||||||
path: '/webhook',
|
|
||||||
method: 'post',
|
|
||||||
handler: async (req) => {
|
|
||||||
const signature = req.headers.get('x-webhook-signature')
|
|
||||||
|
|
||||||
if (!verifySignature(signature, config.secret)) {
|
|
||||||
throw new APIError('Invalid signature', 401)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await req.json()
|
|
||||||
await processWebhook(req.payload, data, config)
|
|
||||||
|
|
||||||
return Response.json({ received: true })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Conditional Endpoints
|
|
||||||
|
|
||||||
Add endpoints based on config options.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const MyCollection: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
fields: [
|
|
||||||
/* ... */
|
|
||||||
],
|
|
||||||
endpoints: [
|
|
||||||
// Always included
|
|
||||||
{
|
|
||||||
path: '/public',
|
|
||||||
method: 'get',
|
|
||||||
handler: async (req) => Response.json({ data: [] }),
|
|
||||||
},
|
|
||||||
// Conditionally included
|
|
||||||
...(process.env.ENABLE_ANALYTICS
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
path: '/analytics',
|
|
||||||
method: 'get',
|
|
||||||
handler: async (req) => Response.json({ analytics: [] }),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenAPI Documentation
|
|
||||||
|
|
||||||
Use `custom` property for API documentation metadata.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const endpoint = {
|
|
||||||
path: '/search',
|
|
||||||
method: 'get',
|
|
||||||
handler: async (req) => {
|
|
||||||
// Handler implementation
|
|
||||||
},
|
|
||||||
custom: {
|
|
||||||
openapi: {
|
|
||||||
summary: 'Search posts',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'q',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
schema: { type: 'string' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: 'Search results',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: { type: 'array' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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`, etc.
|
|
||||||
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. **Handle CORS** - Use `headersWithCors` for cross-origin requests
|
|
||||||
8. **Log errors** - Use `req.payload.logger` for debugging
|
|
||||||
9. **Document with `custom`** - Add OpenAPI metadata for API docs
|
|
||||||
10. **Factory pattern for reuse** - Create endpoint factories for plugins
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- REST API Overview: <https://payloadcms.com/docs/rest-api/overview>
|
|
||||||
- Custom Endpoints: <https://payloadcms.com/docs/rest-api/overview#custom-endpoints>
|
|
||||||
- Access Control: <https://payloadcms.com/docs/access-control/overview>
|
|
||||||
- Local API: <https://payloadcms.com/docs/local-api/overview>
|
|
||||||
@@ -1,553 +0,0 @@
|
|||||||
# Payload Field Type Guards Reference
|
|
||||||
|
|
||||||
Complete reference with detailed examples and patterns. See [FIELDS.md](FIELDS.md#field-type-guards) for quick reference table of all guards.
|
|
||||||
|
|
||||||
## Structural Guards
|
|
||||||
|
|
||||||
### fieldHasSubFields
|
|
||||||
|
|
||||||
Checks if field contains nested fields (group, array, row, or collapsible).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Field } from 'payload'
|
|
||||||
import { fieldHasSubFields } from 'payload'
|
|
||||||
|
|
||||||
function traverseFields(fields: Field[]): void {
|
|
||||||
fields.forEach((field) => {
|
|
||||||
if (fieldHasSubFields(field)) {
|
|
||||||
// Safe to access field.fields
|
|
||||||
traverseFields(field.fields)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldHasSubFields<TField extends ClientField | Field>(
|
|
||||||
field: TField
|
|
||||||
): field is TField & (FieldWithSubFieldsClient | FieldWithSubFields)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Common Pattern - Exclude Arrays:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (fieldHasSubFields(field) && !fieldIsArrayType(field)) {
|
|
||||||
// Groups, rows, collapsibles only (not arrays)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### fieldIsArrayType
|
|
||||||
|
|
||||||
Checks if field type is `'array'`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldIsArrayType } from 'payload'
|
|
||||||
|
|
||||||
if (fieldIsArrayType(field)) {
|
|
||||||
// field.type === 'array'
|
|
||||||
console.log(`Min rows: ${field.minRows}`)
|
|
||||||
console.log(`Max rows: ${field.maxRows}`)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldIsArrayType<TField extends ClientField | Field>(
|
|
||||||
field: TField
|
|
||||||
): field is TField & (ArrayFieldClient | ArrayField)
|
|
||||||
```
|
|
||||||
|
|
||||||
### fieldIsBlockType
|
|
||||||
|
|
||||||
Checks if field type is `'blocks'`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldIsBlockType } from 'payload'
|
|
||||||
|
|
||||||
if (fieldIsBlockType(field)) {
|
|
||||||
// field.type === 'blocks'
|
|
||||||
field.blocks.forEach((block) => {
|
|
||||||
console.log(`Block: ${block.slug}`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldIsBlockType<TField extends ClientField | Field>(
|
|
||||||
field: TField
|
|
||||||
): field is TField & (BlocksFieldClient | BlocksField)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Common Pattern - Distinguish Containers:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (fieldIsArrayType(field)) {
|
|
||||||
// Handle array rows
|
|
||||||
} else if (fieldIsBlockType(field)) {
|
|
||||||
// Handle block types
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### fieldIsGroupType
|
|
||||||
|
|
||||||
Checks if field type is `'group'`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldIsGroupType } from 'payload'
|
|
||||||
|
|
||||||
if (fieldIsGroupType(field)) {
|
|
||||||
// field.type === 'group'
|
|
||||||
console.log(`Interface: ${field.interfaceName}`)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldIsGroupType<TField extends ClientField | Field>(
|
|
||||||
field: TField
|
|
||||||
): field is TField & (GroupFieldClient | GroupField)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Capability Guards
|
|
||||||
|
|
||||||
### fieldSupportsMany
|
|
||||||
|
|
||||||
Checks if field can have multiple values (select, relationship, or upload with `hasMany`).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldSupportsMany } from 'payload'
|
|
||||||
|
|
||||||
if (fieldSupportsMany(field)) {
|
|
||||||
// field.type is 'select' | 'relationship' | 'upload'
|
|
||||||
// Safe to check field.hasMany
|
|
||||||
if (field.hasMany) {
|
|
||||||
console.log('Field accepts multiple values')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldSupportsMany<TField extends ClientField | Field>(
|
|
||||||
field: TField
|
|
||||||
): field is TField & (FieldWithManyClient | FieldWithMany)
|
|
||||||
```
|
|
||||||
|
|
||||||
### fieldHasMaxDepth
|
|
||||||
|
|
||||||
Checks if field is relationship/upload/join with numeric `maxDepth` property.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldHasMaxDepth } from 'payload'
|
|
||||||
|
|
||||||
if (fieldHasMaxDepth(field)) {
|
|
||||||
// field.type is 'upload' | 'relationship' | 'join'
|
|
||||||
// AND field.maxDepth is number
|
|
||||||
const remainingDepth = field.maxDepth - currentDepth
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldHasMaxDepth<TField extends ClientField | Field>(
|
|
||||||
field: TField
|
|
||||||
): field is TField & (FieldWithMaxDepthClient | FieldWithMaxDepth)
|
|
||||||
```
|
|
||||||
|
|
||||||
### fieldShouldBeLocalized
|
|
||||||
|
|
||||||
Checks if field needs localization handling (accounts for parent localization).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldShouldBeLocalized } from 'payload'
|
|
||||||
|
|
||||||
function processField(field: Field, parentIsLocalized: boolean) {
|
|
||||||
if (fieldShouldBeLocalized({ field, parentIsLocalized })) {
|
|
||||||
// Create locale-specific table or index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldShouldBeLocalized({
|
|
||||||
field,
|
|
||||||
parentIsLocalized,
|
|
||||||
}: {
|
|
||||||
field: ClientField | ClientTab | Field | Tab
|
|
||||||
parentIsLocalized: boolean
|
|
||||||
}): boolean
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Accounts for parent localization
|
|
||||||
if (fieldShouldBeLocalized({ field, parentIsLocalized: false })) {
|
|
||||||
/* ... */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### fieldIsVirtual
|
|
||||||
|
|
||||||
Checks if field is virtual (computed or virtual relationship).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldIsVirtual } from 'payload'
|
|
||||||
|
|
||||||
if (fieldIsVirtual(field)) {
|
|
||||||
// field.virtual is truthy
|
|
||||||
if (typeof field.virtual === 'string') {
|
|
||||||
// Virtual relationship path
|
|
||||||
console.log(`Virtual path: ${field.virtual}`)
|
|
||||||
} else {
|
|
||||||
// Computed virtual field (uses hooks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldIsVirtual(field: Field | Tab): boolean
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Guards
|
|
||||||
|
|
||||||
### fieldAffectsData
|
|
||||||
|
|
||||||
**Most commonly used guard.** Checks if field stores data (has name and is not UI-only).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldAffectsData } from 'payload'
|
|
||||||
|
|
||||||
function generateSchema(fields: Field[]) {
|
|
||||||
fields.forEach((field) => {
|
|
||||||
if (fieldAffectsData(field)) {
|
|
||||||
// Safe to access field.name
|
|
||||||
schema[field.name] = getFieldType(field)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldAffectsData<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
|
||||||
field: TField
|
|
||||||
): field is TField & (FieldAffectingDataClient | FieldAffectingData)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pattern - Data Fields Only:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const dataFields = fields.filter(fieldAffectsData)
|
|
||||||
```
|
|
||||||
|
|
||||||
### fieldIsPresentationalOnly
|
|
||||||
|
|
||||||
Checks if field is UI-only (type `'ui'`).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldIsPresentationalOnly } from 'payload'
|
|
||||||
|
|
||||||
if (fieldIsPresentationalOnly(field)) {
|
|
||||||
// field.type === 'ui'
|
|
||||||
// Skip in data operations, GraphQL schema, etc.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldIsPresentationalOnly<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
|
||||||
field: TField
|
|
||||||
): field is TField & (UIFieldClient | UIField)
|
|
||||||
```
|
|
||||||
|
|
||||||
### fieldIsID
|
|
||||||
|
|
||||||
Checks if field name is exactly `'id'`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldIsID } from 'payload'
|
|
||||||
|
|
||||||
if (fieldIsID(field)) {
|
|
||||||
// field.name === 'id'
|
|
||||||
// Special handling for ID field
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldIsID<TField extends ClientField | Field>(
|
|
||||||
field: TField
|
|
||||||
): field is { name: 'id' } & TField
|
|
||||||
```
|
|
||||||
|
|
||||||
### fieldIsHiddenOrDisabled
|
|
||||||
|
|
||||||
Checks if field is hidden or admin-disabled.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldIsHiddenOrDisabled } from 'payload'
|
|
||||||
|
|
||||||
const visibleFields = fields.filter((field) => !fieldIsHiddenOrDisabled(field))
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldIsHiddenOrDisabled<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
|
||||||
field: TField
|
|
||||||
): field is { admin: { hidden: true } } & TField
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layout Guards
|
|
||||||
|
|
||||||
### fieldIsSidebar
|
|
||||||
|
|
||||||
Checks if field is positioned in sidebar.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldIsSidebar } from 'payload'
|
|
||||||
|
|
||||||
const [mainFields, sidebarFields] = fields.reduce(
|
|
||||||
([main, sidebar], field) => {
|
|
||||||
if (fieldIsSidebar(field)) {
|
|
||||||
return [main, [...sidebar, field]]
|
|
||||||
}
|
|
||||||
return [[...main, field], sidebar]
|
|
||||||
},
|
|
||||||
[[], []],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
fieldIsSidebar<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
|
||||||
field: TField
|
|
||||||
): field is { admin: { position: 'sidebar' } } & TField
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tab & Group Guards
|
|
||||||
|
|
||||||
### tabHasName
|
|
||||||
|
|
||||||
Checks if tab is named (stores data under tab name).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { tabHasName } from 'payload'
|
|
||||||
|
|
||||||
tabs.forEach((tab) => {
|
|
||||||
if (tabHasName(tab)) {
|
|
||||||
// tab.name exists
|
|
||||||
dataPath.push(tab.name)
|
|
||||||
}
|
|
||||||
// Process tab.fields
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
tabHasName<TField extends ClientTab | Tab>(
|
|
||||||
tab: TField
|
|
||||||
): tab is NamedTab & TField
|
|
||||||
```
|
|
||||||
|
|
||||||
### groupHasName
|
|
||||||
|
|
||||||
Checks if group is named (stores data under group name).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { groupHasName } from 'payload'
|
|
||||||
|
|
||||||
if (groupHasName(group)) {
|
|
||||||
// group.name exists
|
|
||||||
return data[group.name]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
groupHasName(group: Partial<NamedGroupFieldClient>): group is NamedGroupFieldClient
|
|
||||||
```
|
|
||||||
|
|
||||||
## Option & Value Guards
|
|
||||||
|
|
||||||
### optionIsObject
|
|
||||||
|
|
||||||
Checks if option is object format `{label, value}` vs string.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { optionIsObject } from 'payload'
|
|
||||||
|
|
||||||
field.options.forEach((option) => {
|
|
||||||
if (optionIsObject(option)) {
|
|
||||||
console.log(`${option.label}: ${option.value}`)
|
|
||||||
} else {
|
|
||||||
console.log(option) // string value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
optionIsObject(option: Option): option is OptionObject
|
|
||||||
```
|
|
||||||
|
|
||||||
### optionsAreObjects
|
|
||||||
|
|
||||||
Checks if entire options array contains objects.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { optionsAreObjects } from 'payload'
|
|
||||||
|
|
||||||
if (optionsAreObjects(field.options)) {
|
|
||||||
// All options are OptionObject[]
|
|
||||||
const labels = field.options.map((opt) => opt.label)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
optionsAreObjects(options: Option[]): options is OptionObject[]
|
|
||||||
```
|
|
||||||
|
|
||||||
### optionIsValue
|
|
||||||
|
|
||||||
Checks if option is string value (not object).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { optionIsValue } from 'payload'
|
|
||||||
|
|
||||||
if (optionIsValue(option)) {
|
|
||||||
// option is string
|
|
||||||
const value = option
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
optionIsValue(option: Option): option is string
|
|
||||||
```
|
|
||||||
|
|
||||||
### valueIsValueWithRelation
|
|
||||||
|
|
||||||
Checks if relationship value is polymorphic format `{relationTo, value}`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { valueIsValueWithRelation } from 'payload'
|
|
||||||
|
|
||||||
if (valueIsValueWithRelation(fieldValue)) {
|
|
||||||
// fieldValue.relationTo exists
|
|
||||||
// fieldValue.value exists
|
|
||||||
console.log(`Related to ${fieldValue.relationTo}: ${fieldValue.value}`)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
valueIsValueWithRelation(value: unknown): value is ValueWithRelation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Recursive Field Traversal
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldAffectsData, fieldIsPresentationalOnly, fieldIsHiddenOrDisabled } from 'payload'
|
|
||||||
|
|
||||||
const dataFields = fields.filter(
|
|
||||||
(field) =>
|
|
||||||
fieldAffectsData(field) && !fieldIsPresentationalOnly(field) && !fieldIsHiddenOrDisabled(field),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Container Type Switching
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldSupportsMany, fieldHasMaxDepth } from 'payload'
|
|
||||||
|
|
||||||
// Without guard - TypeScript error
|
|
||||||
// if (field.hasMany) { /* ... */ }
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type Preservation
|
|
||||||
|
|
||||||
All guards preserve the original type constraint:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { ClientField, Field } from 'payload'
|
|
||||||
import { fieldHasSubFields } from 'payload'
|
|
||||||
|
|
||||||
function processServerField(field: Field) {
|
|
||||||
if (fieldHasSubFields(field)) {
|
|
||||||
// field is Field & FieldWithSubFields (not ClientField)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function processClientField(field: ClientField) {
|
|
||||||
if (fieldHasSubFields(field)) {
|
|
||||||
// field is ClientField & FieldWithSubFieldsClient
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,744 +0,0 @@
|
|||||||
# Payload CMS Field Types Reference
|
|
||||||
|
|
||||||
Complete reference for all Payload field types with examples.
|
|
||||||
|
|
||||||
## Text Field
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { TextField } from 'payload'
|
|
||||||
|
|
||||||
const textField: TextField = {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Slug Field Helper
|
|
||||||
|
|
||||||
Built-in helper for auto-generating slugs:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { slugField } from 'payload'
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const Pages: CollectionConfig = {
|
|
||||||
slug: 'pages',
|
|
||||||
fields: [
|
|
||||||
{ name: 'title', type: 'text', required: true },
|
|
||||||
slugField({
|
|
||||||
name: 'slug', // defaults to 'slug'
|
|
||||||
useAsSlug: 'title', // defaults to 'title'
|
|
||||||
checkboxName: 'generateSlug', // defaults to 'generateSlug'
|
|
||||||
localized: true,
|
|
||||||
required: true,
|
|
||||||
overrides: (defaultField) => {
|
|
||||||
// Customize the generated fields if needed
|
|
||||||
return defaultField
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rich Text (Lexical)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { RichTextField } from 'payload'
|
|
||||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
|
||||||
import { HeadingFeature, LinkFeature } from '@payloadcms/richtext-lexical'
|
|
||||||
|
|
||||||
const richTextField: RichTextField = {
|
|
||||||
name: 'content',
|
|
||||||
type: 'richText',
|
|
||||||
required: true,
|
|
||||||
localized: true,
|
|
||||||
editor: lexicalEditor({
|
|
||||||
features: ({ defaultFeatures }) => [
|
|
||||||
...defaultFeatures,
|
|
||||||
HeadingFeature({
|
|
||||||
enabledHeadingSizes: ['h1', 'h2', 'h3'],
|
|
||||||
}),
|
|
||||||
LinkFeature({
|
|
||||||
enabledCollections: ['posts', 'pages'],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Lexical Configuration
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import {
|
|
||||||
BoldFeature,
|
|
||||||
EXPERIMENTAL_TableFeature,
|
|
||||||
FixedToolbarFeature,
|
|
||||||
HeadingFeature,
|
|
||||||
IndentFeature,
|
|
||||||
InlineToolbarFeature,
|
|
||||||
ItalicFeature,
|
|
||||||
LinkFeature,
|
|
||||||
OrderedListFeature,
|
|
||||||
UnderlineFeature,
|
|
||||||
UnorderedListFeature,
|
|
||||||
lexicalEditor,
|
|
||||||
} from '@payloadcms/richtext-lexical'
|
|
||||||
|
|
||||||
// Global editor config with full features
|
|
||||||
export default buildConfig({
|
|
||||||
editor: lexicalEditor({
|
|
||||||
features: () => {
|
|
||||||
return [
|
|
||||||
UnderlineFeature(),
|
|
||||||
BoldFeature(),
|
|
||||||
ItalicFeature(),
|
|
||||||
OrderedListFeature(),
|
|
||||||
UnorderedListFeature(),
|
|
||||||
LinkFeature({
|
|
||||||
enabledCollections: ['pages'],
|
|
||||||
fields: ({ defaultFields }) => {
|
|
||||||
const defaultFieldsWithoutUrl = defaultFields.filter((field) => {
|
|
||||||
if ('name' in field && field.name === 'url') return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
|
||||||
...defaultFieldsWithoutUrl,
|
|
||||||
{
|
|
||||||
name: 'url',
|
|
||||||
type: 'text',
|
|
||||||
admin: {
|
|
||||||
condition: ({ linkType }) => linkType !== 'internal',
|
|
||||||
},
|
|
||||||
label: ({ t }) => t('fields:enterURL'),
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
IndentFeature(),
|
|
||||||
EXPERIMENTAL_TableFeature(),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Field-specific editor with custom toolbar
|
|
||||||
const richTextWithToolbars: RichTextField = {
|
|
||||||
name: 'richText',
|
|
||||||
type: 'richText',
|
|
||||||
editor: lexicalEditor({
|
|
||||||
features: ({ rootFeatures }) => {
|
|
||||||
return [
|
|
||||||
...rootFeatures,
|
|
||||||
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
|
|
||||||
FixedToolbarFeature(),
|
|
||||||
InlineToolbarFeature(),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
label: false,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Relationship
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { RelationshipField } from 'payload'
|
|
||||||
|
|
||||||
// Single relationship
|
|
||||||
const singleRelationship: RelationshipField = {
|
|
||||||
name: 'author',
|
|
||||||
type: 'relationship',
|
|
||||||
relationTo: 'users',
|
|
||||||
required: true,
|
|
||||||
maxDepth: 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple relationships (hasMany)
|
|
||||||
const multipleRelationship: RelationshipField = {
|
|
||||||
name: 'categories',
|
|
||||||
type: 'relationship',
|
|
||||||
relationTo: 'categories',
|
|
||||||
hasMany: true,
|
|
||||||
filterOptions: {
|
|
||||||
active: { equals: true },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Polymorphic relationship
|
|
||||||
const polymorphicRelationship: PolymorphicRelationshipField = {
|
|
||||||
name: 'relatedContent',
|
|
||||||
type: 'relationship',
|
|
||||||
relationTo: ['posts', 'pages'],
|
|
||||||
hasMany: true,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Array
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { ArrayField } from 'payload'
|
|
||||||
|
|
||||||
const arrayField: ArrayField = {
|
|
||||||
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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { BlocksField, 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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocksField: BlocksField = {
|
|
||||||
name: 'layout',
|
|
||||||
type: 'blocks',
|
|
||||||
blocks: [HeroBlock, ContentBlock],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Select
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { SelectField } from 'payload'
|
|
||||||
|
|
||||||
const selectField: SelectField = {
|
|
||||||
name: 'status',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ label: 'Draft', value: 'draft' },
|
|
||||||
{ label: 'Published', value: 'published' },
|
|
||||||
],
|
|
||||||
defaultValue: 'draft',
|
|
||||||
required: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple select
|
|
||||||
const multiSelectField: SelectField = {
|
|
||||||
name: 'tags',
|
|
||||||
type: 'select',
|
|
||||||
hasMany: true,
|
|
||||||
options: ['tech', 'news', 'sports'],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Upload
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { UploadField } from 'payload'
|
|
||||||
|
|
||||||
const uploadField: UploadField = {
|
|
||||||
name: 'featuredImage',
|
|
||||||
type: 'upload',
|
|
||||||
relationTo: 'media',
|
|
||||||
required: true,
|
|
||||||
filterOptions: {
|
|
||||||
mimeType: { contains: 'image' },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Point (Geolocation)
|
|
||||||
|
|
||||||
Point fields store geographic coordinates with automatic 2dsphere indexing for geospatial queries.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { PointField } from 'payload'
|
|
||||||
|
|
||||||
const locationField: PointField = {
|
|
||||||
name: 'location',
|
|
||||||
type: 'point',
|
|
||||||
label: 'Location',
|
|
||||||
required: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns [longitude, latitude]
|
|
||||||
// Example: [-122.4194, 37.7749] for San Francisco
|
|
||||||
```
|
|
||||||
|
|
||||||
### Geospatial Queries
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Query by distance (sorted by nearest first)
|
|
||||||
const nearbyLocations = await payload.find({
|
|
||||||
collection: 'stores',
|
|
||||||
where: {
|
|
||||||
location: {
|
|
||||||
near: [10, 20], // [longitude, latitude]
|
|
||||||
maxDistance: 5000, // in meters
|
|
||||||
minDistance: 1000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Query within polygon area
|
|
||||||
const polygon: Point[] = [
|
|
||||||
[9.0, 19.0], // bottom-left
|
|
||||||
[9.0, 21.0], // top-left
|
|
||||||
[11.0, 21.0], // top-right
|
|
||||||
[11.0, 19.0], // bottom-right
|
|
||||||
[9.0, 19.0], // closing point
|
|
||||||
]
|
|
||||||
|
|
||||||
const withinArea = await payload.find({
|
|
||||||
collection: 'stores',
|
|
||||||
where: {
|
|
||||||
location: {
|
|
||||||
within: {
|
|
||||||
type: 'Polygon',
|
|
||||||
coordinates: [polygon],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Query intersecting area
|
|
||||||
const intersecting = await payload.find({
|
|
||||||
collection: 'stores',
|
|
||||||
where: {
|
|
||||||
location: {
|
|
||||||
intersects: {
|
|
||||||
type: 'Polygon',
|
|
||||||
coordinates: [polygon],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: Point fields are not supported in SQLite.
|
|
||||||
|
|
||||||
## Join Fields
|
|
||||||
|
|
||||||
Join fields create reverse relationships, allowing you to access related documents from the "other side" of a relationship.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { JoinField } from 'payload'
|
|
||||||
|
|
||||||
// From Users collection - show user's orders
|
|
||||||
const ordersJoinField: JoinField = {
|
|
||||||
name: 'orders',
|
|
||||||
type: 'join',
|
|
||||||
collection: 'orders',
|
|
||||||
on: 'customer', // The field in 'orders' that references this user
|
|
||||||
admin: {
|
|
||||||
allowCreate: false,
|
|
||||||
defaultColumns: ['id', 'createdAt', 'total', 'currency', 'items'],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// From Users collection - show user's cart
|
|
||||||
const cartJoinField: JoinField = {
|
|
||||||
name: 'cart',
|
|
||||||
type: 'join',
|
|
||||||
collection: 'carts',
|
|
||||||
on: 'customer',
|
|
||||||
admin: {
|
|
||||||
allowCreate: false,
|
|
||||||
defaultColumns: ['id', 'createdAt', 'total', 'currency'],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Virtual Fields
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { TextField } from 'payload'
|
|
||||||
|
|
||||||
// Computed from siblings
|
|
||||||
const computedVirtualField: TextField = {
|
|
||||||
name: 'fullName',
|
|
||||||
type: 'text',
|
|
||||||
virtual: true,
|
|
||||||
hooks: {
|
|
||||||
afterRead: [({ siblingData }) => `${siblingData.firstName} ${siblingData.lastName}`],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// From relationship path
|
|
||||||
const pathVirtualField: TextField = {
|
|
||||||
name: 'authorName',
|
|
||||||
type: 'text',
|
|
||||||
virtual: 'author.name',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conditional Fields
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { UploadField, CheckboxField } from 'payload'
|
|
||||||
|
|
||||||
// Simple boolean condition
|
|
||||||
const enableFeatureField: CheckboxField = {
|
|
||||||
name: 'enableFeature',
|
|
||||||
type: 'checkbox',
|
|
||||||
}
|
|
||||||
|
|
||||||
const conditionalField: TextField = {
|
|
||||||
name: 'featureText',
|
|
||||||
type: 'text',
|
|
||||||
admin: {
|
|
||||||
condition: (data) => data.enableFeature === true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sibling data condition (from hero field pattern)
|
|
||||||
const typeField: SelectField = {
|
|
||||||
name: 'type',
|
|
||||||
type: 'select',
|
|
||||||
options: ['none', 'highImpact', 'mediumImpact', 'lowImpact'],
|
|
||||||
defaultValue: 'lowImpact',
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaField: UploadField = {
|
|
||||||
name: 'media',
|
|
||||||
type: 'upload',
|
|
||||||
relationTo: 'media',
|
|
||||||
admin: {
|
|
||||||
condition: (_, { type } = {}) => ['highImpact', 'mediumImpact'].includes(type),
|
|
||||||
},
|
|
||||||
required: true,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Radio
|
|
||||||
|
|
||||||
Radio fields present options as radio buttons for single selection.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { RadioField } from 'payload'
|
|
||||||
|
|
||||||
const radioField: RadioField = {
|
|
||||||
name: 'priority',
|
|
||||||
type: 'radio',
|
|
||||||
options: [
|
|
||||||
{ label: 'Low', value: 'low' },
|
|
||||||
{ label: 'Medium', value: 'medium' },
|
|
||||||
{ label: 'High', value: 'high' },
|
|
||||||
],
|
|
||||||
defaultValue: 'medium',
|
|
||||||
admin: {
|
|
||||||
layout: 'horizontal', // or 'vertical'
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Row (Layout)
|
|
||||||
|
|
||||||
Row fields arrange fields horizontally in the admin panel (presentational only).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { RowField } from 'payload'
|
|
||||||
|
|
||||||
const rowField: RowField = {
|
|
||||||
type: 'row',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'firstName',
|
|
||||||
type: 'text',
|
|
||||||
admin: { width: '50%' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lastName',
|
|
||||||
type: 'text',
|
|
||||||
admin: { width: '50%' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Collapsible (Layout)
|
|
||||||
|
|
||||||
Collapsible fields group fields in an expandable/collapsible section.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollapsibleField } from 'payload'
|
|
||||||
|
|
||||||
const collapsibleField: CollapsibleField = {
|
|
||||||
label: ({ data }) => data?.title || 'Advanced Options',
|
|
||||||
type: 'collapsible',
|
|
||||||
admin: {
|
|
||||||
initCollapsed: true,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'customCSS', type: 'textarea' },
|
|
||||||
{ name: 'customJS', type: 'code' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## UI (Custom Components)
|
|
||||||
|
|
||||||
UI fields allow fully custom React components in the admin (no data stored).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { UIField } from 'payload'
|
|
||||||
|
|
||||||
const uiField: UIField = {
|
|
||||||
name: 'customMessage',
|
|
||||||
type: 'ui',
|
|
||||||
admin: {
|
|
||||||
components: {
|
|
||||||
Field: '/path/to/CustomFieldComponent',
|
|
||||||
Cell: '/path/to/CustomCellComponent', // For list view
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tabs & Groups
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { TabsField, GroupField } from 'payload'
|
|
||||||
|
|
||||||
// Tabs
|
|
||||||
const tabsField: TabsField = {
|
|
||||||
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)
|
|
||||||
const groupField: GroupField = {
|
|
||||||
name: 'meta',
|
|
||||||
type: 'group',
|
|
||||||
fields: [
|
|
||||||
{ name: 'title', type: 'text' },
|
|
||||||
{ name: 'description', type: 'textarea' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reusable Field Factories
|
|
||||||
|
|
||||||
Create composable field patterns that can be customized with overrides.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Field, GroupField } from 'payload'
|
|
||||||
|
|
||||||
// Utility for deep merging
|
|
||||||
const deepMerge = <T>(target: T, source: Partial<T>): T => {
|
|
||||||
// Implementation would deeply merge objects
|
|
||||||
return { ...target, ...source }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reusable link field factory
|
|
||||||
type LinkType = (options?: {
|
|
||||||
appearances?: ('default' | 'outline')[] | false
|
|
||||||
disableLabel?: boolean
|
|
||||||
overrides?: Record<string, unknown>
|
|
||||||
}) => GroupField
|
|
||||||
|
|
||||||
export const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => {
|
|
||||||
const linkField: GroupField = {
|
|
||||||
name: 'link',
|
|
||||||
type: 'group',
|
|
||||||
admin: {
|
|
||||||
hideGutter: true,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
type: 'row',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'type',
|
|
||||||
type: 'radio',
|
|
||||||
options: [
|
|
||||||
{ label: 'Internal link', value: 'reference' },
|
|
||||||
{ label: 'Custom URL', value: 'custom' },
|
|
||||||
],
|
|
||||||
defaultValue: 'reference',
|
|
||||||
admin: {
|
|
||||||
layout: 'horizontal',
|
|
||||||
width: '50%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'newTab',
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Open in new tab',
|
|
||||||
admin: {
|
|
||||||
width: '50%',
|
|
||||||
style: {
|
|
||||||
alignSelf: 'flex-end',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'reference',
|
|
||||||
type: 'relationship',
|
|
||||||
relationTo: ['pages'],
|
|
||||||
required: true,
|
|
||||||
maxDepth: 1,
|
|
||||||
admin: {
|
|
||||||
condition: (_, siblingData) => siblingData?.type === 'reference',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'url',
|
|
||||||
type: 'text',
|
|
||||||
label: 'Custom URL',
|
|
||||||
required: true,
|
|
||||||
admin: {
|
|
||||||
condition: (_, siblingData) => siblingData?.type === 'custom',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!disableLabel) {
|
|
||||||
linkField.fields.push({
|
|
||||||
name: 'label',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appearances !== false) {
|
|
||||||
linkField.fields.push({
|
|
||||||
name: 'appearance',
|
|
||||||
type: 'select',
|
|
||||||
defaultValue: 'default',
|
|
||||||
options: [
|
|
||||||
{ label: 'Default', value: 'default' },
|
|
||||||
{ label: 'Outline', value: 'outline' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return deepMerge(linkField, overrides) as GroupField
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
const navItem = link({ appearances: false })
|
|
||||||
const ctaButton = link({
|
|
||||||
overrides: {
|
|
||||||
name: 'cta',
|
|
||||||
admin: {
|
|
||||||
description: 'Call to action button',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Field Type Guards
|
|
||||||
|
|
||||||
Type guards for runtime field type checking and safe type narrowing.
|
|
||||||
|
|
||||||
| Type Guard | Checks For | Use When |
|
|
||||||
| --------------------------- | ----------------------------------------------------------- | ---------------------------------------- |
|
|
||||||
| `fieldAffectsData` | Field stores data (has name, not UI-only) | Need to access field data or name |
|
|
||||||
| `fieldHasSubFields` | Field contains nested fields (group/array/row/collapsible) | Need to 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 (select/relationship/upload) | Check for `hasMany` support |
|
|
||||||
| `fieldHasMaxDepth` | Field supports population depth control | Control relationship/upload/join depth |
|
|
||||||
| `fieldIsPresentationalOnly` | Field is UI-only (no data storage) | 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 handling | Proper locale table checks |
|
|
||||||
| `fieldIsVirtual` | Field is virtual (computed/no DB column) | 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}` format | 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 |
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType } from 'payload'
|
|
||||||
|
|
||||||
function processField(field: Field) {
|
|
||||||
if (fieldAffectsData(field)) {
|
|
||||||
// Safe to access field.name
|
|
||||||
console.log(field.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldHasSubFields(field)) {
|
|
||||||
// Safe to access field.fields
|
|
||||||
field.fields.forEach(processField)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See [FIELD-TYPE-GUARDS.md](FIELD-TYPE-GUARDS.md) for detailed usage patterns.
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
# Payload CMS Hooks Reference
|
|
||||||
|
|
||||||
Complete reference for collection hooks, field hooks, and hook context patterns.
|
|
||||||
|
|
||||||
## Collection Hooks
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
hooks: {
|
|
||||||
// Before validation
|
|
||||||
beforeValidate: [
|
|
||||||
async ({ data, operation }) => {
|
|
||||||
if (operation === 'create') {
|
|
||||||
data.slug = slugify(data.title)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// Before save
|
|
||||||
beforeChange: [
|
|
||||||
async ({ data, req, operation, originalDoc }) => {
|
|
||||||
if (operation === 'update' && data.status === 'published') {
|
|
||||||
data.publishedAt = new Date()
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// After save
|
|
||||||
afterChange: [
|
|
||||||
async ({ doc, req, operation, previousDoc }) => {
|
|
||||||
if (operation === 'create') {
|
|
||||||
await sendNotification(doc)
|
|
||||||
}
|
|
||||||
return doc
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// After read
|
|
||||||
afterRead: [
|
|
||||||
async ({ doc, req }) => {
|
|
||||||
doc.viewCount = await getViewCount(doc.id)
|
|
||||||
return doc
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// Before delete
|
|
||||||
beforeDelete: [
|
|
||||||
async ({ req, id }) => {
|
|
||||||
await cleanupRelatedData(id)
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Field Hooks
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { EmailField, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailField: EmailField = {
|
|
||||||
name: 'email',
|
|
||||||
type: 'email',
|
|
||||||
hooks: {
|
|
||||||
beforeValidate: [beforeValidateHook],
|
|
||||||
afterRead: [afterReadHook],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hook Context
|
|
||||||
|
|
||||||
Share data between hooks or control hook behavior using request context:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fields: [{ name: 'title', type: 'text' }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next.js Revalidation with Context Control
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
|
||||||
import { revalidatePath } from 'next/cache'
|
|
||||||
import type { Page } from '../payload-types'
|
|
||||||
|
|
||||||
export const revalidatePage: CollectionAfterChangeHook<Page> = ({
|
|
||||||
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}`
|
|
||||||
payload.logger.info(`Revalidating old page at path: ${oldPath}`)
|
|
||||||
revalidatePath(oldPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return doc
|
|
||||||
}
|
|
||||||
|
|
||||||
export const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => {
|
|
||||||
if (!context.disableRevalidate) {
|
|
||||||
const path = doc?.slug === 'home' ? '/' : `/${doc?.slug}`
|
|
||||||
revalidatePath(path)
|
|
||||||
}
|
|
||||||
return doc
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Date Field Auto-Set
|
|
||||||
|
|
||||||
Automatically set date when document is published:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { DateField } from 'payload'
|
|
||||||
|
|
||||||
const publishedOnField: DateField = {
|
|
||||||
name: 'publishedOn',
|
|
||||||
type: 'date',
|
|
||||||
admin: {
|
|
||||||
date: {
|
|
||||||
pickerAppearance: 'dayAndTime',
|
|
||||||
},
|
|
||||||
position: 'sidebar',
|
|
||||||
},
|
|
||||||
hooks: {
|
|
||||||
beforeChange: [
|
|
||||||
({ siblingData, value }) => {
|
|
||||||
if (siblingData._status === 'published' && !value) {
|
|
||||||
return new Date()
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hook Patterns 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 (see [ADAPTERS.md#threading-req-through-operations](ADAPTERS.md#threading-req-through-operations))
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,274 +0,0 @@
|
|||||||
# Payload CMS Querying Reference
|
|
||||||
|
|
||||||
Complete reference for querying data across Local API, REST, and GraphQL.
|
|
||||||
|
|
||||||
## Query Operators
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Where } from 'payload'
|
|
||||||
|
|
||||||
// Equals
|
|
||||||
const equalsQuery: Where = { color: { equals: 'blue' } }
|
|
||||||
|
|
||||||
// Not equals
|
|
||||||
const notEqualsQuery: Where = { status: { not_equals: 'draft' } }
|
|
||||||
|
|
||||||
// Greater/less than
|
|
||||||
const greaterThanQuery: Where = { price: { greater_than: 100 } }
|
|
||||||
const lessThanEqualQuery: Where = { age: { less_than_equal: 65 } }
|
|
||||||
|
|
||||||
// Contains (case-insensitive)
|
|
||||||
const containsQuery: Where = { title: { contains: 'payload' } }
|
|
||||||
|
|
||||||
// Like (all words present)
|
|
||||||
const likeQuery: Where = { description: { like: 'cms headless' } }
|
|
||||||
|
|
||||||
// In/not in
|
|
||||||
const inQuery: Where = { category: { in: ['tech', 'news'] } }
|
|
||||||
|
|
||||||
// Exists
|
|
||||||
const existsQuery: Where = { image: { exists: true } }
|
|
||||||
|
|
||||||
// Near (point fields)
|
|
||||||
const nearQuery: Where = { location: { near: '-122.4194,37.7749,10000' } }
|
|
||||||
```
|
|
||||||
|
|
||||||
## AND/OR Logic
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Where } from 'payload'
|
|
||||||
|
|
||||||
const complexQuery: Where = {
|
|
||||||
or: [
|
|
||||||
{ color: { equals: 'mint' } },
|
|
||||||
{
|
|
||||||
and: [{ color: { equals: 'white' } }, { featured: { equals: false } }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Nested Properties
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Where } from 'payload'
|
|
||||||
|
|
||||||
const nestedQuery: Where = {
|
|
||||||
'author.role': { equals: 'editor' },
|
|
||||||
'meta.featured': { exists: true },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Local API
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Find documents
|
|
||||||
const posts = await payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
where: {
|
|
||||||
status: { equals: 'published' },
|
|
||||||
'author.name': { contains: 'john' },
|
|
||||||
},
|
|
||||||
depth: 2,
|
|
||||||
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' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Threading req Parameter
|
|
||||||
|
|
||||||
When performing operations in hooks or nested operations, pass the `req` parameter to maintain transaction context:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ✅ CORRECT: Pass req for transaction safety
|
|
||||||
const afterChange: CollectionAfterChangeHook = async ({ doc, req }) => {
|
|
||||||
await req.payload.create({
|
|
||||||
collection: 'audit-log',
|
|
||||||
data: { action: 'created', docId: doc.id },
|
|
||||||
req, // Maintains transaction atomicity
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ WRONG: Missing req breaks transaction
|
|
||||||
const afterChange: CollectionAfterChangeHook = async ({ doc, req }) => {
|
|
||||||
await req.payload.create({
|
|
||||||
collection: 'audit-log',
|
|
||||||
data: { action: 'created', docId: doc.id },
|
|
||||||
// Missing req - runs in separate transaction
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This is critical for MongoDB replica sets and Postgres. See [ADAPTERS.md#threading-req-through-operations](ADAPTERS.md#threading-req-through-operations) for details.
|
|
||||||
|
|
||||||
### Access Control in Local API
|
|
||||||
|
|
||||||
**Important**: Local API bypasses access control by default (`overrideAccess: true`). When passing a `user` parameter, you must explicitly set `overrideAccess: false` to respect that user's permissions.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ❌ WRONG: User is passed but access control is bypassed
|
|
||||||
const posts = await payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
user: currentUser,
|
|
||||||
// Missing: overrideAccess: false
|
|
||||||
// Result: Operation runs with ADMIN privileges, ignoring user's permissions
|
|
||||||
})
|
|
||||||
|
|
||||||
// ✅ CORRECT: Respects user's access control permissions
|
|
||||||
const posts = await payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
user: currentUser,
|
|
||||||
overrideAccess: false, // Required to enforce access control
|
|
||||||
// Result: User only sees posts they have permission to read
|
|
||||||
})
|
|
||||||
|
|
||||||
// Administrative operation (intentionally bypass access control)
|
|
||||||
const allPosts = await payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
// No user parameter
|
|
||||||
// overrideAccess defaults to true
|
|
||||||
// Result: Returns all posts regardless of access control
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to use `overrideAccess: false`:**
|
|
||||||
|
|
||||||
- Performing operations on behalf of a user
|
|
||||||
- Testing access control logic
|
|
||||||
- API routes that should respect user permissions
|
|
||||||
- Any operation where `user` parameter is provided
|
|
||||||
|
|
||||||
**When `overrideAccess: true` is appropriate:**
|
|
||||||
|
|
||||||
- Administrative operations (migrations, seeds, cron jobs)
|
|
||||||
- Internal system operations
|
|
||||||
- Operations explicitly intended to bypass access control
|
|
||||||
|
|
||||||
See [ACCESS-CONTROL.md#important-notes](ACCESS-CONTROL.md#important-notes) for more details.
|
|
||||||
|
|
||||||
## REST API
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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
|
|
||||||
|
|
||||||
```txt
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutation {
|
|
||||||
updatePost(id: "123", data: { status: published }) {
|
|
||||||
id
|
|
||||||
status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutation {
|
|
||||||
deletePost(id: "123") {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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`
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
# Payload CMS + Next.js Troubleshooting
|
|
||||||
|
|
||||||
## PostgreSQL Connection Issues
|
|
||||||
|
|
||||||
### Wrong port
|
|
||||||
- Docker container `payload-db-1` exposes MongoDB on port **27017** (default)
|
|
||||||
- Fix: Use `localhost:5555` in DATABASE_URL for local development
|
|
||||||
|
|
||||||
### Wrong database name
|
|
||||||
- Payload CMS expects database `payload` (matches `POSTGRES_DB=payload`)
|
|
||||||
- **NOT** `postgres` or `payloaddb`
|
|
||||||
- Working DATABASE_URL: `postgresql://payload:payloadpass@localhost:5555/payload`
|
|
||||||
|
|
||||||
### Wrong credentials
|
|
||||||
- Docker compose uses `POSTGRES_USER=payload` / `POSTGRES_PASSWORD=payloadpass`
|
|
||||||
- NOT the default `postgres:postgres`
|
|
||||||
|
|
||||||
### Schema not creating tables
|
|
||||||
**Symptom:** Admin page shows blank/white but HTML loads fine. Tables don't exist in DB.
|
|
||||||
|
|
||||||
**Root cause:** `payload migrate` may not have run or failed silently.
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
```bash
|
|
||||||
# 1. Stop dev server
|
|
||||||
pkill -f "next"
|
|
||||||
|
|
||||||
# 2. Run migration
|
|
||||||
cd /path/to/project
|
|
||||||
pnpm payload migrate --yes
|
|
||||||
# OR for fresh start:
|
|
||||||
pnpm payload migrate:fresh --yes
|
|
||||||
|
|
||||||
# 3. Verify tables created
|
|
||||||
PGPASSWORD=payloadpass psql -h localhost -p 5555 -U payload -d payload -c "\dt"
|
|
||||||
|
|
||||||
# 4. Restart dev server
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Admin Page Blank/White Screen
|
|
||||||
|
|
||||||
### Causes
|
|
||||||
|
|
||||||
1. **Browser cache from old deployment** — standalone mode serves old static file hashes
|
|
||||||
- Fix: Ctrl+Shift+R (hard refresh) or open Incognito window
|
|
||||||
|
|
||||||
2. **Static files not matching the build** — running standalone with dev `.next`
|
|
||||||
- Fix: Always `pnpm build` before running `node .next/standalone/server.js`
|
|
||||||
- OR just use `pnpm dev` for development
|
|
||||||
|
|
||||||
3. **Database tables don't exist** — Payload admin can't load without schema
|
|
||||||
- Fix: Run `pnpm payload migrate` to create tables
|
|
||||||
|
|
||||||
4. **WebSocket HMR errors** — not a real issue, just hot reload failing
|
|
||||||
- This is cosmetic and doesn't affect functionality
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
```bash
|
|
||||||
# Check if admin HTML loads
|
|
||||||
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/admin
|
|
||||||
# Should return 200
|
|
||||||
|
|
||||||
# Check if JS chunks load (may 404 in dev mode - OK)
|
|
||||||
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_next/static/chunks/0pmuyajd0waqg.js
|
|
||||||
|
|
||||||
# Check DB tables
|
|
||||||
PGPASSWORD=payloadpass psql -h localhost -p 5555 -U payload -d payload -c "\dt"
|
|
||||||
# Should show: media, payload_kv, posts, users, users_sessions, etc.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Payload Migration Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm payload migrate # Run pending migrations
|
|
||||||
pnpm payload migrate:fresh # Drop all tables and recreate (DANGEROUS)
|
|
||||||
pnpm payload migrate:reset # Reset migration history
|
|
||||||
pnpm generate:types # Generate TypeScript types
|
|
||||||
pnpm generate:importmap # Regenerate import map
|
|
||||||
```
|
|
||||||
|
|
||||||
## Payload CMS 3.x Breaking Changes
|
|
||||||
|
|
||||||
- `GRAPHQL_GET` → use `GRAPHQL_PLAYGROUND_GET` from `@payloadcms/next/routes`
|
|
||||||
- Collection config imports must use `import type { CollectionConfig } from 'payload'`
|
|
||||||
- `payload push` deprecated → use `payload migrate`
|
|
||||||
- PostgreSQL adapter in separate package: `@payloadcms/db-postgres`
|
|
||||||
- Rich text editor in separate package: `@payloadcms/richtext-lexical`
|
|
||||||
|
|
||||||
## Docker Compose for PostgreSQL
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: payload
|
|
||||||
POSTGRES_PASSWORD: payloadpass
|
|
||||||
POSTGRES_DB: payload
|
|
||||||
ports:
|
|
||||||
- '5432:5432' # Only if not already in use
|
|
||||||
```
|
|
||||||
|
|
||||||
DATABASE_URL: `postgresql://payload:***@localhost:5432/payload`
|
|
||||||
(Port depends on what's already mapped in docker-compose)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next.js 15.3.8 + React 19 SWC Bug (Critical)
|
|
||||||
|
|
||||||
### Symptom
|
|
||||||
Build หรือ dev server compile ส่ง SyntaxError แปลกๆ เช่น:
|
|
||||||
```
|
|
||||||
SyntaxError: Unexpected token (50:3)
|
|
||||||
49 | return (
|
|
||||||
> 50 | <>
|
|
||||||
| ^
|
|
||||||
```
|
|
||||||
เกิดขึ้นกับ **เฉพาะไฟล์ที่มี**:
|
|
||||||
1. Fragment shorthand `<>` (แทน `<React.Fragment>`)
|
|
||||||
2. **Thai text หรือ non-ASCII text** ใน JSX attributes/props ของ elements ภายใน fragment
|
|
||||||
|
|
||||||
ถ้าไฟล์มี `<>` แต่ไม่มี Thai text → compile ผ่าน
|
|
||||||
ถ้าไฟล์มี Thai text แต่ใช้ `<React.Fragment>` → compile ผ่าน
|
|
||||||
|
|
||||||
### Root Cause
|
|
||||||
Next.js 15.3.8 มี SWC compiler bug ที่ค้าง stale cache ของ SyntaxError ไว้แม้หลังแก้ไขไฟล์แล้ว
|
|
||||||
|
|
||||||
### Workaround (2 วิธี)
|
|
||||||
**วิธีที่ 1 — เปลี่ยนจาก `<>` เป็น `<React.Fragment>` หรือ `<Fragment>`:**
|
|
||||||
```tsx
|
|
||||||
import { Fragment } from 'react'
|
|
||||||
// แทน:
|
|
||||||
return <>
|
|
||||||
<div>...</div>
|
|
||||||
</>
|
|
||||||
// ใช้:
|
|
||||||
return <Fragment><div>...</div></Fragment>
|
|
||||||
```
|
|
||||||
|
|
||||||
**วิธีที่ 2 — เขียน component ใหม่ทั้งหมด (แนะนำ):**
|
|
||||||
ถ้า component มี fragment shorthand + Thai text เยอะ ให้เขียนใหม่โดยใช้ pattern ที่ไม่มีปัญหา:
|
|
||||||
- ใส่ `return (...)` โดยไม่มี `<>` ครอบ
|
|
||||||
- ใช้ wrapper `<div>` แทน fragment ถ้าเป็นไปได้
|
|
||||||
- ถ้าต้องใช้ fragment ใช้ `<Fragment>`
|
|
||||||
|
|
||||||
### How to Detect
|
|
||||||
```bash
|
|
||||||
# ดูว่าไฟล์มี fragment shorthand และ Thai text หรือไม่
|
|
||||||
grep -l "<>" src/app/\(frontend\)/**/*.tsx | xargs grep -l "[ก-๙]"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prevention
|
|
||||||
หลีกเลี่ยงการใช้ `<>` shorthand ใน component ที่มี Thai text — ใช้ `<div>` wrapper หรือ `<Fragment>` แทนเสมอ
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ConsentLogs: Default Export Required
|
|
||||||
|
|
||||||
Payload CMS บางเวอร์ชัน require ว่า collection config ที่สร้างเองต้องใช้ **default export** ไม่ใช่ named export
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ✅ ถูกต้อง
|
|
||||||
const ConsentLogs: CollectionConfig = { ... }
|
|
||||||
export default ConsentLogs
|
|
||||||
|
|
||||||
// ❌ ผิด — named export จะทำให้ Payload มองไม่เห็น collection
|
|
||||||
export const ConsentLogs = { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
ถ้า collection ไม่ปรากฏใน Payload admin → ตรวจสอบว่าใช้ `export default` ไม่ใช่ `export const`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Payload Access Functions: Must Be Separate File
|
|
||||||
|
|
||||||
Payload CMS ไม่รู้จัก `access` property ที่เป็น inline function ใน collection config — ต้องแยกออกมาเป็นไฟล์
|
|
||||||
|
|
||||||
**ถูกต้อง:** `src/collections/access.ts`
|
|
||||||
```ts
|
|
||||||
import type { Access } from 'payload'
|
|
||||||
|
|
||||||
export const admins: Access = () => true
|
|
||||||
export const anyone: Access = () => true
|
|
||||||
```
|
|
||||||
|
|
||||||
**แล้ว import ใน collection:**
|
|
||||||
```ts
|
|
||||||
import { admins } from './access'
|
|
||||||
const MyCollection: CollectionConfig = {
|
|
||||||
access: { create: admins },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**ผิด:** inline function ใน collection config จะถูก strip หรือไม่ทำงาน
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dev Mode: IP Access + allowedDevOrigins
|
|
||||||
|
|
||||||
เมื่อรัน dev server แล้วเข้าผ่าน IP address (เช่น `110.164.146.185:3000`) จะมี warning:
|
|
||||||
```
|
|
||||||
Access to server at IP from the development server is blocked by CORS policy.
|
|
||||||
allowedDevOrigins
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fix: เพิ่ม allowedDevOrigins ใน next.config.ts
|
|
||||||
```ts
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
allowedDevOrigins: ['110.164.146.185', '110.164.146.185:3000'],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker: อย่าลืม Restart + Clear Cache หลังแก้ไข
|
|
||||||
```bash
|
|
||||||
docker exec <container> rm -rf /home/node/app/.next
|
|
||||||
docker restart <container>
|
|
||||||
# รอ warm up 10-40 วินาที แล้วค่อยเทสต์
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SWC Cache: Stale Cache หลังแก้ไข Error
|
|
||||||
|
|
||||||
ถ้าแก้ไข syntax error แล้ว dev server ยังแสดง error เดิม → SWC cache ค้าง
|
|
||||||
|
|
||||||
**วิธีแก้:**
|
|
||||||
```bash
|
|
||||||
# ลบ .next cache
|
|
||||||
rm -rf .next
|
|
||||||
|
|
||||||
# ถ้าใช้ Docker
|
|
||||||
docker exec <container> rm -rf /home/node/app/.next
|
|
||||||
docker restart <container>
|
|
||||||
```
|
|
||||||
|
|
||||||
**สาเหตุ:** Next.js 15 SWC compiler cache ระดับ binary ค้างอยู่ใน `.next/cache/swc`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## sitemap.xml Route (Next.js App Router)
|
|
||||||
|
|
||||||
`MetadataRoute.Sitemap` as a **default export function** fails with 500/timeout in Next.js App Router. The correct pattern:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ✅ ถูกต้อง — ใช้ GET handler + new Response()
|
|
||||||
export async function GET(): Promise<Response> {
|
|
||||||
const pages = [/* ... */]
|
|
||||||
|
|
||||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
${pages.map(p => ` <url><loc>${p.url}</loc>...</url>`).join('\n')}
|
|
||||||
</urlset>`
|
|
||||||
|
|
||||||
return new Response(xml, {
|
|
||||||
headers: { 'Content-Type': 'application/xml' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ ผิด — MetadataRoute.Sitemap as default export
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
||||||
// ...returns array — causes 500 in some Next.js versions
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Payload first request ช้ามาก (7-35s) ทำให้ sitemap timeout — ใช้ fallback static data:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const STATIC_PAGES = [
|
|
||||||
{ url: 'https://example.com/', priority: 1.0, changefreq: 'weekly' },
|
|
||||||
// ...
|
|
||||||
]
|
|
||||||
|
|
||||||
export async function GET(): Promise<Response> {
|
|
||||||
let pages: string[] = []
|
|
||||||
try {
|
|
||||||
const payload = await getPayload({ config })
|
|
||||||
const { docs } = await payload.find({ collection: 'pages', limit: 100 })
|
|
||||||
pages = docs.map(d => d.slug as string)
|
|
||||||
} catch {
|
|
||||||
// Payload unavailable — use static fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...build XML
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critical: `devBundleServerPackages: false` + `.next` Cache Clear = Total Failure
|
|
||||||
|
|
||||||
**Symptom:** หลังลบ `.next` cache แล้ว restart dev server — ทุกหน้ารวม `/` เป็น **500 error** พร้อม:
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Failed to load external module payload-e448a27c99c096d3
|
|
||||||
Cannot find package 'payload-e448a27c99c096d3'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Root Cause:** `withPayload(nextConfig, { devBundleServerPackages: false })` บอก Payload ว่าไม่ต้อง bundle Payload packages ลงใน `.next` แต่ Turbopack ยังอ้างถึง bundled chunk names เดิมจาก cache ที่ถูกลบไปแล้ว
|
|
||||||
|
|
||||||
**Fix:** ลบ `{ devBundleServerPackages: false }` ออก — ใช้แค่ `withPayload(nextConfig)`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ✅ ถูกต้อง
|
|
||||||
export default withPayload(nextConfig)
|
|
||||||
|
|
||||||
// ❌ ลบออก — ทำให้ล้มเหลวหลัง clear .next cache
|
|
||||||
export default withPayload(nextConfig, { devBundleServerPackages: false })
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prevention:** ถ้าต้อง clear `.next` cache เพราะ cache มีปัญหา ให้ลบ `devBundleServerPackages: false` ก่อน restart dev server
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## robots.txt Route (Next.js App Router)
|
|
||||||
|
|
||||||
`MetadataRoute.Robots` as default export function causes `TypeError: NextResponse.text is not a function` error. Must use explicit GET:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ✅ ถูกต้อง
|
|
||||||
export async function GET() {
|
|
||||||
return new Response('User-agent: *\nAllow: /\nDisallow: /admin\n', {
|
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ ผิด — MetadataRoute.Robots default export
|
|
||||||
export default function robots(): Promise<MetadataRoute.Robots> {
|
|
||||||
return Promise.resolve({ rules: { userAgent: '*', allow: '/' } })
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## robots.txt Route (Next.js App Router)
|
|
||||||
|
|
||||||
**Two patterns that cause 500:**
|
|
||||||
|
|
||||||
1. `MetadataRoute.Robots` as default export — บาง version ทำให้ `TypeError: NextResponse.text is not a function`
|
|
||||||
|
|
||||||
2. **Cached file conflict** — ถ้ามี file `app/robots.txt` (ไม่ใช่ route.ts) หรือ cached file ใน `.next/dev/server/app/` อยู่ จะทำให้ route.ts handler ถูก ignore แล้ว return empty response
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ✅ ถูกต้อง
|
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
return NextResponse.text(
|
|
||||||
`User-agent: *
|
|
||||||
Allow: /
|
|
||||||
Disallow: /admin
|
|
||||||
Disallow: /api/
|
|
||||||
|
|
||||||
Sitemap: https://www.example.com/sitemap.xml
|
|
||||||
`,
|
|
||||||
{ headers: { 'Content-Type': 'text/plain; charset=utf-8' } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**ถ้า robots.txt เป็น 500 หรือว่างเปล่า:** ตรวจสอบว่าไม่มี `robots.txt` file ตรง (แทน route.ts) และลบ `.next` cache:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm -rf .next
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## sitemap.xml: Array Return = 500 Error
|
|
||||||
|
|
||||||
**Symptom:** `GET /sitemap.xml` returns 500 — log บอกว่าได้ `Array` แทน `Response`
|
|
||||||
|
|
||||||
**Root Cause:** Route handler ส่ง array ไปแทน Response object (เช่น `return [...pages, ...posts]`)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ❌ ผิด — array ไม่ใช่ Response
|
|
||||||
export async function GET() {
|
|
||||||
const pages = await getPages()
|
|
||||||
return pages // ← 500 error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ ถูกต้อง
|
|
||||||
export async function GET() {
|
|
||||||
const pages = await getPages()
|
|
||||||
const xml = buildSitemapXml(pages)
|
|
||||||
return new Response(xml, {
|
|
||||||
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `/sitemap` Page Conflicts with `/sitemap.xml` Route
|
|
||||||
|
|
||||||
ถ้ามีทั้ง `app/sitemap/page.tsx` และ `app/sitemap.xml/route.ts` — Next.js จะ route ไปที่ page.tsx ก่อน ทำให้ `/sitemap.xml` เป็น **404**
|
|
||||||
|
|
||||||
**Fix:** ลบ `app/sitemap/` directory ถ้ามี sitemap.xml route:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm -rf app/sitemap/
|
|
||||||
```
|
|
||||||
|
|
||||||
ตรวจสอบ: `ls app/` อย่างน้อยต้องมีไฟล์ `.xml` ไม่ใช่ directory ที่ชื่อเดียวกัน
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bulk Insert Posts ใน MongoDB (Direct via mongosh)
|
|
||||||
|
|
||||||
เมื่อ Payload REST API (`POST /api/posts`) ตอบ `500: Something went wrong` เวลา insert richText/Lexical field โดยตรง สามารถใช้ **direct MongoDB insert** แทนได้
|
|
||||||
|
|
||||||
### วิธีทำ
|
|
||||||
```bash
|
|
||||||
# เขียน script เป็นไฟล์ .cjs (CommonJS)
|
|
||||||
# รันโดยตรงจาก host (ไม่ต้องเข้า container)
|
|
||||||
node seed-mongo.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
### หา MongoDB URL
|
|
||||||
```bash
|
|
||||||
grep MONGODB_URL .env
|
|
||||||
# ถ้าใช้ Docker: mongodb://localhost:27017/portal-mini-store
|
|
||||||
# ถ้าใช้ Atlas: mongodb+srv://user:pass@cluster.mongodb.net/dbname
|
|
||||||
```
|
|
||||||
|
|
||||||
### Payload SDK Seed Fails ด้วย spawn Error
|
|
||||||
ถ้า seed script ที่ใช้ Payload SDK (`getPayload()`) ขึ้น error เช่น `spawn is not defined` หรือ `node not found` — นั่นคือ Payload SDK ภายในมีการ `spawn('node')` ซึ่งล้มเหลวในบาง environment
|
|
||||||
|
|
||||||
**วิธีแก้: ใช้ MongoDB driver โดยตรง (CommonJS)**
|
|
||||||
```js
|
|
||||||
// seed-mongo.cjs — CommonJS เท่านั้น (require, not import)
|
|
||||||
const { MongoClient } = require('mongodb')
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const client = new MongoClient(process.env.MONGODB_URL)
|
|
||||||
await client.connect()
|
|
||||||
const db = client.db()
|
|
||||||
|
|
||||||
// insert posts
|
|
||||||
const posts = [/* ... */]
|
|
||||||
for (const post of posts) {
|
|
||||||
const result = await db.collection('posts').insertOne({
|
|
||||||
...post,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
console.log('Inserted:', post.title, result.insertedId)
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lexical Content Format ขั้นต่ำ
|
|
||||||
```js
|
|
||||||
content: {
|
|
||||||
root: {
|
|
||||||
type: 'root',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: 'paragraph',
|
|
||||||
children: [{ type: 'text', text: 'your excerpt or content here' }]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### หา Mongo Container Name
|
|
||||||
```bash
|
|
||||||
docker ps --format '{{.Names}}' # ดู container names
|
|
||||||
# ถ้าใช้ docker-compose จะเป็น <project>-mongo หรือ <project>-db
|
|
||||||
```
|
|
||||||
|
|
||||||
### ตรวจสอบว่า Posts ถูก Insert แล้วผ่าน Payload API
|
|
||||||
```bash
|
|
||||||
docker exec <app-container> node -e "
|
|
||||||
fetch('http://localhost:3000/api/posts?limit=15')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => { console.log('Total:', d.totalDocs); d.docs.forEach(p => console.log(' -', p.title)); })
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
### ข้อควรระวัง
|
|
||||||
- Insert ตรงๆ ผ่าน MongoDB จะ bypass Payload access control
|
|
||||||
- ถ้ามี auth token ต้องใช้ Payload API แทน
|
|
||||||
- richText field ต้องเป็น Lexical JSON format (ดูด้านบน)
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#===============================================================================
|
|
||||||
# migrate-to-payload.sh - Migrate Astro content to Payload CMS with Lexical
|
|
||||||
#
|
|
||||||
# Usage: ./migrate-to-payload.sh [source-path] [target-path]
|
|
||||||
#
|
|
||||||
# This script migrates content from Astro MDX/Markdown to Payload CMS Lexical.
|
|
||||||
# - Converts .md/.mdx files to Payload CMS Lexical JSON format
|
|
||||||
# - Creates Payload collection entries
|
|
||||||
# - Preserves frontmatter as collection fields
|
|
||||||
#
|
|
||||||
# Requirements:
|
|
||||||
# - node.js 20+
|
|
||||||
# - npm
|
|
||||||
#
|
|
||||||
#===============================================================================
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
SOURCE_PATH="${1:-}"
|
|
||||||
TARGET_PATH="${2:-.}"
|
|
||||||
|
|
||||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
|
||||||
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
|
||||||
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
|
||||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
|
||||||
|
|
||||||
print_usage() {
|
|
||||||
cat << EOF
|
|
||||||
Usage: $(basename "$0") [source-path] [target-path]
|
|
||||||
|
|
||||||
Migrate Astro content to Payload CMS with Lexical
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
source-path Path to Astro project with content
|
|
||||||
target-path Path to Next.js + Payload CMS project
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
$(basename "$0") /path/to/astro-site /path/to/payload-site
|
|
||||||
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
detect_content_type() {
|
|
||||||
log_info "Detecting content structure..."
|
|
||||||
|
|
||||||
cd "$SOURCE_PATH"
|
|
||||||
|
|
||||||
if [ -d "src/content" ]; then
|
|
||||||
CONTENT_DIR="src/content"
|
|
||||||
elif [ -d "content" ]; then
|
|
||||||
CONTENT_DIR="content"
|
|
||||||
elif [ -d "src/pages" ]; then
|
|
||||||
CONTENT_DIR="src/pages"
|
|
||||||
else
|
|
||||||
log_error "No content directory found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log_success "Content directory: $CONTENT_DIR"
|
|
||||||
}
|
|
||||||
|
|
||||||
backup_content() {
|
|
||||||
log_info "Backing up content..."
|
|
||||||
|
|
||||||
BACKUP_DIR="/tmp/migration-backup-$(date +%s)"
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
|
|
||||||
if [ -d "$SOURCE_PATH/$CONTENT_DIR" ]; then
|
|
||||||
cp -r "$SOURCE_PATH/$CONTENT_DIR" "$BACKUP_DIR/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log_success "Backup at: $BACKUP_DIR"
|
|
||||||
}
|
|
||||||
|
|
||||||
analyze_content() {
|
|
||||||
log_info "Analyzing content..."
|
|
||||||
|
|
||||||
cd "$SOURCE_PATH"
|
|
||||||
|
|
||||||
local md_count=$(find "$CONTENT_DIR" -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | wc -l)
|
|
||||||
local astro_count=$(find . -type f -name "*.astro" 2>/dev/null | grep -v node_modules | wc -l)
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " Content files: $md_count"
|
|
||||||
echo " Astro components: $astro_count"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
find "$CONTENT_DIR" -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | head -20
|
|
||||||
}
|
|
||||||
|
|
||||||
create_lexical_content() {
|
|
||||||
log_info "Converting MDX to Payload CMS Lexical format..."
|
|
||||||
|
|
||||||
cd "$SOURCE_PATH"
|
|
||||||
|
|
||||||
local output_dir="$TARGET_PATH/src/content-migration"
|
|
||||||
mkdir -p "$output_dir"
|
|
||||||
|
|
||||||
find "$CONTENT_DIR" -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | while read -r file; do
|
|
||||||
local relative_path="${file#$SOURCE_PATH/$CONTENT_DIR/}"
|
|
||||||
local filename=$(basename "$file" .mdx .md | sed 's/\.mdx$//' | sed 's/\.md$//')
|
|
||||||
local slug=$(echo "$filename" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
|
|
||||||
|
|
||||||
local frontmatter=""
|
|
||||||
local content=""
|
|
||||||
|
|
||||||
if grep -q "^---" "$file" 2>/dev/null; then
|
|
||||||
frontmatter=$(sed -n '/^---/,/^---/p' "$file" | head -n -1 | tail -n +2)
|
|
||||||
content=$(awk '/^---/{found=1; next} found' "$file")
|
|
||||||
else
|
|
||||||
content=$(cat "$file")
|
|
||||||
fi
|
|
||||||
|
|
||||||
local title=$(echo "$frontmatter" | grep -i "^title:" | cut -d':' -f2- | tr -d ' "' | head -1)
|
|
||||||
local date=$(echo "$frontmatter" | grep -i "^date:" | cut -d':' -f2- | tr -d ' "' | head -1)
|
|
||||||
local description=$(echo "$frontmatter" | grep -i "^description:" | cut -d':' -f2- | tr -d ' "' | head -1)
|
|
||||||
local author=$(echo "$frontmatter" | grep -i "^author:" | cut -d':' -f2- | tr -d ' "' | head -1)
|
|
||||||
local image=$(echo "$frontmatter" | grep -i "^image:" | cut -d':' -f2- | tr -d ' "' | head -1)
|
|
||||||
local tags=$(echo "$frontmatter" | grep -i "^tags:" | cut -d':' -f2- | tr -d '[]"' | head -1)
|
|
||||||
|
|
||||||
title=${title:-$filename}
|
|
||||||
date=${date:-$(date +%Y-%m-%d)}
|
|
||||||
|
|
||||||
cat > "$output_dir/${slug}.json" << JSONEOF
|
|
||||||
{
|
|
||||||
"title": "$title",
|
|
||||||
"slug": "$slug",
|
|
||||||
"createdAt": "$date",
|
|
||||||
"updatedAt": "$(date +%Y-%m-%d)",
|
|
||||||
"meta": {
|
|
||||||
"title": "$title",
|
|
||||||
"description": "$description"
|
|
||||||
},
|
|
||||||
"author": "$author",
|
|
||||||
"heroImage": "$image",
|
|
||||||
"tags": ["$tags"],
|
|
||||||
"content": {
|
|
||||||
"root": {
|
|
||||||
"type": "root",
|
|
||||||
"format": "",
|
|
||||||
"indent": 0,
|
|
||||||
"version": 1,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"type": "paragraph",
|
|
||||||
"version": 1,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"version": 1,
|
|
||||||
"text": "$content",
|
|
||||||
"mode": "tokenized",
|
|
||||||
"style": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSONEOF
|
|
||||||
|
|
||||||
echo " Converted: $filename → $slug.json"
|
|
||||||
done
|
|
||||||
|
|
||||||
log_success "Conversion complete: $output_dir/"
|
|
||||||
}
|
|
||||||
|
|
||||||
create_payload_import_script() {
|
|
||||||
log_info "Creating Payload import script..."
|
|
||||||
|
|
||||||
local output_dir="$TARGET_PATH/scripts"
|
|
||||||
mkdir -p "$output_dir"
|
|
||||||
|
|
||||||
cat > "$output_dir/import-content.ts" << 'TSEOF'
|
|
||||||
import { payload } from '../src/lib/payload'
|
|
||||||
import { promises as fs } from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
async function importContent() {
|
|
||||||
const contentDir = path.join(process.cwd(), 'src/content-migration')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const files = await fs.readdir(contentDir)
|
|
||||||
const jsonFiles = files.filter(f => f.endsWith('.json'))
|
|
||||||
|
|
||||||
for (const file of jsonFiles) {
|
|
||||||
const filePath = path.join(contentDir, file)
|
|
||||||
const content = JSON.parse(await fs.readFile(filePath, 'utf-8'))
|
|
||||||
|
|
||||||
await payload.create({
|
|
||||||
collection: 'posts',
|
|
||||||
data: {
|
|
||||||
title: content.title,
|
|
||||||
slug: content.slug,
|
|
||||||
createdAt: content.createdAt,
|
|
||||||
updatedAt: content.updatedAt,
|
|
||||||
meta: content.meta,
|
|
||||||
author: content.author,
|
|
||||||
heroImage: content.heroImage,
|
|
||||||
tags: content.tags,
|
|
||||||
content: content.content,
|
|
||||||
_status: 'published',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`Imported: ${content.title}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nSuccessfully imported ${jsonFiles.length} posts`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Import failed:', error)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
importContent()
|
|
||||||
TSEOF
|
|
||||||
|
|
||||||
log_success "Created: $output_dir/import-content.ts"
|
|
||||||
}
|
|
||||||
|
|
||||||
create_migration_report() {
|
|
||||||
log_info "Creating migration report..."
|
|
||||||
|
|
||||||
cd "$SOURCE_PATH"
|
|
||||||
|
|
||||||
local page_count=$(find "$CONTENT_DIR" -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | wc -l)
|
|
||||||
|
|
||||||
cat > "$TARGET_PATH/MIGRATION_REPORT.md" << EOF
|
|
||||||
# Migration Report: Astro → Payload CMS
|
|
||||||
|
|
||||||
## Source
|
|
||||||
- **Type:** Astro
|
|
||||||
- **Path:** $SOURCE_PATH
|
|
||||||
- **Backup:** $BACKUP_DIR
|
|
||||||
- **Date:** $(date)
|
|
||||||
|
|
||||||
## Statistics
|
|
||||||
- **Total Posts:** $page_count
|
|
||||||
|
|
||||||
## Content Migration
|
|
||||||
|
|
||||||
Content has been converted to Payload CMS Lexical JSON format in:
|
|
||||||
\`\`\`
|
|
||||||
src/content-migration/
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Review converted content:**
|
|
||||||
\`\`\`bash
|
|
||||||
ls src/content-migration/
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
2. **Configure Payload collection:**
|
|
||||||
Make sure you have a 'posts' collection in \`src/collections/Posts.ts\`
|
|
||||||
|
|
||||||
3. **Import content to Payload:**
|
|
||||||
\`\`\`bash
|
|
||||||
npx tsx scripts/import-content.ts
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
4. **Verify in admin:**
|
|
||||||
- Go to http://localhost:3002/admin
|
|
||||||
- Navigate to Posts collection
|
|
||||||
- Verify content and rich text editor (Lexical)
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- MDX/Markdown content is converted to Lexical JSON format
|
|
||||||
- Frontmatter fields (title, date, description) are mapped to collection fields
|
|
||||||
- Complex MDX components need manual conversion in Payload admin
|
|
||||||
- Images need to be re-uploaded to Payload Media
|
|
||||||
EOF
|
|
||||||
|
|
||||||
log_success "Migration report: $TARGET_PATH/MIGRATION_REPORT.md"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
echo "=============================================="
|
|
||||||
echo " Astro → Payload CMS Migration Tool"
|
|
||||||
echo " Convert MDX/MD to Payload CMS with Lexical"
|
|
||||||
echo "=============================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then
|
|
||||||
print_usage
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$SOURCE_PATH" ]; then
|
|
||||||
print_usage
|
|
||||||
echo ""
|
|
||||||
log_error "Please specify source path"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d "$SOURCE_PATH" ]; then
|
|
||||||
log_error "Source path not found: $SOURCE_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d "$TARGET_PATH" ]; then
|
|
||||||
log_error "Target path not found: $TARGET_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
detect_content_type
|
|
||||||
backup_content
|
|
||||||
analyze_content
|
|
||||||
create_lexical_content
|
|
||||||
create_payload_import_script
|
|
||||||
create_migration_report
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=============================================="
|
|
||||||
log_success "Migration preparation complete!"
|
|
||||||
echo "=============================================="
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo " 1. cd $TARGET_PATH"
|
|
||||||
echo " 2. Review converted content in src/content-migration/"
|
|
||||||
echo " 3. Run: npm run dev"
|
|
||||||
echo " 4. Import: npx tsx scripts/import-content.ts"
|
|
||||||
echo " 5. Verify in Payload admin (http://localhost:3002/admin)"
|
|
||||||
echo ""
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
327
skills/website-creator/scripts/install-tina-backend.sh
Executable file
327
skills/website-creator/scripts/install-tina-backend.sh
Executable file
@@ -0,0 +1,327 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
BACKEND_PATH="${1:-./tina-backend}"
|
||||||
|
|
||||||
|
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||||
|
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||||
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
print_usage() {
|
||||||
|
cat << EOF
|
||||||
|
Usage: $(basename "$0") [target-path]
|
||||||
|
|
||||||
|
Install Tina CMS Backend (self-hosted)
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
target-path Path where Tina backend will be installed (default: ./tina-backend)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") /opt/tina-backend
|
||||||
|
|
||||||
|
This script installs a self-hosted Tina CMS backend with:
|
||||||
|
- Auth.js authentication
|
||||||
|
- SQLite database adapter
|
||||||
|
- Git provider for content
|
||||||
|
- Next.js API routes
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Node.js 18+
|
||||||
|
- npm or yarn
|
||||||
|
- git
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
echo "=============================================="
|
||||||
|
echo " Tina CMS Backend Installer (Self-Hosted)"
|
||||||
|
echo "=============================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then
|
||||||
|
print_usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "$BACKEND_PATH" ]; then
|
||||||
|
log_error "Directory already exists: $BACKEND_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Creating Tina backend at: $BACKEND_PATH"
|
||||||
|
mkdir -p "$BACKEND_PATH"
|
||||||
|
cd "$BACKEND_PATH"
|
||||||
|
|
||||||
|
log_info "Creating package.json..."
|
||||||
|
cat > package.json << 'PKGEOF'
|
||||||
|
{
|
||||||
|
"name": "tina-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tinacms dev",
|
||||||
|
"build": "tinacms build",
|
||||||
|
"start": "tinacms start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/core": "^0.34.0",
|
||||||
|
"@auth/drizzle-adapter": "^1.4.0",
|
||||||
|
"@libsql/client": "^0.14.0",
|
||||||
|
"@tinacms/auth": "^2.0.0",
|
||||||
|
"@tinacms/database": "^2.0.0",
|
||||||
|
"@tinacms/git-provider": "^2.0.0",
|
||||||
|
"@tinacms/graphql": "^2.0.0",
|
||||||
|
"@tinacms/mssql": "^2.0.0",
|
||||||
|
"@tinacms/server": "^2.0.0",
|
||||||
|
"drizzle-orm": "^0.38.0",
|
||||||
|
"next": "^14.0.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0",
|
||||||
|
"tinacms": "^2.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PKGEOF
|
||||||
|
|
||||||
|
log_info "Creating TypeScript config..."
|
||||||
|
cat > tsconfig.json << 'TSEOF'
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "tina/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
TSEOF
|
||||||
|
|
||||||
|
mkdir -p src/app/api/auth/\[...nextauth\]
|
||||||
|
mkdir -p src/app/api/tina/\[\[...tina\]\]
|
||||||
|
mkdir -p tina
|
||||||
|
|
||||||
|
log_info "Creating Auth.js configuration..."
|
||||||
|
cat > src/auth.config.ts << 'AUTHEOF'
|
||||||
|
import { AuthConfig } from "@auth/core/types";
|
||||||
|
|
||||||
|
const authConfig: AuthConfig = {
|
||||||
|
secret: process.env.NEXTAUTH_SECRET || "your-secret-change-in-production",
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: "github",
|
||||||
|
name: "GitHub",
|
||||||
|
type: "oauth",
|
||||||
|
clientId: process.env.GITHUB_ID || "",
|
||||||
|
clientSecret: process.env.GITHUB_SECRET || "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user && token.sub) {
|
||||||
|
session.user.email = token.email as string;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/auth/signin",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default authConfig;
|
||||||
|
AUTHEOF
|
||||||
|
|
||||||
|
log_info "Creating NextAuth API route..."
|
||||||
|
cat > 'src/app/api/auth/[...nextauth]/route.ts' << 'NEXTAUTHEOF'
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { AuthHandler } from "@auth/core";
|
||||||
|
import authConfig from "../../../auth.config";
|
||||||
|
|
||||||
|
const authHandler = (req: NextRequest) =>
|
||||||
|
AuthHandler({
|
||||||
|
...authConfig,
|
||||||
|
req: req as any,
|
||||||
|
resolve(): Promise<any> {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
secret: authConfig.secret!,
|
||||||
|
trustHost: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export { authHandler as GET, authHandler as POST };
|
||||||
|
NEXTAUTHEOF
|
||||||
|
|
||||||
|
log_info "Creating Tina API route..."
|
||||||
|
cat > 'src/app/api/tina/[[...tina]]/route.ts' << 'TINAEOF'
|
||||||
|
import { TinaNodeBackend } from "@tinacms/server";
|
||||||
|
import authConfig from "../../../../auth.config";
|
||||||
|
import { branchName } from "./branch";
|
||||||
|
|
||||||
|
const tinaBackend = TinaNodeBackend({
|
||||||
|
authConfig: authConfig as any,
|
||||||
|
branch: branchName,
|
||||||
|
});
|
||||||
|
|
||||||
|
export { tinaBackend as GET, tinaBackend as POST };
|
||||||
|
TINAEOF
|
||||||
|
|
||||||
|
log_info "Creating Tina branch configuration..."
|
||||||
|
cat > src/app/api/tina/branch.ts << 'BRANCHEMAP'
|
||||||
|
export const branchName = process.env.TINA_BRANCH || "main";
|
||||||
|
BRANCHEMAP
|
||||||
|
|
||||||
|
log_info "Creating database schema..."
|
||||||
|
mkdir -p src/lib
|
||||||
|
cat > src/lib/schema.ts << 'DBEOF'
|
||||||
|
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const users = sqliteTable("users", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name"),
|
||||||
|
email: text("email").unique(),
|
||||||
|
emailVerified: integer("email_verified", { mode: "boolean" }),
|
||||||
|
image: text("image"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const accounts = sqliteTable("accounts", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.references(() => users.id)
|
||||||
|
.notNull(),
|
||||||
|
type: text("type").notNull(),
|
||||||
|
provider: text("provider").notNull(),
|
||||||
|
providerAccountId: text("provider_account_id").notNull(),
|
||||||
|
refresh_token: text("refresh_token"),
|
||||||
|
access_token: text("access_token"),
|
||||||
|
expires_at: integer("expires_at"),
|
||||||
|
token_type: text("token_type"),
|
||||||
|
scope: text("scope"),
|
||||||
|
id_token: text("id_token"),
|
||||||
|
session_state: text("session_state"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessions = sqliteTable("sessions", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
sessionToken: text("session_token").unique(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.references(() => users.id)
|
||||||
|
.notNull(),
|
||||||
|
expires: integer("expires", { mode: "timestamp" }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const verificationTokens = sqliteTable("verification_tokens", {
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
token: text("token").notNull(),
|
||||||
|
expires: integer("expires", { mode: "timestamp" }).notNull(),
|
||||||
|
});
|
||||||
|
DBEOF
|
||||||
|
|
||||||
|
log_info "Creating database client..."
|
||||||
|
cat > src/lib/db.ts << 'DBCEOF'
|
||||||
|
import { createClient } from "@libsql/client";
|
||||||
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
url: process.env.DATABASE_URL || "file:local.db",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
|
DBCEOF
|
||||||
|
|
||||||
|
log_info "Creating environment template..."
|
||||||
|
cat > .env.example << 'ENVEOF'
|
||||||
|
NEXTAUTH_SECRET=generate-a-random-secret-here
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
GITHUB_ID=your-github-oauth-app-client-id
|
||||||
|
GITHUB_SECRET=your-github-oauth-app-client-secret
|
||||||
|
|
||||||
|
DATABASE_URL=file:local.db
|
||||||
|
TINA_BRANCH=main
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
log_info "Creating README..."
|
||||||
|
cat > README.md << 'READMEEOF'
|
||||||
|
# Tina CMS Backend (Self-Hosted)
|
||||||
|
|
||||||
|
Self-hosted Tina CMS backend with Auth.js authentication and SQLite database.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure environment:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set up GitHub OAuth App:
|
||||||
|
- Go to https://github.com/settings/developers
|
||||||
|
- Create a new OAuth App
|
||||||
|
- Set callback URL to: `http://your-domain.com/api/auth/callback/github`
|
||||||
|
|
||||||
|
4. Start development:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| NEXTAUTH_SECRET | Random secret for NextAuth |
|
||||||
|
| NEXTAUTH_URL | Your site URL |
|
||||||
|
| GITHUB_ID | GitHub OAuth Client ID |
|
||||||
|
| GITHUB_SECRET | GitHub OAuth Client Secret |
|
||||||
|
| DATABASE_URL | SQLite database path |
|
||||||
|
| TINA_BRANCH | Git branch for content |
|
||||||
|
|
||||||
|
## Connecting Frontend
|
||||||
|
|
||||||
|
In your Astro frontend's `tina/config.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineConfig } from "tinacms";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
apiUrl: "https://your-tina-backend.com",
|
||||||
|
contentApiUrl: "https://your-tina-backend.com",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
READMEEOF
|
||||||
|
|
||||||
|
log_success "Tina backend created at: $BACKEND_PATH"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. cd $BACKEND_PATH"
|
||||||
|
echo " 2. npm install"
|
||||||
|
echo " 3. cp .env.example .env"
|
||||||
|
echo " 4. Configure GitHub OAuth App"
|
||||||
|
echo " 5. npm run dev"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
443
skills/website-creator/scripts/migrate-tina.sh
Executable file
443
skills/website-creator/scripts/migrate-tina.sh
Executable file
@@ -0,0 +1,443 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#===============================================================================
|
||||||
|
# migrate-tina.sh - Migrate existing websites to Astro + Tina CMS
|
||||||
|
#
|
||||||
|
# Usage: ./migrate-tina.sh [source-path] [target-path]
|
||||||
|
#
|
||||||
|
# This script migrates websites to Astro + Tina CMS:
|
||||||
|
# - Converts content to Tina CMS format
|
||||||
|
# - Sets up Astro DB for consent logging
|
||||||
|
# - Adds PDPA-compliant consent system
|
||||||
|
# - Preserves content and structure
|
||||||
|
#
|
||||||
|
# Requirements:
|
||||||
|
# - node.js 20+
|
||||||
|
# - npm
|
||||||
|
# - git
|
||||||
|
#
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
SOURCE_PATH="${1:-}"
|
||||||
|
TARGET_PATH="${2:-.}"
|
||||||
|
|
||||||
|
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||||
|
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||||
|
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
||||||
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
print_usage() {
|
||||||
|
cat << EOF
|
||||||
|
Usage: $(basename "$0") [source-path] [target-path]
|
||||||
|
|
||||||
|
Migrate existing website to Astro + Tina CMS
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
source-path Path to existing website project
|
||||||
|
target-path Path for the migrated Astro + Tina project
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") /path/to/existing-site /path/to/migrated-site
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Detects source website technology (Astro, Next.js, etc.)
|
||||||
|
- Converts content to Tina CMS format
|
||||||
|
- Sets up Astro DB for consent logging (PDPA compliant)
|
||||||
|
- Adds cookie consent banner with Thai law compliance
|
||||||
|
- Preserves SEO metadata and content structure
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_source_type() {
|
||||||
|
log_info "Detecting source website type..."
|
||||||
|
|
||||||
|
cd "$SOURCE_PATH"
|
||||||
|
|
||||||
|
if [ -f "astro.config.mjs" ] || [ -f "astro.config.ts" ]; then
|
||||||
|
SOURCE_TYPE="astro"
|
||||||
|
log_success "Detected: Astro"
|
||||||
|
elif [ -f "next.config.js" ] || [ -f "next.config.mjs" ] || [ -f "package.json" ] && grep -q "next" package.json 2>/dev/null; then
|
||||||
|
SOURCE_TYPE="nextjs"
|
||||||
|
log_success "Detected: Next.js"
|
||||||
|
elif [ -f "package.json" ] && grep -q "remix" package.json 2>/dev/null; then
|
||||||
|
SOURCE_TYPE="remix"
|
||||||
|
log_success "Detected: Remix"
|
||||||
|
elif [ -d "src/content" ] || [ -d "content/posts" ]; then
|
||||||
|
SOURCE_TYPE="generic"
|
||||||
|
log_success "Detected: Generic static site"
|
||||||
|
else
|
||||||
|
log_warning "Could not detect source type, assuming generic"
|
||||||
|
SOURCE_TYPE="generic"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
analyze_source_content() {
|
||||||
|
log_info "Analyzing source content..."
|
||||||
|
|
||||||
|
cd "$SOURCE_PATH"
|
||||||
|
|
||||||
|
local md_count=$(find . -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | grep -v node_modules | wc -l)
|
||||||
|
local astro_count=$(find . -type f -name "*.astro" 2>/dev/null | grep -v node_modules | wc -l)
|
||||||
|
local pages_count=$(find . -type f \( -name "*.tsx" -o -name "*.jsx" \) 2>/dev/null | grep -v node_modules | grep -E "pages/|app/" | wc -l)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Analysis Results:"
|
||||||
|
echo " ─────────────────"
|
||||||
|
echo " Markdown/MDX files: $md_count"
|
||||||
|
echo " Astro components: $astro_count"
|
||||||
|
echo " Pages (tsx/jsx): $pages_count"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# List sample content files
|
||||||
|
if [ $md_count -gt 0 ]; then
|
||||||
|
echo " Sample content files:"
|
||||||
|
find . -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | grep -v node_modules | head -5 | while read -r f; do
|
||||||
|
echo " - $f"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_template() {
|
||||||
|
log_info "Copying Astro+Tina template..."
|
||||||
|
|
||||||
|
local template_dir="$(dirname "$(dirname "$(readlink -f "$0")")")/templates/astro-tina-starter"
|
||||||
|
|
||||||
|
if [ ! -d "$template_dir" ]; then
|
||||||
|
log_error "Template not found: $template_dir"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp -r "$template_dir"/* "$TARGET_PATH/"
|
||||||
|
cp -r "$template_dir"/.* "$TARGET_PATH/" 2>/dev/null || true
|
||||||
|
|
||||||
|
log_success "Template copied to: $TARGET_PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate_content() {
|
||||||
|
log_info "Migrating content to Tina format..."
|
||||||
|
|
||||||
|
cd "$SOURCE_PATH"
|
||||||
|
|
||||||
|
# Detect content directory
|
||||||
|
local content_dir=""
|
||||||
|
if [ -d "src/content" ]; then
|
||||||
|
content_dir="src/content"
|
||||||
|
elif [ -d "content" ]; then
|
||||||
|
content_dir="content"
|
||||||
|
elif [ -d "content/posts" ]; then
|
||||||
|
content_dir="content/posts"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$content_dir" ]; then
|
||||||
|
log_warning "No content directory found, creating default structure"
|
||||||
|
mkdir -p "$TARGET_PATH/src/content"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create Tina content directory
|
||||||
|
mkdir -p "$TARGET_PATH/src/content"
|
||||||
|
|
||||||
|
# Copy markdown/mdx files
|
||||||
|
find "$content_dir" -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | while read -r file; do
|
||||||
|
local relative_path="${file#$SOURCE_PATH/$content_dir/}"
|
||||||
|
local target_file="$TARGET_PATH/src/content/$relative_path"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$target_file")"
|
||||||
|
cp "$file" "$target_file"
|
||||||
|
|
||||||
|
echo " Migrated: $relative_path"
|
||||||
|
done
|
||||||
|
|
||||||
|
log_success "Content migration complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
add_consent_system() {
|
||||||
|
log_info "Adding PDPA-compliant consent system..."
|
||||||
|
|
||||||
|
local consent_template="$(dirname "$(dirname "$(readlink -f "$0")")")/templates/consent"
|
||||||
|
|
||||||
|
if [ ! -d "$consent_template" ]; then
|
||||||
|
log_warning "Consent template not found, skipping"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy consent files
|
||||||
|
cp -r "$consent_template"/* "$TARGET_PATH/src/components/consent/" 2>/dev/null || true
|
||||||
|
|
||||||
|
log_success "Consent system added"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_tina_schema() {
|
||||||
|
log_info "Creating Tina CMS schema..."
|
||||||
|
|
||||||
|
cd "$TARGET_PATH"
|
||||||
|
|
||||||
|
# Ensure .tina directory exists
|
||||||
|
mkdir -p .tina
|
||||||
|
|
||||||
|
# Create or update schema
|
||||||
|
cat > .tina/schema.ts << 'EOF'
|
||||||
|
import { defineSchema, config } from 'tinacms'
|
||||||
|
|
||||||
|
// Your content collections
|
||||||
|
const schema = defineSchema({
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
name: 'post',
|
||||||
|
label: 'Posts',
|
||||||
|
path: 'src/content/posts',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'slug',
|
||||||
|
label: 'Slug',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'datetime',
|
||||||
|
name: 'date',
|
||||||
|
label: 'Date',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'author',
|
||||||
|
label: 'Author',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'image',
|
||||||
|
label: 'Featured Image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'rich-text',
|
||||||
|
name: 'body',
|
||||||
|
label: 'Body',
|
||||||
|
isBody: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'page',
|
||||||
|
label: 'Pages',
|
||||||
|
path: 'src/content/pages',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'slug',
|
||||||
|
label: 'Slug',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'rich-text',
|
||||||
|
name: 'body',
|
||||||
|
label: 'Body',
|
||||||
|
isBody: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default config({
|
||||||
|
schema,
|
||||||
|
// Other config options
|
||||||
|
})
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log_success "Tina schema created"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_migration_report() {
|
||||||
|
log_info "Creating migration report..."
|
||||||
|
|
||||||
|
cd "$SOURCE_PATH"
|
||||||
|
|
||||||
|
local md_count=$(find . -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | grep -v node_modules | wc -l)
|
||||||
|
|
||||||
|
cat > "$TARGET_PATH/MIGRATION_REPORT.md" << EOF
|
||||||
|
# Migration Report: → Astro + Tina CMS
|
||||||
|
|
||||||
|
## Source
|
||||||
|
- **Original Type:** $SOURCE_TYPE
|
||||||
|
- **Path:** $SOURCE_PATH
|
||||||
|
- **Date:** $(date)
|
||||||
|
|
||||||
|
## Statistics
|
||||||
|
- **Content Files Migrated:** $md_count
|
||||||
|
|
||||||
|
## What's Included
|
||||||
|
|
||||||
|
### ✅ Astro 6.1.7
|
||||||
|
Modern static site framework with excellent performance.
|
||||||
|
|
||||||
|
### ✅ Tina CMS
|
||||||
|
Self-hosted Git-based CMS for visual content editing.
|
||||||
|
|
||||||
|
### ✅ Tailwind CSS 4.x
|
||||||
|
Latest Tailwind with @tailwindcss/vite plugin.
|
||||||
|
|
||||||
|
### ✅ Astro DB
|
||||||
|
Built-in database for consent logging and dynamic content.
|
||||||
|
|
||||||
|
### ✅ PDPA Consent System
|
||||||
|
Thai Personal Data Protection Act compliant cookie consent:
|
||||||
|
- Cookie banner with Accept/Reject/Preferences
|
||||||
|
- Consent logging in Astro DB
|
||||||
|
- API endpoint for consent management
|
||||||
|
|
||||||
|
### ✅ Nano Stores
|
||||||
|
Lightweight client-side state management.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
$TARGET_PATH/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── consent/ # PDPA consent system
|
||||||
|
│ ├── content/
|
||||||
|
│ │ ├── posts/ # Blog posts (Tina managed)
|
||||||
|
│ │ └── pages/ # Static pages (Tina managed)
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ └── Layout.astro
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ └── index.astro
|
||||||
|
│ └── styles/
|
||||||
|
│ └── global.css
|
||||||
|
├── .tina/
|
||||||
|
│ └── schema.ts # Tina content schema
|
||||||
|
├── db/
|
||||||
|
│ └── config.ts # Astro DB config
|
||||||
|
├── Dockerfile
|
||||||
|
└── AGENTS.md # AI agent instructions
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Install dependencies:**
|
||||||
|
\`\`\`bash
|
||||||
|
cd $TARGET_PATH
|
||||||
|
npm install
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
2. **Set up environment:**
|
||||||
|
\`\`\`bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
3. **Start development:**
|
||||||
|
\`\`\`bash
|
||||||
|
npm run dev
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
4. **Access Tina Admin:**
|
||||||
|
- Visit \`http://localhost:4321/admin\` (when in dev mode)
|
||||||
|
- Or \`http://localhost:4321/___tina\` for direct access
|
||||||
|
|
||||||
|
5. **Configure Tina Backend** (for production):
|
||||||
|
\`\`\`bash
|
||||||
|
./scripts/install-tina-backend.sh
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Tina CMS Setup
|
||||||
|
|
||||||
|
For production, you'll need to set up the Tina backend:
|
||||||
|
\`\`\`bash
|
||||||
|
./scripts/install-tina-backend.sh
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
This will install:
|
||||||
|
- Auth.js for authentication
|
||||||
|
- Database adapter for content storage
|
||||||
|
- Git provider for content management
|
||||||
|
|
||||||
|
## PDPA Compliance
|
||||||
|
|
||||||
|
The consent system logs:
|
||||||
|
- User consent choices (accept/reject)
|
||||||
|
- Cookie categories (analytics, marketing, functional)
|
||||||
|
- Timestamp and user agent
|
||||||
|
- IP address (for compliance auditing)
|
||||||
|
|
||||||
|
Logs are stored in Astro DB and can be exported for compliance reporting.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log_success "Migration report: $TARGET_PATH/MIGRATION_REPORT.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
echo "=============================================="
|
||||||
|
echo " Website → Astro + Tina CMS Migration Tool"
|
||||||
|
echo "=============================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then
|
||||||
|
print_usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$SOURCE_PATH" ]; then
|
||||||
|
print_usage
|
||||||
|
echo ""
|
||||||
|
log_error "Please specify source path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$SOURCE_PATH" ]; then
|
||||||
|
log_error "Source path not found: $SOURCE_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$TARGET_PATH" ]; then
|
||||||
|
mkdir -p "$TARGET_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
detect_source_type
|
||||||
|
analyze_source_content
|
||||||
|
copy_template
|
||||||
|
migrate_content
|
||||||
|
add_consent_system
|
||||||
|
create_tina_schema
|
||||||
|
create_migration_report
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=============================================="
|
||||||
|
log_success "Migration complete!"
|
||||||
|
echo "=============================================="
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. cd $TARGET_PATH"
|
||||||
|
echo " 2. npm install"
|
||||||
|
echo " 3. npm run dev"
|
||||||
|
echo " 4. See MIGRATION_REPORT.md for details"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -1,119 +1,79 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#===============================================================================
|
|
||||||
# new-project.sh - สร้าง Next.js + Payload CMS project ใหม่จาก Template
|
|
||||||
#
|
|
||||||
# Usage: ./new-project.sh [project-name] [project-path]
|
|
||||||
#
|
|
||||||
# สร้าง Next.js + Payload CMS project ใหม่โดย:
|
|
||||||
# 1. คัดลอก nextjs-payload-starter template
|
|
||||||
# 2. ติดตั้ง dependencies
|
|
||||||
# 3. ตั้งค่า environment
|
|
||||||
#
|
|
||||||
# Requirements:
|
|
||||||
# - git
|
|
||||||
# - node.js 20+
|
|
||||||
# - npm
|
|
||||||
#
|
|
||||||
#===============================================================================
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Colors
|
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m'
|
||||||
|
|
||||||
# Default values
|
|
||||||
PROJECT_NAME="${1:-}"
|
PROJECT_NAME="${1:-}"
|
||||||
PROJECT_PATH="${2:-.}"
|
PROJECT_PATH="${2:-.}"
|
||||||
|
|
||||||
# Get skill directory
|
|
||||||
SKILL_DIR="$(dirname "$(dirname "$(readlink -f "$0")")")"
|
SKILL_DIR="$(dirname "$(dirname "$(readlink -f "$0")")")"
|
||||||
TEMPLATE_DIR="$SKILL_DIR/templates/nextjs-payload-starter"
|
TEMPLATE_DIR="$SKILL_DIR/templates/astro-tina-starter"
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||||
# Helper functions
|
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||||
#-------------------------------------------------------------------------------
|
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
||||||
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
log_info() {
|
|
||||||
echo -e "${BLUE}[INFO]${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
log_success() {
|
|
||||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
log_warning() {
|
|
||||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
log_error() {
|
|
||||||
echo -e "${RED}[ERROR]${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
print_usage() {
|
print_usage() {
|
||||||
cat << EOF
|
cat << EOF
|
||||||
Usage: $(basename "$0") [project-name] [project-path]
|
Usage: $(basename "$0") [project-name] [project-path]
|
||||||
|
|
||||||
สร้าง Next.js + Payload CMS project ใหม่จาก Template
|
Create new Astro + Tina CMS project from template
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
project-name ชื่อ project (optional)
|
project-name Project name (optional)
|
||||||
project-path ที่อยู่ project (default: current directory)
|
project-path Project location (default: current directory)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") my-website
|
$(basename "$0") my-website
|
||||||
$(basename "$0") my-website /path/to/projects/
|
$(basename "$0") my-website /path/to/projects/
|
||||||
|
|
||||||
|
Creates:
|
||||||
|
- Astro 6.1.7 framework
|
||||||
|
- Tailwind CSS 4.x
|
||||||
|
- Tina CMS (self-hosted)
|
||||||
|
- Astro DB for consent logging
|
||||||
|
- PDPA-compliant consent system
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Pre-flight checks
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
check_requirements() {
|
check_requirements() {
|
||||||
log_info "ตรวจสอบความต้องการของระบบ..."
|
log_info "Checking requirements..."
|
||||||
|
|
||||||
# Check git
|
|
||||||
if ! command -v git &> /dev/null; then
|
if ! command -v git &> /dev/null; then
|
||||||
log_error "git ไม่พบ กรุณาติดตั้ง git ก่อน"
|
log_error "git not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check node
|
|
||||||
if ! command -v node &> /dev/null; then
|
if ! command -v node &> /dev/null; then
|
||||||
log_error "node.js ไม่พบ กรุณาติดตั้ง node.js ก่อน"
|
log_error "node.js not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
||||||
if [ "$NODE_VERSION" -lt 20 ]; then
|
if [ "$NODE_VERSION" -lt 20 ]; then
|
||||||
log_error "node.js version ต้อง >= 20 (ตอนนี้: $(node -v))"
|
log_error "node.js >= 20 required (current: $(node -v))"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check npm
|
|
||||||
if ! command -v npm &> /dev/null; then
|
if ! command -v npm &> /dev/null; then
|
||||||
log_error "npm ไม่พบ กรุณาติดตั้ง npm ก่อน"
|
log_error "npm not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check template exists
|
|
||||||
if [ ! -d "$TEMPLATE_DIR" ]; then
|
if [ ! -d "$TEMPLATE_DIR" ]; then
|
||||||
log_error "ไม่พบ Next.js Payload Starter Template: $TEMPLATE_DIR"
|
log_error "Template not found: $TEMPLATE_DIR"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_success "ความต้องการของระบบผ่าน (git, node $(node -v), npm)"
|
log_success "Requirements OK (git, node $(node -v), npm)"
|
||||||
}
|
}
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Create project directory
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
setup_directory() {
|
setup_directory() {
|
||||||
local actual_project_path="$PROJECT_PATH"
|
local actual_project_path="$PROJECT_PATH"
|
||||||
|
|
||||||
@@ -121,13 +81,11 @@ setup_directory() {
|
|||||||
actual_project_path="$PROJECT_PATH/$PROJECT_NAME"
|
actual_project_path="$PROJECT_PATH/$PROJECT_NAME"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create directory
|
|
||||||
mkdir -p "$actual_project_path"
|
mkdir -p "$actual_project_path"
|
||||||
|
|
||||||
# Check if directory is empty
|
|
||||||
if [ "$(ls -A "$actual_project_path" | wc -l)" -gt 0 ]; then
|
if [ "$(ls -A "$actual_project_path" | wc -l)" -gt 0 ]; then
|
||||||
log_warning "Directory ไม่ว่าง: $actual_project_path"
|
log_warning "Directory not empty: $actual_project_path"
|
||||||
read -p "ดำเนินต่อ? (y/n): " -n 1 -r
|
read -p "Continue? (y/n): " -n 1 -r
|
||||||
echo
|
echo
|
||||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
@@ -138,32 +96,37 @@ setup_directory() {
|
|||||||
log_info "Project path: $PROJECT_PATH"
|
log_info "Project path: $PROJECT_PATH"
|
||||||
}
|
}
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Copy template
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
copy_template() {
|
copy_template() {
|
||||||
log_info "คัดลอก Next.js Payload Starter Template..."
|
log_info "Copying Astro+Tina template..."
|
||||||
|
|
||||||
# Copy all template files
|
|
||||||
cp -r "$TEMPLATE_DIR/"* "$PROJECT_PATH/"
|
cp -r "$TEMPLATE_DIR/"* "$PROJECT_PATH/"
|
||||||
cp -r "$TEMPLATE_DIR/src/collections/access" "$PROJECT_PATH/src/collections/" 2>/dev/null || true
|
cp -r "$TEMPLATE_DIR"/.* "$PROJECT_PATH/" 2>/dev/null || true
|
||||||
|
|
||||||
# Copy consent API if exists
|
log_success "Template copied"
|
||||||
if [ -d "$SKILL_DIR/templates/consent/api" ]; then
|
|
||||||
mkdir -p "$PROJECT_PATH/src/pages/api"
|
|
||||||
cp "$SKILL_DIR/templates/consent/api/"* "$PROJECT_PATH/src/pages/api/" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
log_success "คัดลอก template เสร็จสมบูรณ์"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
copy_consent_system() {
|
||||||
# Copy legal templates
|
log_info "Adding PDPA consent system..."
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
local consent_template="$SKILL_DIR/templates/consent"
|
||||||
|
|
||||||
|
if [ -d "$consent_template" ]; then
|
||||||
|
mkdir -p "$PROJECT_PATH/src/components/consent"
|
||||||
|
cp "$consent_template/ConsentBanner.astro" "$PROJECT_PATH/src/components/consent/" 2>/dev/null || true
|
||||||
|
cp "$consent_template/stores/"* "$PROJECT_PATH/src/stores/" 2>/dev/null || true
|
||||||
|
|
||||||
|
mkdir -p "$PROJECT_PATH/src/pages/api"
|
||||||
|
cp "$consent_template/api/consent.ts" "$PROJECT_PATH/src/pages/api/" 2>/dev/null || true
|
||||||
|
|
||||||
|
mkdir -p "$PROJECT_PATH/db"
|
||||||
|
cp "$consent_template/db/config.ts" "$PROJECT_PATH/db/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Consent system added"
|
||||||
|
}
|
||||||
|
|
||||||
copy_legal_templates() {
|
copy_legal_templates() {
|
||||||
log_info "คัดลอก PDPA templates..."
|
log_info "Copying PDPA legal templates..."
|
||||||
|
|
||||||
mkdir -p "$PROJECT_PATH/src/content/pages"
|
mkdir -p "$PROJECT_PATH/src/content/pages"
|
||||||
|
|
||||||
@@ -175,167 +138,91 @@ copy_legal_templates() {
|
|||||||
cp "$SKILL_DIR/templates/terms-of-service.md" "$PROJECT_PATH/src/content/pages/"
|
cp "$SKILL_DIR/templates/terms-of-service.md" "$PROJECT_PATH/src/content/pages/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_success "คัดลอก PDPA templates เสร็จสมบูรณ์"
|
log_success "Legal templates copied"
|
||||||
}
|
}
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Install dependencies
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
install_dependencies() {
|
install_dependencies() {
|
||||||
log_info "ติดตั้ง dependencies..."
|
log_info "Installing dependencies..."
|
||||||
|
|
||||||
cd "$PROJECT_PATH"
|
cd "$PROJECT_PATH"
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
log_success "ติดตั้ง dependencies เสร็จสมบูรณ์"
|
log_success "Dependencies installed"
|
||||||
}
|
}
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Setup environment
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
setup_environment() {
|
setup_environment() {
|
||||||
log_info "ตั้งค่า environment..."
|
log_info "Setting up environment..."
|
||||||
|
|
||||||
cd "$PROJECT_PATH"
|
cd "$PROJECT_PATH"
|
||||||
|
|
||||||
if [ ! -f ".env" ]; then
|
if [ ! -f ".env" ]; then
|
||||||
if [ -f ".env.example" ]; then
|
if [ -f ".env.example" ]; then
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
log_success "สร้าง .env จาก .env.example"
|
log_success "Created .env from .env.example"
|
||||||
log_warning "กรุณาแก้ไข .env และใส่ DATABASE_URL ที่ถูกต้อง"
|
|
||||||
else
|
else
|
||||||
cat > .env << 'EOF'
|
cat > .env << 'EOF'
|
||||||
# Payload CMS
|
PUBLIC_SITE_URL=http://localhost:4321
|
||||||
PAYLOAD_SECRET=change-this-secret-key-at-least-32-characters
|
TINA_TOKEN=your-tina-token
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
|
|
||||||
|
|
||||||
# Server
|
|
||||||
SERVER_URL=http://localhost:4321
|
|
||||||
NODE_ENV=development
|
|
||||||
EOF
|
EOF
|
||||||
log_success "สร้าง .env เริ่มต้น"
|
log_success "Created default .env"
|
||||||
log_warning "กรุณาแก้ไข .env และใส่ DATABASE_URL ที่ถูกต้อง"
|
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
log_info ".env มีอยู่แล้ว"
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Create AI_RULES.md
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
create_ai_rules() {
|
|
||||||
log_info "สร้าง AI_RULES.md..."
|
|
||||||
|
|
||||||
cd "$PROJECT_PATH"
|
|
||||||
|
|
||||||
cat > AI_RULES.md << 'EOF'
|
|
||||||
# AI Rules
|
|
||||||
|
|
||||||
## Tech Stack Overview
|
|
||||||
|
|
||||||
- **Frontend:** Next.js App Router + TypeScript
|
|
||||||
- **Backend/CMS:** Payload CMS 3.0
|
|
||||||
- **Database:** MongoDB (via mongooseAdapter)
|
|
||||||
- **Styling:** Tailwind CSS v4
|
|
||||||
- **Authentication:** Payload built-in auth with role-based access
|
|
||||||
- **Image Handling:** Payload Media collection
|
|
||||||
|
|
||||||
## File Organization
|
|
||||||
|
|
||||||
- **Collections:** Define Payload collections in `src/collections/`
|
|
||||||
- **Pages:** Next.js App Router in `src/app/`
|
|
||||||
- **Components:** Reusable components in `src/components/`
|
|
||||||
- **Styles:** Global styles in `src/app/globals.css`
|
|
||||||
|
|
||||||
## Never Modify These Files
|
|
||||||
|
|
||||||
- `src/payload-types.ts` - Auto-generated by Payload
|
|
||||||
- `src/migrations/` - Database migration files
|
|
||||||
|
|
||||||
## Thai-First
|
|
||||||
|
|
||||||
- ใช้ Kanit หรือ Noto Sans Thai fonts
|
|
||||||
- Thai typography CSS
|
|
||||||
- Thai structured data (LocalBusiness, Organization)
|
|
||||||
- ภาษาไทยเป็นหลักใน content
|
|
||||||
EOF
|
|
||||||
|
|
||||||
log_success "สร้าง AI_RULES.md เสร็จสมบูรณ์"
|
|
||||||
}
|
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Initialize git
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
init_git() {
|
init_git() {
|
||||||
log_info "เริ่มต้น git..."
|
log_info "Initializing git..."
|
||||||
|
|
||||||
cd "$PROJECT_PATH"
|
cd "$PROJECT_PATH"
|
||||||
|
|
||||||
if [ ! -d ".git" ]; then
|
if [ ! -d ".git" ]; then
|
||||||
git init
|
git init
|
||||||
git add .
|
git add .
|
||||||
git commit -m "Initial commit: Next.js + Payload CMS starter"
|
git commit -m "Initial commit: Astro + Tina CMS starter"
|
||||||
log_success "เริ่มต้น git เสร็จสมบูรณ์"
|
log_success "Git initialized"
|
||||||
else
|
|
||||||
log_info "git repo มีอยู่แล้ว"
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Show project structure
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
show_structure() {
|
show_structure() {
|
||||||
log_info "โครงสร้าง project:"
|
log_info "Project structure:"
|
||||||
cd "$PROJECT_PATH"
|
cd "$PROJECT_PATH"
|
||||||
echo ""
|
echo ""
|
||||||
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.astro" -o -name "*.mjs" -o -name "*.css" -o -name "*.md" -o -name "package.json" \) 2>/dev/null | grep -v node_modules | sort | head -30
|
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.astro" -o -name "*.mjs" -o -name "*.css" -o -name "*.md" -o -name "package.json" \) 2>/dev/null | grep -v node_modules | sort | head -30
|
||||||
}
|
}
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
# Main
|
|
||||||
#-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
echo "=============================================="
|
echo "=============================================="
|
||||||
echo " Next.js + Payload CMS Project Creator"
|
echo " Astro + Tina CMS Project Creator"
|
||||||
echo " Using Next.js Payload Starter"
|
|
||||||
echo "=============================================="
|
echo "=============================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Parse arguments
|
|
||||||
if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then
|
if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then
|
||||||
print_usage
|
print_usage
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run steps
|
|
||||||
check_requirements
|
check_requirements
|
||||||
setup_directory
|
setup_directory
|
||||||
copy_template
|
copy_template
|
||||||
|
copy_consent_system
|
||||||
copy_legal_templates
|
copy_legal_templates
|
||||||
install_dependencies
|
install_dependencies
|
||||||
setup_environment
|
setup_environment
|
||||||
create_ai_rules
|
|
||||||
init_git
|
init_git
|
||||||
show_structure
|
show_structure
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=============================================="
|
echo "=============================================="
|
||||||
log_success "สร้าง Next.js + Payload CMS project เสร็จสมบูรณ์!"
|
log_success "Project created successfully!"
|
||||||
echo "=============================================="
|
echo "=============================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo "ขั้นตอนถัดไป:"
|
echo "Next steps:"
|
||||||
echo " 1. cd $PROJECT_PATH"
|
echo " 1. cd $PROJECT_PATH"
|
||||||
echo " 2. แก้ไข .env (MONGODB_URL, PAYLOAD_SECRET)"
|
echo " 2. npm run dev"
|
||||||
echo " 3. npm install"
|
echo " 3. Open http://localhost:4321"
|
||||||
echo " 4. npm run dev"
|
echo ""
|
||||||
echo " 5. เปิด http://localhost:3002/admin สำหรับ Payload admin"
|
echo "For Tina CMS admin:"
|
||||||
|
echo " - npm run dev"
|
||||||
|
echo " - Visit http://localhost:4321/admin"
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'tinacms';
|
||||||
|
import { schema } from './schema';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema,
|
||||||
|
ui: {
|
||||||
|
navigation: {
|
||||||
|
'content/posts': { label: 'Posts' },
|
||||||
|
'content/pages': { label: 'Pages' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
tina: {
|
||||||
|
publicFolder: 'public',
|
||||||
|
mediaRoot: 'uploads',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { defineSchema } from 'tinacms'
|
||||||
|
|
||||||
|
export const schema = defineSchema({
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
name: 'post',
|
||||||
|
label: 'Posts',
|
||||||
|
path: 'src/content/posts',
|
||||||
|
format: 'mdx',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'datetime',
|
||||||
|
name: 'publishedAt',
|
||||||
|
label: 'Published At',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'category',
|
||||||
|
label: 'Category',
|
||||||
|
options: ['news', 'blog', 'tutorial'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'rich-text',
|
||||||
|
name: 'body',
|
||||||
|
label: 'Body',
|
||||||
|
isBody: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'page',
|
||||||
|
label: 'Pages',
|
||||||
|
path: 'src/content/pages',
|
||||||
|
format: 'mdx',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'rich-text',
|
||||||
|
name: 'body',
|
||||||
|
label: 'Body',
|
||||||
|
isBody: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'settings',
|
||||||
|
label: 'Settings',
|
||||||
|
path: 'src/content/settings',
|
||||||
|
format: 'json',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'siteName',
|
||||||
|
label: 'Site Name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'siteDescription',
|
||||||
|
label: 'Site Description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'language',
|
||||||
|
label: 'Language',
|
||||||
|
options: ['th', 'en', 'th-en'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
198
skills/website-creator/templates/astro-tina-starter/AGENTS.md
Normal file
198
skills/website-creator/templates/astro-tina-starter/AGENTS.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Astro Tina Starter - Agent Knowledge Base
|
||||||
|
|
||||||
|
**Generated:** 2026-04-17
|
||||||
|
**Version:** 1.0.0
|
||||||
|
**Type:** Astro 6 + Tina CMS Starter Template
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
|
||||||
|
Starter template for building websites with Astro 6, Tina CMS, and Tailwind CSS 4.x.
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
| Component | Technology | Version |
|
||||||
|
|-----------|------------|---------|
|
||||||
|
| Framework | Astro | 6.1.7 |
|
||||||
|
| CMS | Tina CMS | 2.x |
|
||||||
|
| Styling | Tailwind CSS | 4.x |
|
||||||
|
| Database | Astro DB | 0.14.x |
|
||||||
|
| State | Nano Stores | 0.11.x |
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- Self-hosted Tina CMS with schema-based content
|
||||||
|
- Tailwind CSS 4.x using `@tailwindcss/vite` plugin
|
||||||
|
- Astro DB for consent logging (PDPA compliant)
|
||||||
|
- Thai language support with Noto Sans Thai
|
||||||
|
- Docker-ready deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROJECT STRUCTURE
|
||||||
|
|
||||||
|
```
|
||||||
|
astro-tina-starter/
|
||||||
|
├── .tina/
|
||||||
|
│ ├── config.ts # Tina CMS configuration
|
||||||
|
│ └── schema.ts # Content schema definitions
|
||||||
|
├── db/
|
||||||
|
│ ├── config.ts # Astro DB schema
|
||||||
|
│ └── seed.ts # Database seed script
|
||||||
|
├── src/
|
||||||
|
│ ├── styles/
|
||||||
|
│ │ └── global.css # Tailwind v4 styles + @theme
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ └── Layout.astro
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ └── index.astro
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── Header.astro
|
||||||
|
│ └── content/
|
||||||
|
│ ├── config.ts # Astro content collections
|
||||||
|
│ ├── posts/ # Blog posts (MDX)
|
||||||
|
│ ├── pages/ # Static pages (MDX)
|
||||||
|
│ └── settings/ # Site settings (JSON)
|
||||||
|
├── public/
|
||||||
|
│ └── favicon.svg
|
||||||
|
├── Dockerfile
|
||||||
|
├── astro.config.mjs
|
||||||
|
├── tsconfig.json
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IMPORTANT CONVENTIONS
|
||||||
|
|
||||||
|
### Tailwind CSS 4.x Setup
|
||||||
|
|
||||||
|
**CRITICAL:** This template uses `@tailwindcss/vite` plugin, NOT `@astrojs/tailwind`.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// astro.config.mjs
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* src/styles/global.css */
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #1a1a1a;
|
||||||
|
--color-accent: #3b82f6;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tina CMS Content
|
||||||
|
|
||||||
|
Tina CMS manages content in `src/content/`:
|
||||||
|
- `posts/` - Blog posts (MDX format)
|
||||||
|
- `pages/` - Static pages (MDX format)
|
||||||
|
- `settings/` - Site settings (JSON format)
|
||||||
|
|
||||||
|
Schema defined in `.tina/schema.ts`.
|
||||||
|
|
||||||
|
### Astro DB Schema
|
||||||
|
|
||||||
|
Consent log table for PDPA compliance in `db/config.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CREDENTIALS
|
||||||
|
|
||||||
|
No external API credentials required for this template.
|
||||||
|
|
||||||
|
### Optional Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `TINA_TOKEN` | Tina CMS production authentication |
|
||||||
|
| `TINA_CLIENT_ID` | Tina CMS client ID |
|
||||||
|
| `DATABASE_URL` | Custom database connection (optional) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## COMMANDS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Development
|
||||||
|
npm run dev # Full dev (Tina + Astro)
|
||||||
|
npm run dev:astro # Astro only
|
||||||
|
npm run dev:tina # Tina CMS only
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build # Production build
|
||||||
|
npm run preview # Preview production build
|
||||||
|
|
||||||
|
# Database
|
||||||
|
npm run db:push # Push schema to database
|
||||||
|
npm run db:seed # Seed database
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PDPA COMPLIANCE
|
||||||
|
|
||||||
|
Template includes consent logging via Astro DB:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// db/config.ts
|
||||||
|
export const ConsentLog = defineTable({
|
||||||
|
columns: {
|
||||||
|
action: text(),
|
||||||
|
purpose: text(),
|
||||||
|
analytics: boolean(),
|
||||||
|
marketing: boolean(),
|
||||||
|
functional: boolean(),
|
||||||
|
userAgent: text(),
|
||||||
|
ip: text(),
|
||||||
|
timestamp: text(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ANTI-PATTERNS
|
||||||
|
|
||||||
|
- **NEVER** use `@astrojs/tailwind` (deprecated)
|
||||||
|
- **ALWAYS** use `@tailwindcss/vite` for Tailwind v4
|
||||||
|
- **NEVER** commit environment files (.env)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DEPLOYMENT
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t astro-tina-starter .
|
||||||
|
docker run -p 8080:80 astro-tina-starter
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
# Serve dist/ folder with any static server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NOTES
|
||||||
|
|
||||||
|
- Tina CMS admin: http://localhost:4321/admin
|
||||||
|
- Astro default port: 4321
|
||||||
|
- Tina dev server: 3001
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine AS runner
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
104
skills/website-creator/templates/astro-tina-starter/README.md
Normal file
104
skills/website-creator/templates/astro-tina-starter/README.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Astro Tina Starter
|
||||||
|
|
||||||
|
Astro 6.1.7 + Tina CMS starter template with Tailwind CSS 4.x
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework:** Astro 6.1.7
|
||||||
|
- **CMS:** Tina CMS (self-hosted)
|
||||||
|
- **Styling:** Tailwind CSS 4.x with `@tailwindcss/vite`
|
||||||
|
- **Database:** Astro DB (LibSQL)
|
||||||
|
- **State:** Nano Stores + React
|
||||||
|
- **Language:** TypeScript
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Self-hosted Tina CMS with schema-based content
|
||||||
|
- Tailwind CSS 4.x using `@tailwindcss/vite` plugin
|
||||||
|
- Astro DB for consent logging (PDPA compliant)
|
||||||
|
- Nano Stores for client-side state management
|
||||||
|
- Thai language support foundation
|
||||||
|
- Docker-ready deployment
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tina CMS Access
|
||||||
|
|
||||||
|
During development, access Tina CMS at:
|
||||||
|
- http://localhost:4321/admin
|
||||||
|
|
||||||
|
For production, you'll need a TINA_TOKEN environment variable.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
astro-tina-starter/
|
||||||
|
├── .tina/
|
||||||
|
│ ├── config.ts # Tina CMS configuration
|
||||||
|
│ └── schema.ts # Content schema definitions
|
||||||
|
├── db/
|
||||||
|
│ ├── config.ts # Astro DB schema (consent logs)
|
||||||
|
│ └── seed.ts # Database seed script
|
||||||
|
├── src/
|
||||||
|
│ ├── styles/
|
||||||
|
│ │ └── global.css # Tailwind v4 styles
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ └── Layout.astro
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ └── index.astro
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── Header.astro
|
||||||
|
│ └── content/
|
||||||
|
│ └── config.ts # Tina content collections
|
||||||
|
├── Dockerfile
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tailwind CSS 4.x
|
||||||
|
|
||||||
|
This template uses Tailwind CSS 4.x with the `@tailwindcss/vite` plugin.
|
||||||
|
The configuration is done via CSS `@theme` block in `src/styles/global.css`.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #1a1a1a;
|
||||||
|
--color-accent: #3b82f6;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Astro DB
|
||||||
|
|
||||||
|
The template includes a consent-log table for PDPA compliance:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// db/config.ts
|
||||||
|
export const ConsentLog = defineTable({
|
||||||
|
columns: {
|
||||||
|
action: text(),
|
||||||
|
purpose: text(),
|
||||||
|
analytics: boolean(),
|
||||||
|
marketing: boolean(),
|
||||||
|
functional: boolean(),
|
||||||
|
userAgent: text(),
|
||||||
|
ip: text(),
|
||||||
|
timestamp: text(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig } from 'astro/config'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import tina from 'tinacms'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [
|
||||||
|
tina({
|
||||||
|
enabled: !!process.env.TINA_TOKEN,
|
||||||
|
sidebar: {
|
||||||
|
partials: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@components': path.resolve(__dirname, './src/components'),
|
||||||
|
'@layouts': path.resolve(__dirname, './src/layouts'),
|
||||||
|
'@styles': path.resolve(__dirname, './src/styles'),
|
||||||
|
'@content': path.resolve(__dirname, './src/content'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: 'static',
|
||||||
|
build: {
|
||||||
|
assets: '_assets',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 4321,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineDb, defineTable, column } from 'astro:db';
|
||||||
|
|
||||||
|
const ConsentLog = defineTable({
|
||||||
|
columns: {
|
||||||
|
id: column.number({ primaryKey: true }),
|
||||||
|
action: column.text(),
|
||||||
|
purpose: column.text(),
|
||||||
|
analytics: column.boolean({ default: false }),
|
||||||
|
marketing: column.boolean({ default: false }),
|
||||||
|
functional: column.boolean({ default: false }),
|
||||||
|
userAgent: column.text({ optional: true }),
|
||||||
|
ip: column.text({ optional: true }),
|
||||||
|
timestamp: column.date(),
|
||||||
|
sessionId: column.text({ optional: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineDb({
|
||||||
|
tables: {
|
||||||
|
ConsentLog,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { db } from 'astro:db'
|
||||||
|
import { sql } from 'astro/db'
|
||||||
|
|
||||||
|
export default async function seed() {
|
||||||
|
// Seed default settings if needed
|
||||||
|
console.log('Database seeded successfully')
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "astro-tina-starter",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Astro 6 + Tina CMS starter template with Tailwind CSS 4.x",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tinacms dev --port 3001 & astro dev",
|
||||||
|
"dev:astro": "astro dev",
|
||||||
|
"dev:tina": "tinacms dev --port 3001",
|
||||||
|
"build": "tinacms build && astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro",
|
||||||
|
"db:push": "astro db push",
|
||||||
|
"db:seed": "astro db seed"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/check": "^0.9.4",
|
||||||
|
"@astrojs/db": "^0.14.3",
|
||||||
|
"@nanostores/react": "^0.7.3",
|
||||||
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"astro": "^6.1.7",
|
||||||
|
"nanostores": "^0.11.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"tina": "^2.1.4",
|
||||||
|
"tinacms": "^2.2.4",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#3b82f6"/>
|
||||||
|
<stop offset="100%" style="stop-color:#1d4ed8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="32" height="32" rx="6" fill="url(#grad)"/>
|
||||||
|
<text x="16" y="22" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white" text-anchor="middle">A</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
siteName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteName = "Astro Tina Starter" } = Astro.props
|
||||||
|
---
|
||||||
|
|
||||||
|
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-primary-200">
|
||||||
|
<nav class="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||||
|
<a href="/" class="font-bold text-xl text-primary-900 hover:text-accent-600 transition-colors">
|
||||||
|
{siteName}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<a href="/" class="text-primary-600 hover:text-primary-900 transition-colors">
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<a href="/blog" class="text-primary-600 hover:text-primary-900 transition-colors">
|
||||||
|
Blog
|
||||||
|
</a>
|
||||||
|
<a href="/about" class="text-primary-600 hover:text-primary-900 transition-colors">
|
||||||
|
About
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineCollection, z } from "astro:content"
|
||||||
|
|
||||||
|
const postCollection = defineCollection({
|
||||||
|
type: "content",
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
publishedAt: z.date().optional(),
|
||||||
|
category: z.enum(["news", "blog", "tutorial"]).optional(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageCollection = defineCollection({
|
||||||
|
type: "content",
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingsCollection = defineCollection({
|
||||||
|
type: "data",
|
||||||
|
schema: z.object({
|
||||||
|
siteName: z.string(),
|
||||||
|
siteDescription: z.string(),
|
||||||
|
language: z.enum(["th", "en", "th-en"]).default("th"),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
posts: postCollection,
|
||||||
|
pages: pageCollection,
|
||||||
|
settings: settingsCollection,
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
title: Welcome to Astro Tina Starter
|
||||||
|
description: A modern starter template with Astro 6, Tina CMS, and Thai language support.
|
||||||
|
publishedAt: 2026-04-17
|
||||||
|
category: blog
|
||||||
|
---
|
||||||
|
|
||||||
|
Welcome to our new blog built with Astro and Tina CMS!
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Tina CMS** - Self-hosted content management
|
||||||
|
- **Tailwind CSS v4** - Latest styling with @tailwindcss/vite
|
||||||
|
- **Astro DB** - Built-in database support
|
||||||
|
- **Thai Support** - Ready for Thai language content
|
||||||
|
|
||||||
|
Stay tuned for more updates!
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"siteName": "Astro Tina Starter",
|
||||||
|
"siteDescription": "Astro 6 + Tina CMS starter template with Thai language support",
|
||||||
|
"language": "th"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import "@/styles/global.css"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title = "Astro Tina Starter",
|
||||||
|
description = "Astro 6 + Tina CMS starter template",
|
||||||
|
} = Astro.props
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="th">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-primary-50 text-primary-900 min-h-screen">
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
import Layout from "@/layouts/Layout.astro"
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
<main>
|
||||||
|
<section class="px-6 py-24 max-w-4xl mx-auto">
|
||||||
|
<h1 class="text-4xl md:text-5xl font-bold tracking-tight mb-6">
|
||||||
|
Welcome to Astro Tina Starter
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-primary-600 mb-8 max-w-2xl">
|
||||||
|
A modern starter template with Astro 6, Tina CMS, Tailwind CSS 4.x,
|
||||||
|
and Thai language support.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<div class="p-6 bg-white rounded-xl border border-primary-200">
|
||||||
|
<h2 class="text-xl font-semibold mb-3">Tina CMS</h2>
|
||||||
|
<p class="text-primary-600">
|
||||||
|
Self-hosted content management with schema-based editing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 bg-white rounded-xl border border-primary-200">
|
||||||
|
<h2 class="text-xl font-semibold mb-3">Tailwind v4</h2>
|
||||||
|
<p class="text-primary-600">
|
||||||
|
Latest Tailwind CSS with @tailwindcss/vite plugin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 bg-white rounded-xl border border-primary-200">
|
||||||
|
<h2 class="text-xl font-semibold mb-3">Astro DB</h2>
|
||||||
|
<p class="text-primary-600">
|
||||||
|
Built-in database for consent logging and more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 bg-white rounded-xl border border-primary-200">
|
||||||
|
<h2 class="text-xl font-semibold mb-3">Thai Support</h2>
|
||||||
|
<p class="text-primary-600">
|
||||||
|
Ready for Thai language content with Noto Sans Thai.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", "Noto Sans Thai", system-ui, sans-serif;
|
||||||
|
--font-serif: "Merriweather", Georgia, serif;
|
||||||
|
|
||||||
|
--color-primary-50: #f8fafc;
|
||||||
|
--color-primary-100: #f1f5f9;
|
||||||
|
--color-primary-200: #e2e8f0;
|
||||||
|
--color-primary-300: #cbd5e1;
|
||||||
|
--color-primary-400: #94a3b8;
|
||||||
|
--color-primary-500: #64748b;
|
||||||
|
--color-primary-600: #475569;
|
||||||
|
--color-primary-700: #334155;
|
||||||
|
--color-primary-800: #1e293b;
|
||||||
|
--color-primary-900: #0f172a;
|
||||||
|
--color-primary-950: #020617;
|
||||||
|
|
||||||
|
--color-accent-50: #eff6ff;
|
||||||
|
--color-accent-100: #dbeafe;
|
||||||
|
--color-accent-200: #bfdbfe;
|
||||||
|
--color-accent-300: #93c5fd;
|
||||||
|
--color-accent-400: #60a5fa;
|
||||||
|
--color-accent-500: #3b82f6;
|
||||||
|
--color-accent-600: #2563eb;
|
||||||
|
--color-accent-700: #1d4ed8;
|
||||||
|
--color-accent-800: #1e40af;
|
||||||
|
--color-accent-900: #1e3a8a;
|
||||||
|
|
||||||
|
--color-success-500: #22c55e;
|
||||||
|
--color-warning-500: #f59e0b;
|
||||||
|
--color-error-500: #ef4444;
|
||||||
|
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
--radius-xl: 1rem;
|
||||||
|
--radius-2xl: 1.5rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: var(--color-accent-200);
|
||||||
|
color: var(--color-primary-900);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@components/*": ["./src/components/*"],
|
||||||
|
"@layouts/*": ["./src/layouts/*"],
|
||||||
|
"@styles/*": ["./src/styles/*"],
|
||||||
|
"@content/*": ["./src/content/*"]
|
||||||
|
},
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", ".tina/**/*", "db/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", ".astro"]
|
||||||
|
}
|
||||||
447
skills/website-creator/templates/consent/ConsentBanner.astro
Normal file
447
skills/website-creator/templates/consent/ConsentBanner.astro
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* PDPA Consent Banner Component for Astro + Tina
|
||||||
|
* Replaces cookie-banner.tsx from Next.js+Payload
|
||||||
|
*
|
||||||
|
* Usage: Import and add <ConsentBanner /> to your layout
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Optional: Custom privacy policy URL */
|
||||||
|
privacyPolicyUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { privacyPolicyUrl = "/privacy-policy" } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="pdpa-consent-banner"
|
||||||
|
class="consent-banner"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Cookie Consent Banner"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div class="consent-banner__content">
|
||||||
|
<!-- Main Banner -->
|
||||||
|
<div id="consent-main" class="consent-banner__main">
|
||||||
|
<h3 class="consent-banner__title">
|
||||||
|
🍪 การยินยอมตาม พ.ร.บ.คุ้มครองข้อมูลส่วนบุคคล
|
||||||
|
</h3>
|
||||||
|
<p class="consent-banner__text">
|
||||||
|
เราใช้คุกกี้เพื่อปรับปรุงประสบการณ์การใช้งานเว็บไซต์ของคุณ การเข้าชมเว็บไซต์ต่อถือว่าคุณยินยอมให้เราใช้คุกกี้{' '}
|
||||||
|
<a href={privacyPolicyUrl} class="consent-banner__link">เรียนรู้เพิ่มเติม</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="consent-banner__buttons">
|
||||||
|
<button
|
||||||
|
id="consent-accept-all"
|
||||||
|
class="consent-btn consent-btn--accept"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
ยอมรับทั้งหมด
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="consent-reject-all"
|
||||||
|
class="consent-btn consent-btn--reject"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
ปฏิเสธทั้งหมด
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="consent-show-preferences"
|
||||||
|
class="consent-btn consent-btn--preferences"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
ตั้งค่าคุกกี้
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preferences Panel -->
|
||||||
|
<div id="consent-preferences" class="consent-banner__preferences" style="display: none;">
|
||||||
|
<h3 class="consent-banner__title">ตั้งค่าคุกกี้</h3>
|
||||||
|
|
||||||
|
<p class="consent-banner__text" style="margin-bottom: 1rem; color: #555; font-size: 0.875rem;">
|
||||||
|
จัดการการตั้งค่าคุกกี้ของคุณด้านล่าง
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="consent-banner__options">
|
||||||
|
<!-- Functional Cookies -->
|
||||||
|
<div class="consent-option consent-option--disabled">
|
||||||
|
<div class="consent-option__header">
|
||||||
|
<div>
|
||||||
|
<h4 class="consent-option__title">คุกกี้ที่จำเป็น</h4>
|
||||||
|
<p class="consent-option__desc">
|
||||||
|
จำเป็นสำหรับการทำงานของเว็บไซต์ ไม่สามารปิดได้
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="consent-option__badge">เปิดอยู่เสมอ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Analytics Cookies -->
|
||||||
|
<div class="consent-option">
|
||||||
|
<div class="consent-option__header">
|
||||||
|
<div>
|
||||||
|
<h4 class="consent-option__title">คุกกี้วิเคราะห์</h4>
|
||||||
|
<p class="consent-option__desc">
|
||||||
|
ช่วยเราเข้าใจว่าผู้เยี่ยมชมใช้งานเว็บไซต์ของเราอย่างไร
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="consent-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="consent-analytics"
|
||||||
|
name="analytics"
|
||||||
|
class="consent-checkbox__input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Marketing Cookies -->
|
||||||
|
<div class="consent-option">
|
||||||
|
<div class="consent-option__header">
|
||||||
|
<div>
|
||||||
|
<h4 class="consent-option__title">คุกกี้การตลาด</h4>
|
||||||
|
<p class="consent-option__desc">
|
||||||
|
ใช้ติดตามผู้เยี่ยมชมข้ามเว็บไซต์เพื่อการโฆษณา
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="consent-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="consent-marketing"
|
||||||
|
name="marketing"
|
||||||
|
class="consent-checkbox__input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="consent-banner__buttons">
|
||||||
|
<button
|
||||||
|
id="consent-save-preferences"
|
||||||
|
class="consent-btn consent-btn--save"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
บันทึกการตั้งค่า
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="consent-back"
|
||||||
|
class="consent-btn consent-btn--back"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
กลับ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.consent-banner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 1.5rem;
|
||||||
|
z-index: 9999;
|
||||||
|
border-top: 1px solid #e5e5e5;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-banner__content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-banner__title {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-banner__text {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-banner__link {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-banner__link:hover {
|
||||||
|
color: #004499;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-banner__buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-btn {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-btn--accept {
|
||||||
|
background-color: #22c55e;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-btn--accept:hover {
|
||||||
|
background-color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-btn--reject {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-btn--reject:hover {
|
||||||
|
background-color: #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-btn--preferences {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #0066cc;
|
||||||
|
border: 1px solid #0066cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-btn--preferences:hover {
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-btn--save {
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-btn--save:hover {
|
||||||
|
background-color: #004499;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-btn--back {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #666;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-btn--back:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preferences Panel */
|
||||||
|
.consent-banner__options {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-option {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-option--disabled {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-option__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-option__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-option__desc {
|
||||||
|
margin: 0.25rem 0 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-option__badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background-color: #e5e5e5;
|
||||||
|
color: #666;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-checkbox__input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide initially via JS */
|
||||||
|
.consent-banner[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { consentStore, type ConsentState } from './stores/consent';
|
||||||
|
|
||||||
|
// DOM Elements
|
||||||
|
const banner = document.getElementById('pdpa-consent-banner');
|
||||||
|
const mainPanel = document.getElementById('consent-main');
|
||||||
|
const prefsPanel = document.getElementById('consent-preferences');
|
||||||
|
const analyticsCheckbox = document.getElementById('consent-analytics') as HTMLInputElement;
|
||||||
|
const marketingCheckbox = document.getElementById('consent-marketing') as HTMLInputElement;
|
||||||
|
|
||||||
|
// Button handlers
|
||||||
|
const acceptAllBtn = document.getElementById('consent-accept-all');
|
||||||
|
const rejectAllBtn = document.getElementById('consent-reject-all');
|
||||||
|
const showPrefsBtn = document.getElementById('consent-show-preferences');
|
||||||
|
const savePrefsBtn = document.getElementById('consent-save-preferences');
|
||||||
|
const backBtn = document.getElementById('consent-back');
|
||||||
|
|
||||||
|
// Default consent state
|
||||||
|
const defaultConsent: ConsentState = {
|
||||||
|
analytics: false,
|
||||||
|
marketing: false,
|
||||||
|
functional: false,
|
||||||
|
hasConsented: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'pdpa_consent';
|
||||||
|
|
||||||
|
// Save consent to localStorage and server
|
||||||
|
async function saveConsent(newConsent: ConsentState) {
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newConsent));
|
||||||
|
|
||||||
|
// Update nanostore
|
||||||
|
consentStore.set(newConsent);
|
||||||
|
|
||||||
|
// Hide banner
|
||||||
|
if (banner) {
|
||||||
|
banner.setAttribute('hidden', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to server
|
||||||
|
try {
|
||||||
|
await fetch('/api/consent', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: newConsent.hasConsented ? 'accept' : 'reject',
|
||||||
|
purpose: 'all',
|
||||||
|
analytics: newConsent.analytics,
|
||||||
|
marketing: newConsent.marketing,
|
||||||
|
functional: newConsent.functional,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to log consent:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept all cookies
|
||||||
|
acceptAllBtn?.addEventListener('click', () => {
|
||||||
|
saveConsent({
|
||||||
|
analytics: true,
|
||||||
|
marketing: true,
|
||||||
|
functional: true,
|
||||||
|
hasConsented: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reject all cookies
|
||||||
|
rejectAllBtn?.addEventListener('click', () => {
|
||||||
|
saveConsent({
|
||||||
|
analytics: false,
|
||||||
|
marketing: false,
|
||||||
|
functional: false,
|
||||||
|
hasConsented: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show preferences panel
|
||||||
|
showPrefsBtn?.addEventListener('click', () => {
|
||||||
|
if (mainPanel && prefsPanel) {
|
||||||
|
mainPanel.style.display = 'none';
|
||||||
|
prefsPanel.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save custom preferences
|
||||||
|
savePrefsBtn?.addEventListener('click', () => {
|
||||||
|
saveConsent({
|
||||||
|
analytics: analyticsCheckbox?.checked ?? false,
|
||||||
|
marketing: marketingCheckbox?.checked ?? false,
|
||||||
|
functional: true, // Always on
|
||||||
|
hasConsented: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Back to main panel
|
||||||
|
backBtn?.addEventListener('click', () => {
|
||||||
|
if (mainPanel && prefsPanel) {
|
||||||
|
prefsPanel.style.display = 'none';
|
||||||
|
mainPanel.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for existing consent on load
|
||||||
|
function initBanner() {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
// Already consented - hide banner
|
||||||
|
if (banner) {
|
||||||
|
banner.setAttribute('hidden', 'true');
|
||||||
|
}
|
||||||
|
// Sync with nanostore
|
||||||
|
consentStore.set(parsed);
|
||||||
|
} catch {
|
||||||
|
// No valid consent - show banner
|
||||||
|
if (banner) {
|
||||||
|
banner.removeAttribute('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No consent yet - show banner
|
||||||
|
if (banner) {
|
||||||
|
banner.removeAttribute('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
initBanner();
|
||||||
|
</script>
|
||||||
@@ -1,61 +1,70 @@
|
|||||||
# PDPA Consent Logging Template
|
# PDPA Consent Logging Template
|
||||||
|
|
||||||
Template สำหรับเพิ่ม PDPA consent logging ใน Next.js + Payload CMS (MongoDB)
|
Template สำหรับเพิ่ม PDPA consent logging ใน Astro + Tina (Astro DB)
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
```
|
```
|
||||||
consent/
|
consent/
|
||||||
├── collections/
|
├── ConsentBanner.astro # Consent banner component
|
||||||
│ └── ConsentLogs.ts # Payload collection สำหรับ consent logs
|
|
||||||
├── api/
|
├── api/
|
||||||
│ └── route.ts # API endpoint สำหรับบันทึก consent
|
│ └── consent.ts # API endpoints (GET, POST, DELETE)
|
||||||
├── cookie-banner.tsx # CookieBanner component
|
├── db/
|
||||||
└── README.md
|
│ └── config.ts # Astro DB schema (defineTable)
|
||||||
|
├── stores/
|
||||||
|
│ └── consent.ts # Nano Stores for client state
|
||||||
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
## วิธีใช้
|
## วิธีใช้ (Astro)
|
||||||
|
|
||||||
### 1. เพิ่ม ConsentLogs Collection
|
### 1. เพิ่ม Astro DB Schema
|
||||||
|
|
||||||
Copy `collections/ConsentLogs.ts` ไปที่ `src/collections/` ของ project
|
Copy `db/config.ts` ไปที่ `src/db/config.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/db/config.ts
|
||||||
|
import { defineTable, column } from 'astro:db';
|
||||||
|
|
||||||
|
export const ConsentLog = defineTable({
|
||||||
|
columns: {
|
||||||
|
id: column.number({ primaryKey: true }),
|
||||||
|
action: column.text(),
|
||||||
|
purpose: column.text(),
|
||||||
|
analytics: column.boolean({ default: false }),
|
||||||
|
marketing: column.boolean({ default: false }),
|
||||||
|
functional: column.boolean({ default: false }),
|
||||||
|
userAgent: column.text({ optional: true }),
|
||||||
|
ip: column.text({ optional: true }),
|
||||||
|
timestamp: column.date(),
|
||||||
|
sessionId: column.text({ optional: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
### 2. สร้าง API Endpoint
|
### 2. สร้าง API Endpoint
|
||||||
|
|
||||||
Copy `api/route.ts` ไปที่ `src/app/api/consent/route.ts`
|
Copy `api/consent.ts` ไปที่ `src/pages/api/consent.ts`
|
||||||
|
|
||||||
### 3. เพิ่ม CookieBanner Component
|
### 3. เพิ่ม ConsentBanner Component
|
||||||
|
|
||||||
Copy `cookie-banner.tsx` ไปที่ `src/components/`
|
Copy `ConsentBanner.astro` ไปที่ `src/components/consent/ConsentBanner.astro`
|
||||||
|
|
||||||
### 4. เพิ่มใน Layout
|
### 4. เพิ่มใน Layout
|
||||||
|
|
||||||
เพิ่ม `<CookieBanner />` ใน `src/app/(frontend)/layout.tsx`:
|
เพิ่ม `<ConsentBanner />` ใน `src/layouts/Layout.astro`:
|
||||||
|
|
||||||
```tsx
|
```astro
|
||||||
import { CookieBanner } from '@/components/cookie-banner'
|
---
|
||||||
|
import ConsentBanner from '../components/consent/ConsentBanner.astro';
|
||||||
|
---
|
||||||
|
|
||||||
export default function RootLayout({ children }) {
|
<html lang="th">
|
||||||
return (
|
|
||||||
<html>
|
|
||||||
<body>
|
<body>
|
||||||
{children}
|
<slot />
|
||||||
<CookieBanner />
|
<ConsentBanner />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. เพิ่ม Collection ใน payload.config.ts
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import ConsentLogs from './collections/ConsentLogs'
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
collections: [Users, Media, Snacks, Orders, ConsentLogs],
|
|
||||||
// ...
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
@@ -80,7 +89,7 @@ export default buildConfig({
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"doc": {
|
"doc": {
|
||||||
"id": "...",
|
"id": 1,
|
||||||
"action": "accept",
|
"action": "accept",
|
||||||
"purpose": "all",
|
"purpose": "all",
|
||||||
"analytics": true,
|
"analytics": true,
|
||||||
@@ -93,7 +102,46 @@ export default buildConfig({
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### GET /api/consent
|
||||||
|
|
||||||
|
ดึง consent logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:4321/api/consent"
|
||||||
|
```
|
||||||
|
|
||||||
|
### DELETE /api/consent
|
||||||
|
|
||||||
|
Right to be forgotten (ลบข้อมูลตาม พ.ร.บ.)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE "http://localhost:4321/api/consent?sessionId=xxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nano Stores Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { consentStore, hasAnalyticsConsent, hasMarketingConsent } from './stores/consent';
|
||||||
|
|
||||||
|
// Subscribe to changes
|
||||||
|
consentStore.subscribe((state) => {
|
||||||
|
console.log('Consent changed:', state);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check consent
|
||||||
|
if (hasAnalyticsConsent()) {
|
||||||
|
// Load analytics
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
- **ยอมรับทั้งหมด** - เปิดทุกคุกกี้
|
||||||
|
- **ปฏิเสธทั้งหมด** - ปิดทุกคุกกี้ (ยกเว้น functional)
|
||||||
|
- **ตั้งค่าคุกกี้** - แผงปรับแต่งเอง
|
||||||
|
|
||||||
## ⚠️ Pitfalls สำคัญ
|
## ⚠️ Pitfalls สำคัญ
|
||||||
|
|
||||||
1. **ใช้ `mongooseAdapter` ไม่ใช่ `mongodbAdapter`**
|
1. **Astro DB ต้องรันบน server-side** - ใช้ `APIRoute` import
|
||||||
2. **ConsentLogs ต้องใช้ `export default`** ไม่ใช่ named export
|
2. **Nano Stores รันบน client-side** - ใช้ `<script>` tag ใน Astro
|
||||||
|
3. **import ถูกต้อง** - ใช้ `import { db } from 'astro:db'` ไม่ใช่ `defineDb`
|
||||||
120
skills/website-creator/templates/consent/api/consent.ts
Normal file
120
skills/website-creator/templates/consent/api/consent.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { db, eq } from 'astro:db';
|
||||||
|
import { ConsentLog } from '../db/config';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const {
|
||||||
|
action = 'accept',
|
||||||
|
purpose = 'all',
|
||||||
|
analytics = false,
|
||||||
|
marketing = false,
|
||||||
|
functional = false,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
const ip = clientAddress || 'unknown';
|
||||||
|
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||||
|
|
||||||
|
const doc = await db.insert(ConsentLog).values({
|
||||||
|
action,
|
||||||
|
purpose,
|
||||||
|
analytics,
|
||||||
|
marketing,
|
||||||
|
functional,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
doc,
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Consent API error:', error);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to log consent',
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
|
|
||||||
|
let docs;
|
||||||
|
if (sessionId) {
|
||||||
|
docs = await db.select().from(ConsentLog).where(
|
||||||
|
eq(ConsentLog.sessionId, sessionId)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
docs = await db.select().from(ConsentLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
docs,
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Consent GET error:', error);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to retrieve consent logs',
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'sessionId is required',
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await db.delete(ConsentLog).where(
|
||||||
|
eq(ConsentLog.sessionId, sessionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
deleted,
|
||||||
|
message: 'All consent records for this session have been deleted',
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Right to be forgotten error:', error);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to delete consent records',
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { getPayload } from 'payload'
|
|
||||||
import config from '@/payload.config'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/consent - Right to be forgotten (GDPR/PDPA)
|
|
||||||
*
|
|
||||||
* Deletes all consent records for a given session or user
|
|
||||||
*/
|
|
||||||
export async function DELETE(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const payloadConfig = await config
|
|
||||||
const payload = await getPayload({ config: payloadConfig })
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const sessionId = searchParams.get('sessionId')
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find and delete all consent logs for this session
|
|
||||||
const result = await payload.delete({
|
|
||||||
collection: 'consent-logs',
|
|
||||||
where: {
|
|
||||||
sessionId: { equals: sessionId },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
deleted: result.deletedDocs?.length || 0,
|
|
||||||
message: 'All consent records for this session have been deleted'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Right to be forgotten error:', error)
|
|
||||||
return NextResponse.json({ error: 'Failed to delete consent records' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import { CollectionConfig, Field } from 'payload'
|
|
||||||
|
|
||||||
// Consent Log Collection - เก็บ log การยินยอมของ users
|
|
||||||
export const ConsentLog: CollectionConfig = {
|
|
||||||
slug: 'consent-logs',
|
|
||||||
admin: {
|
|
||||||
useAsTitle: 'sessionId',
|
|
||||||
defaultColumns: ['sessionId', 'consentType', 'granted', 'createdAt'],
|
|
||||||
description: 'บันทึกการยินยอมของผู้ใช้ตาม PDPA',
|
|
||||||
},
|
|
||||||
access: {
|
|
||||||
// ทุกคนสามารถสร้าง log ได้ (public)
|
|
||||||
create: () => true,
|
|
||||||
// แต่ดูได้เฉพาะ admin
|
|
||||||
read: ({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
return user.role === 'admin'
|
|
||||||
},
|
|
||||||
// แก้ไขได้เฉพาะ admin
|
|
||||||
update: ({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
return user.role === 'admin'
|
|
||||||
},
|
|
||||||
// ลบได้เฉพาะ admin
|
|
||||||
delete: ({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
return user.role === 'admin'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'sessionId',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
admin: {
|
|
||||||
description: 'Session ID ของผู้ใช้',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'consentType',
|
|
||||||
type: 'select',
|
|
||||||
required: true,
|
|
||||||
options: [
|
|
||||||
{ label: 'Essential', value: 'essential' },
|
|
||||||
{ label: 'Analytics', value: 'analytics' },
|
|
||||||
{ label: 'Marketing', value: 'marketing' },
|
|
||||||
{ label: 'Functional', value: 'functional' },
|
|
||||||
{ label: 'All Accepted', value: 'accept_all' },
|
|
||||||
{ label: 'All Rejected', value: 'reject_all' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'granted',
|
|
||||||
type: 'checkbox',
|
|
||||||
required: true,
|
|
||||||
defaultValue: false,
|
|
||||||
admin: {
|
|
||||||
description: 'ยินยอมหรือไม่',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ipAddress',
|
|
||||||
type: 'text',
|
|
||||||
admin: {
|
|
||||||
description: 'IP Address ของผู้ใช้',
|
|
||||||
readOnly: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'userAgent',
|
|
||||||
type: 'text',
|
|
||||||
admin: {
|
|
||||||
description: 'Browser User Agent',
|
|
||||||
readOnly: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'metadata',
|
|
||||||
type: 'json',
|
|
||||||
admin: {
|
|
||||||
description: 'ข้อมูลเพิ่มเติม',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'createdAt',
|
|
||||||
type: 'date',
|
|
||||||
required: true,
|
|
||||||
admin: {
|
|
||||||
description: 'วันที่และเวลาที่ยินยอม',
|
|
||||||
readOnly: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
hooks: {
|
|
||||||
beforeChange: [
|
|
||||||
({ data }) => {
|
|
||||||
// เพิ่ม timestamp อัตโนมัติ
|
|
||||||
if (!data.createdAt) {
|
|
||||||
data.createdAt = new Date().toISOString()
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consent Settings Collection - เก็บ settings ของ consent banner
|
|
||||||
export const ConsentSettings: CollectionConfig = {
|
|
||||||
slug: 'consent-settings',
|
|
||||||
admin: {
|
|
||||||
useAsTitle: 'title',
|
|
||||||
description: 'ตั้งค่า Cookie Consent Banner',
|
|
||||||
},
|
|
||||||
access: {
|
|
||||||
read: () => true, // Public read
|
|
||||||
create: ({ req: { user } }) => !!user && user.role === 'admin',
|
|
||||||
update: ({ req: { user } }) => !!user && user.role === 'admin',
|
|
||||||
delete: ({ req: { user } }) => !!user && user.role === 'admin',
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'title',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
defaultValue: 'นโยบายคุกกี้',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'description',
|
|
||||||
type: 'textarea',
|
|
||||||
required: true,
|
|
||||||
defaultValue: 'เราใช้คุกกี้เพื่อปรับปรุงประสบการณ์การใช้งานเว็บไซต์ของคุณ คุณสามารถเลือกได้ว่าจะอนุญาตคุกกี้ประเภทใด',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'position',
|
|
||||||
type: 'select',
|
|
||||||
defaultValue: 'bottom',
|
|
||||||
options: [
|
|
||||||
{ label: 'ด้านล่าง (Bottom)', value: 'bottom' },
|
|
||||||
{ label: 'ด้านบน (Top)', value: 'top' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'theme',
|
|
||||||
type: 'select',
|
|
||||||
defaultValue: 'light',
|
|
||||||
options: [
|
|
||||||
{ label: 'Light Mode', value: 'light' },
|
|
||||||
{ label: 'Dark Mode', value: 'dark' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'essentialCookies',
|
|
||||||
type: 'json',
|
|
||||||
admin: {
|
|
||||||
description: 'รายชื่อ essential cookies ที่จำเป็นต้องมี',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'analyticsCookies',
|
|
||||||
type: 'json',
|
|
||||||
admin: {
|
|
||||||
description: 'รายชื่อ analytics cookies',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'marketingCookies',
|
|
||||||
type: 'json',
|
|
||||||
admin: {
|
|
||||||
description: 'รายชื่อ marketing cookies',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'functionalCookies',
|
|
||||||
type: 'json',
|
|
||||||
admin: {
|
|
||||||
description: 'รายชื่อ functional cookies',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'isActive',
|
|
||||||
type: 'checkbox',
|
|
||||||
defaultValue: true,
|
|
||||||
admin: {
|
|
||||||
description: 'แสดง consent banner หรือไม่',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export interface ConsentLogData {
|
|
||||||
action: 'accept' | 'reject' | 'update'
|
|
||||||
purpose: 'analytics' | 'marketing' | 'functional' | 'all'
|
|
||||||
userAgent?: string
|
|
||||||
ip?: string
|
|
||||||
timestamp: string
|
|
||||||
previousConsent?: Record<string, boolean>
|
|
||||||
newConsent?: Record<string, boolean>
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConsentLogs: CollectionConfig = {
|
|
||||||
slug: 'consent-logs',
|
|
||||||
admin: {
|
|
||||||
useAsTitle: 'timestamp',
|
|
||||||
defaultColumns: ['timestamp', 'action', 'purpose', 'ip'],
|
|
||||||
description: 'Log of all consent actions for PDPA compliance',
|
|
||||||
},
|
|
||||||
access: {
|
|
||||||
create: () => true, // Allow anyone to create consent logs (public endpoint)
|
|
||||||
read: () => true, // Allow reading for compliance purposes
|
|
||||||
update: () => false, // Consent logs should not be modified
|
|
||||||
delete: () => false, // Consent logs should not be deleted
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'action',
|
|
||||||
type: 'select',
|
|
||||||
required: true,
|
|
||||||
options: [
|
|
||||||
{ label: 'Accept', value: 'accept' },
|
|
||||||
{ label: 'Reject', value: 'reject' },
|
|
||||||
{ label: 'Update', value: 'update' },
|
|
||||||
],
|
|
||||||
admin: {
|
|
||||||
description: 'The type of consent action',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'purpose',
|
|
||||||
type: 'select',
|
|
||||||
required: true,
|
|
||||||
options: [
|
|
||||||
{ label: 'Analytics', value: 'analytics' },
|
|
||||||
{ label: 'Marketing', value: 'marketing' },
|
|
||||||
{ label: 'Functional', value: 'functional' },
|
|
||||||
{ label: 'All', value: 'all' },
|
|
||||||
],
|
|
||||||
admin: {
|
|
||||||
description: 'The purpose of the consent',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'analytics',
|
|
||||||
type: 'checkbox',
|
|
||||||
defaultValue: false,
|
|
||||||
admin: {
|
|
||||||
description: 'Consent for analytics cookies',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'marketing',
|
|
||||||
type: 'checkbox',
|
|
||||||
defaultValue: false,
|
|
||||||
admin: {
|
|
||||||
description: 'Consent for marketing cookies',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'functional',
|
|
||||||
type: 'checkbox',
|
|
||||||
defaultValue: false,
|
|
||||||
admin: {
|
|
||||||
description: 'Consent for functional cookies',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'userAgent',
|
|
||||||
type: 'text',
|
|
||||||
admin: {
|
|
||||||
readOnly: true,
|
|
||||||
description: 'Browser user agent string',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ip',
|
|
||||||
type: 'text',
|
|
||||||
admin: {
|
|
||||||
readOnly: true,
|
|
||||||
description: 'IP address of the user',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'timestamp',
|
|
||||||
type: 'date',
|
|
||||||
required: true,
|
|
||||||
admin: {
|
|
||||||
readOnly: true,
|
|
||||||
description: 'When the consent was given',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'previousConsent',
|
|
||||||
type: 'json',
|
|
||||||
admin: {
|
|
||||||
readOnly: true,
|
|
||||||
description: 'Previous consent state (for updates)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'newConsent',
|
|
||||||
type: 'json',
|
|
||||||
admin: {
|
|
||||||
readOnly: true,
|
|
||||||
description: 'New consent state',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ConsentLogs
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
|
|
||||||
interface ConsentState {
|
|
||||||
analytics: boolean
|
|
||||||
marketing: boolean
|
|
||||||
functional: boolean
|
|
||||||
hasConsented: boolean
|
|
||||||
timestamp?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultConsent: ConsentState = {
|
|
||||||
analytics: false,
|
|
||||||
marketing: false,
|
|
||||||
functional: false,
|
|
||||||
hasConsented: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'pdpa_consent'
|
|
||||||
|
|
||||||
export function CookieBanner() {
|
|
||||||
const [consent, setConsent] = useState<ConsentState>(defaultConsent)
|
|
||||||
const [showBanner, setShowBanner] = useState(false)
|
|
||||||
const [showPreferences, setShowPreferences] = useState(false)
|
|
||||||
|
|
||||||
// Load consent from localStorage on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(stored)
|
|
||||||
setConsent(parsed)
|
|
||||||
setShowBanner(false)
|
|
||||||
} catch {
|
|
||||||
setShowBanner(true)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setShowBanner(true)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Save consent to localStorage
|
|
||||||
const saveConsent = async (newConsent: ConsentState) => {
|
|
||||||
// Save to localStorage
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newConsent))
|
|
||||||
setConsent(newConsent)
|
|
||||||
setShowBanner(false)
|
|
||||||
setShowPreferences(false)
|
|
||||||
|
|
||||||
// Log to server
|
|
||||||
try {
|
|
||||||
await fetch('/api/consent', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
action: newConsent.hasConsented ? 'accept' : 'reject',
|
|
||||||
purpose: 'all',
|
|
||||||
...newConsent,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to log consent:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept all cookies
|
|
||||||
const acceptAll = () => {
|
|
||||||
saveConsent({
|
|
||||||
analytics: true,
|
|
||||||
marketing: true,
|
|
||||||
functional: true,
|
|
||||||
hasConsented: true,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject all cookies (only functional)
|
|
||||||
const rejectAll = () => {
|
|
||||||
saveConsent({
|
|
||||||
analytics: false,
|
|
||||||
marketing: false,
|
|
||||||
functional: false,
|
|
||||||
hasConsented: true,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save custom preferences
|
|
||||||
const savePreferences = () => {
|
|
||||||
saveConsent({
|
|
||||||
...consent,
|
|
||||||
hasConsented: true,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update individual preference
|
|
||||||
const updatePreference = (key: keyof Pick<ConsentState, 'analytics' | 'marketing' | 'functional'>, value: boolean) => {
|
|
||||||
setConsent(prev => ({ ...prev, [key]: value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no banner to show, return null
|
|
||||||
if (!showBanner) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
boxShadow: '0 -4px 20px rgba(0, 0, 0, 0.15)',
|
|
||||||
padding: '1.5rem',
|
|
||||||
zIndex: 9999,
|
|
||||||
borderTop: '1px solid #e5e5e5',
|
|
||||||
}}
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Cookie Consent Banner"
|
|
||||||
>
|
|
||||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
|
||||||
{!showPreferences ? (
|
|
||||||
// Main banner
|
|
||||||
<div>
|
|
||||||
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '1.125rem', fontWeight: 600 }}>
|
|
||||||
🍪 PDPA Cookie Consent
|
|
||||||
</h3>
|
|
||||||
<p style={{ margin: '0 0 1rem 0', color: '#555', fontSize: '0.9375rem', lineHeight: 1.5 }}>
|
|
||||||
We use cookies to enhance your experience. By continuing to visit this site, you agree to our use of cookies.{' '}
|
|
||||||
<a href="/privacy-policy" style={{ color: '#0066cc' }}>
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
||||||
<button
|
|
||||||
onClick={acceptAll}
|
|
||||||
style={{
|
|
||||||
padding: '0.625rem 1.25rem',
|
|
||||||
backgroundColor: '#22c55e',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '0.9375rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Accept All Cookies
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={rejectAll}
|
|
||||||
style={{
|
|
||||||
padding: '0.625rem 1.25rem',
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
color: '#333',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '0.9375rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reject All
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPreferences(true)}
|
|
||||||
style={{
|
|
||||||
padding: '0.625rem 1.25rem',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: '#0066cc',
|
|
||||||
border: '1px solid #0066cc',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '0.9375rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cookie Preferences
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Preferences panel
|
|
||||||
<div>
|
|
||||||
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '1.125rem', fontWeight: 600 }}>
|
|
||||||
Cookie Preferences
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p style={{ margin: '0 0 1rem 0', color: '#555', fontSize: '0.875rem' }}>
|
|
||||||
Manage your cookie preferences below.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
{/* Functional Cookies */}
|
|
||||||
<div style={{
|
|
||||||
padding: '1rem',
|
|
||||||
backgroundColor: '#f9f9f9',
|
|
||||||
borderRadius: '8px',
|
|
||||||
marginBottom: '0.75rem',
|
|
||||||
border: '1px solid #e5e5e5'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
|
||||||
<div>
|
|
||||||
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Functional Cookies</h4>
|
|
||||||
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
|
|
||||||
Essential for the website to function properly. Cannot be disabled.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
padding: '0.25rem 0.75rem',
|
|
||||||
backgroundColor: '#e5e5e5',
|
|
||||||
color: '#666',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
Always Active
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Analytics Cookies */}
|
|
||||||
<div style={{
|
|
||||||
padding: '1rem',
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '8px',
|
|
||||||
marginBottom: '0.75rem',
|
|
||||||
border: '1px solid #e5e5e5'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div>
|
|
||||||
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Analytics Cookies</h4>
|
|
||||||
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
|
|
||||||
Help us understand how visitors interact with our website.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={consent.analytics}
|
|
||||||
onChange={(e) => updatePreference('analytics', e.target.checked)}
|
|
||||||
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Marketing Cookies */}
|
|
||||||
<div style={{
|
|
||||||
padding: '1rem',
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '8px',
|
|
||||||
marginBottom: '0.75rem',
|
|
||||||
border: '1px solid #e5e5e5'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div>
|
|
||||||
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Marketing Cookies</h4>
|
|
||||||
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
|
|
||||||
Used to track visitors across websites for advertising purposes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={consent.marketing}
|
|
||||||
onChange={(e) => updatePreference('marketing', e.target.checked)}
|
|
||||||
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
|
||||||
<button
|
|
||||||
onClick={savePreferences}
|
|
||||||
style={{
|
|
||||||
padding: '0.625rem 1.25rem',
|
|
||||||
backgroundColor: '#0066cc',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '0.9375rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save Preferences
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPreferences(false)}
|
|
||||||
style={{
|
|
||||||
padding: '0.625rem 1.25rem',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: '#666',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '0.9375rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
38
skills/website-creator/templates/consent/db/config.ts
Normal file
38
skills/website-creator/templates/consent/db/config.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineDb, defineTable, column } from 'astro:db';
|
||||||
|
|
||||||
|
const ConsentLog = defineTable({
|
||||||
|
columns: {
|
||||||
|
id: column.number({ primaryKey: true }),
|
||||||
|
action: column.text(),
|
||||||
|
purpose: column.text(),
|
||||||
|
analytics: column.boolean({ default: false }),
|
||||||
|
marketing: column.boolean({ default: false }),
|
||||||
|
functional: column.boolean({ default: false }),
|
||||||
|
userAgent: column.text({ optional: true }),
|
||||||
|
ip: column.text({ optional: true }),
|
||||||
|
timestamp: column.date(),
|
||||||
|
sessionId: column.text({ optional: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ConsentAction = 'accept' | 'reject' | 'update';
|
||||||
|
export type ConsentPurpose = 'analytics' | 'marketing' | 'functional' | 'all';
|
||||||
|
|
||||||
|
export interface ConsentRow {
|
||||||
|
id: number;
|
||||||
|
action: ConsentAction;
|
||||||
|
purpose: ConsentPurpose;
|
||||||
|
analytics: boolean;
|
||||||
|
marketing: boolean;
|
||||||
|
functional: boolean;
|
||||||
|
userAgent?: string;
|
||||||
|
ip?: string;
|
||||||
|
timestamp: Date;
|
||||||
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineDb({
|
||||||
|
tables: {
|
||||||
|
ConsentLog,
|
||||||
|
},
|
||||||
|
});
|
||||||
75
skills/website-creator/templates/consent/stores/consent.ts
Normal file
75
skills/website-creator/templates/consent/stores/consent.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { map } from 'nanostores';
|
||||||
|
|
||||||
|
export interface ConsentState {
|
||||||
|
analytics: boolean;
|
||||||
|
marketing: boolean;
|
||||||
|
functional: boolean;
|
||||||
|
hasConsented: boolean;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsentLogData extends ConsentState {
|
||||||
|
ip?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultConsent: ConsentState = {
|
||||||
|
analytics: false,
|
||||||
|
marketing: false,
|
||||||
|
functional: false,
|
||||||
|
hasConsented: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const consentStore = map<ConsentState>(defaultConsent);
|
||||||
|
export const STORAGE_KEY = 'pdpa_consent';
|
||||||
|
|
||||||
|
export function loadConsent(): ConsentState {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return defaultConsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored) as ConsentState;
|
||||||
|
consentStore.set(parsed);
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return defaultConsent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultConsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConsentLocally(state: ConsentState): void {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
|
consentStore.set(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAnalyticsConsent(): boolean {
|
||||||
|
const state = consentStore.get();
|
||||||
|
return state.hasConsented && state.analytics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMarketingConsent(): boolean {
|
||||||
|
const state = consentStore.get();
|
||||||
|
return state.hasConsented && state.marketing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasFunctionalConsent(): boolean {
|
||||||
|
const state = consentStore.get();
|
||||||
|
return state.hasConsented;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetConsent(): void {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
consentStore.set(defaultConsent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasConsented(): boolean {
|
||||||
|
return consentStore.get().hasConsented;
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
*.log
|
|
||||||
.env*.local
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Payload CMS
|
|
||||||
PAYLOAD_SECRET=your-secret-key-here-change-in-production
|
|
||||||
|
|
||||||
# Database (PostgreSQL) - database name must match POSTGRES_DB in docker-compose
|
|
||||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/payload
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Multi-stage Dockerfile for Next.js + Payload CMS with PostgreSQL
|
|
||||||
# Requires `output: 'standalone'` in next.config.ts
|
|
||||||
|
|
||||||
FROM node:22-alpine AS deps
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
|
||||||
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate && pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
FROM deps AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NODE_ENV production
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
|
||||||
RUN adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
RUN mkdir .next
|
|
||||||
RUN chown nextjs:nodejs .next
|
|
||||||
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
ENV PORT 3000
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
|
||||||
payload:
|
|
||||||
image: node:22-alpine
|
|
||||||
ports:
|
|
||||||
- '3000:3000'
|
|
||||||
volumes:
|
|
||||||
- .:/home/node/app
|
|
||||||
- node_modules:/home/node/app/node_modules
|
|
||||||
working_dir: /home/node/app/
|
|
||||||
command: sh -c "corepack enable && corepack prepare pnpm@9.0.0 --activate && pnpm install && pnpm dev"
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
networks:
|
|
||||||
- payload-network
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
restart: always
|
|
||||||
image: postgres:16-alpine
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
ports:
|
|
||||||
- '5432:5432'
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: payload
|
|
||||||
POSTGRES_PASSWORD: payloadpass
|
|
||||||
POSTGRES_DB: payload
|
|
||||||
networks:
|
|
||||||
- payload-network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
payload-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
node_modules:
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { withPayload } from '@payloadcms/next/withPayload'
|
|
||||||
import type { NextConfig } from 'next'
|
|
||||||
import path from 'path'
|
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
|
||||||
const dirname = path.dirname(__filename)
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
images: {
|
|
||||||
localPatterns: [
|
|
||||||
{
|
|
||||||
pathname: '/api/media/file/**',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
output: 'standalone',
|
|
||||||
webpack: (webpackConfig) => {
|
|
||||||
webpackConfig.resolve.extensionAlias = {
|
|
||||||
'.cjs': ['.cts', '.cjs'],
|
|
||||||
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
|
||||||
'.mjs': ['.mts', '.mjs'],
|
|
||||||
}
|
|
||||||
return webpackConfig
|
|
||||||
},
|
|
||||||
turbopack: {
|
|
||||||
root: path.resolve(dirname),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withPayload(nextConfig, { devBundleServerPackages: false })
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "nextjs-payload-starter",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Next.js + Payload CMS starter template with PostgreSQL",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
|
|
||||||
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
|
|
||||||
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
|
|
||||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
|
||||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
|
||||||
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
|
|
||||||
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
|
||||||
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
|
|
||||||
"docker:dev": "docker compose up -d",
|
|
||||||
"docker:dev:logs": "docker compose logs -f",
|
|
||||||
"docker:down": "docker compose down"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@payloadcms/next": "^3.82.1",
|
|
||||||
"@payloadcms/richtext-lexical": "^3.82.1",
|
|
||||||
"@payloadcms/ui": "^3.82.1",
|
|
||||||
"@payloadcms/db-postgres": "^3.82.1",
|
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"graphql": "^16.8.1",
|
|
||||||
"next": "^16.2.3",
|
|
||||||
"payload": "^3.82.1",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-dom": "^19.2.4",
|
|
||||||
"sharp": "^0.34.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.19.9",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"eslint": "^9.16.0",
|
|
||||||
"eslint-config-next": "^16.2.3",
|
|
||||||
"prettier": "^3.4.2",
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.20.2 || >=20.9.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #333;
|
|
||||||
background-color: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #0070f3;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
min-height: calc(100vh - 200px);
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
background: white;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { Metadata } from 'next'
|
|
||||||
import './globals.css'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: {
|
|
||||||
default: 'Next.js + Payload CMS',
|
|
||||||
template: '%s | Next.js + Payload CMS',
|
|
||||||
},
|
|
||||||
description: 'A website built with Next.js and Payload CMS',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FrontendLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html lang="th">
|
|
||||||
<body>{children}</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { getPayload } from 'payload'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import config from '@payload-config'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export default async function HomePage() {
|
|
||||||
let posts: any[] = []
|
|
||||||
try {
|
|
||||||
const payload = await getPayload({ config })
|
|
||||||
const { docs } = await payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
limit: 10,
|
|
||||||
sort: '-createdAt',
|
|
||||||
})
|
|
||||||
posts = docs
|
|
||||||
} catch (e) {
|
|
||||||
// Table might not exist yet - that's OK for initial setup
|
|
||||||
console.warn('Could not fetch posts:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
|
||||||
<header style={{ marginBottom: '3rem', borderBottom: '1px solid #eee', paddingBottom: '1rem' }}>
|
|
||||||
<h1 style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>
|
|
||||||
Next.js + Payload CMS
|
|
||||||
</h1>
|
|
||||||
<p style={{ color: '#666' }}>
|
|
||||||
Welcome to your new website. Edit <code>src/app/(frontend)/page.tsx</code> to customize.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>Recent Posts</h2>
|
|
||||||
|
|
||||||
{posts.length === 0 ? (
|
|
||||||
<p style={{ color: '#888' }}>
|
|
||||||
No posts yet. Go to{' '}
|
|
||||||
<Link href="/admin" style={{ color: '#0070f3' }}>
|
|
||||||
Admin Panel
|
|
||||||
</Link>{' '}
|
|
||||||
to create your first post.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
|
||||||
{posts.map((post) => (
|
|
||||||
<li
|
|
||||||
key={post.id}
|
|
||||||
style={{
|
|
||||||
padding: '1rem',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
border: '1px solid #eee',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/posts/${post.slug || post.id}`}
|
|
||||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
|
||||||
>
|
|
||||||
<h3 style={{ margin: 0, marginBottom: '0.5rem' }}>{post.title as string}</h3>
|
|
||||||
{post.createdAt && (
|
|
||||||
<p style={{ margin: 0, fontSize: '0.875rem', color: '#888' }}>
|
|
||||||
{new Date(post.createdAt).toLocaleDateString('th-TH', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer style={{ marginTop: '4rem', paddingTop: '2rem', borderTop: '1px solid #eee' }}>
|
|
||||||
<Link href="/admin" style={{ color: '#0070f3' }}>
|
|
||||||
Go to Admin Panel
|
|
||||||
</Link>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
|
||||||
import type { Metadata } from 'next'
|
|
||||||
|
|
||||||
import config from '@payload-config'
|
|
||||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
|
|
||||||
import { importMap } from '../importMap.js'
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/* THIS FILE IS GENERATED BY PAYLOAD - RUN `pnpm generate:importmap` AFTER CHANGING COLLECTIONS */
|
|
||||||
export const importMap = {}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
|
||||||
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)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
|
||||||
/* Run `pnpm generate:importmap` to regenerate */
|
|
||||||
import config from '@payload-config'
|
|
||||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
|
||||||
|
|
||||||
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
|
||||||
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)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/* Custom styles for Payload admin - add your overrides here */
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
|
||||||
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
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const Media: CollectionConfig = {
|
|
||||||
slug: 'media',
|
|
||||||
access: {
|
|
||||||
read: () => true,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'alt',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
upload: true,
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const Pages: CollectionConfig = {
|
|
||||||
slug: 'pages',
|
|
||||||
admin: {
|
|
||||||
useAsTitle: 'title',
|
|
||||||
defaultColumns: ['title', 'slug', 'updatedAt'],
|
|
||||||
},
|
|
||||||
access: {
|
|
||||||
read: () => true,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'title',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'slug',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
admin: {
|
|
||||||
description: 'URL-friendly version (e.g. "about-us")',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'status',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ label: 'Draft', value: 'draft' },
|
|
||||||
{ label: 'Published', value: 'published' },
|
|
||||||
],
|
|
||||||
defaultValue: 'draft',
|
|
||||||
admin: {
|
|
||||||
description: 'Control publication status',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'content',
|
|
||||||
type: 'richText',
|
|
||||||
label: 'Page Content',
|
|
||||||
admin: {
|
|
||||||
description: 'Main page content — use the visual editor',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'updatedAt',
|
|
||||||
type: 'date',
|
|
||||||
admin: {
|
|
||||||
readOnly: true,
|
|
||||||
date: {
|
|
||||||
pickerAppearance: 'dayAndTime',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
hooks: {
|
|
||||||
beforeChange: [
|
|
||||||
({ data }) => {
|
|
||||||
if (data.title && !data.slug) {
|
|
||||||
data.slug = data.title
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9ก-๙]+/g, '-')
|
|
||||||
.replace(/(^-|-$)/g, '')
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const Posts: CollectionConfig = {
|
|
||||||
slug: 'posts',
|
|
||||||
admin: {
|
|
||||||
useAsTitle: 'title',
|
|
||||||
defaultColumns: ['title', 'slug', 'createdAt'],
|
|
||||||
},
|
|
||||||
access: {
|
|
||||||
read: () => true,
|
|
||||||
create: () => true,
|
|
||||||
update: () => true,
|
|
||||||
delete: () => true,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'title',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'slug',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
admin: {
|
|
||||||
description: 'URL-friendly version of the title',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'content',
|
|
||||||
type: 'richText',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'featuredImage',
|
|
||||||
type: 'upload',
|
|
||||||
relationTo: 'media',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'publishedAt',
|
|
||||||
type: 'date',
|
|
||||||
admin: {
|
|
||||||
date: {
|
|
||||||
pickerAppearance: 'dayAndTime',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'status',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ label: 'Draft', value: 'draft' },
|
|
||||||
{ label: 'Published', value: 'published' },
|
|
||||||
],
|
|
||||||
defaultValue: 'draft',
|
|
||||||
admin: {
|
|
||||||
description: 'Control publication status',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
hooks: {
|
|
||||||
beforeChange: [
|
|
||||||
({ data }) => {
|
|
||||||
if (data.title && !data.slug) {
|
|
||||||
data.slug = data.title
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/(^-|-$)/g, '')
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { CollectionConfig } from 'payload'
|
|
||||||
|
|
||||||
export const Users: CollectionConfig = {
|
|
||||||
slug: 'users',
|
|
||||||
admin: {
|
|
||||||
useAsTitle: 'email',
|
|
||||||
},
|
|
||||||
auth: true,
|
|
||||||
fields: [
|
|
||||||
// Email added by default
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as config } from './payload.config'
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { postgresAdapter } from '@payloadcms/db-postgres'
|
|
||||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
|
||||||
import path from 'path'
|
|
||||||
import { buildConfig } from 'payload'
|
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import sharp from 'sharp'
|
|
||||||
|
|
||||||
import { Users } from './collections/Users'
|
|
||||||
import { Media } from './collections/Media'
|
|
||||||
import { Posts } from './collections/Posts'
|
|
||||||
import { Pages } from './collections/Pages'
|
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
|
||||||
const dirname = path.dirname(filename)
|
|
||||||
|
|
||||||
export default buildConfig({
|
|
||||||
admin: {
|
|
||||||
user: Users.slug,
|
|
||||||
importMap: {
|
|
||||||
baseDir: path.resolve(dirname),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
collections: [
|
|
||||||
Users,
|
|
||||||
Media,
|
|
||||||
Posts,
|
|
||||||
Pages,
|
|
||||||
],
|
|
||||||
editor: lexicalEditor(),
|
|
||||||
secret: process.env.PAYLOAD_SECRET || '',
|
|
||||||
typescript: {
|
|
||||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
|
||||||
},
|
|
||||||
db: postgresAdapter({
|
|
||||||
pool: {
|
|
||||||
connectionString: process.env.DATABASE_URL || '',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
sharp,
|
|
||||||
plugins: [],
|
|
||||||
})
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"],
|
|
||||||
"@payload-config": ["./src/payload.config.ts"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user