diff --git a/skills/seo-multi-channel/SKILL.md b/skills/seo-multi-channel/SKILL.md index 67dba8a..a6e7137 100644 --- a/skills/seo-multi-channel/SKILL.md +++ b/skills/seo-multi-channel/SKILL.md @@ -142,7 +142,7 @@ Output: - meta_description: 150-160 chars - slug: Auto-generated (Thai-friendly) - images: Saved to website repo - - astro_ready: true (content collections format) + - payload_cms_ready: true (REST API format) ``` **X/Twitter Thread:** @@ -415,55 +415,55 @@ def save_image_for_channel(image_data: bytes, topic: str, channel: str) -> str: ### **Website-Creator Integration:** ```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 - Returns deployment status + Publish blog post to Payload CMS via REST API + Returns publication status """ # Parse frontmatter frontmatter = parse_frontmatter(article_md) - + # Detect language lang = detect_content_language(article_md) - + # Generate slug slug = generate_slug(frontmatter['title'], lang) - - # Determine output path - output_path = os.path.join( - website_repo, - 'src/content/blog', - f'({lang})', - f'{slug}.md' + if lang == 'th': + slug = f'th/{slug}' + + # Convert markdown to Lexical JSON + content = markdown_to_lexical(article_md) + + # Prepare Payload document + payload_doc = { + '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 - os.makedirs(os.path.dirname(output_path), exist_ok=True) - - # Write article - 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 + + result = response.json() + + # Return publication info return { 'published': True, + 'id': result.get('id'), 'slug': slug, 'language': lang, - 'path': output_path, - 'deployment_url': f"https://your-domain.com/blog/{slug}" if lang == 'en' else f"https://your-domain.com/th/{slug}" + 'admin_url': f'{website_url}/admin/collections/posts/{result.get("id")}', + 'api_url': f'{website_url}/api/posts', } ``` diff --git a/skills/seo-multi-channel/scripts/auto_publish.py b/skills/seo-multi-channel/scripts/auto_publish.py index 05e728a..e4610be 100644 --- a/skills/seo-multi-channel/scripts/auto_publish.py +++ b/skills/seo-multi-channel/scripts/auto_publish.py @@ -1,8 +1,8 @@ #!/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. """ @@ -11,195 +11,579 @@ import sys import subprocess import argparse import re +import json +import urllib.request +import urllib.error from pathlib import Path from datetime import datetime -from typing import Dict, Optional +from typing import Dict, Optional, List -class AstroPublisher: - """Publish blog posts to Astro content collections""" - - def __init__(self, website_repo: str): +class PayloadPublisher: + """Publish blog posts to Payload CMS via REST API""" + + def __init__(self, website_url: str, website_repo: str = None): """ - Initialize Astro publisher - + Initialize Payload publisher + 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.content_dir = os.path.join(website_repo, 'src/content/blog') - self.images_dir = os.path.join(website_repo, 'public/images/blog') - + self.collection = "posts" + + 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: """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) thai_ratio = thai_chars / total_chars if total_chars > 0 else 0 - return 'th' if thai_ratio > 0.3 else 'en' - - def generate_slug(self, title: str, lang: str = 'en') -> str: + return "th" if thai_ratio > 0.3 else "en" + + def generate_slug(self, title: str, lang: str = "en") -> str: """Generate URL-friendly slug""" # Remove special characters - slug = re.sub(r'[^\w\s-]', '', title.lower()) + slug = re.sub(r"[^\w\s-]", "", title.lower()) # Replace whitespace with hyphens - slug = re.sub(r'[-\s]+', '-', slug) + slug = re.sub(r"[-\s]+", "-", slug) # Remove leading/trailing hyphens - slug = slug.strip('-_') + slug = slug.strip("-_") # Limit length return slug[:100] - + def parse_frontmatter(self, content: str) -> Dict: """Parse frontmatter from markdown content""" import yaml - - if not content.startswith('---'): + + if not content.startswith("---"): return {} - + try: # Extract frontmatter - parts = content.split('---', 2) + parts = content.split("---", 2) if len(parts) >= 2: frontmatter = yaml.safe_load(parts[1]) return frontmatter or {} except: pass - + 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: markdown_content: Full markdown with frontmatter - images: List of image paths to copy - use_git: Whether to git commit and push (default: False - direct write only) - + images: List of image paths to upload + use_git: Whether to git commit and push (default: False) + payload_token: Payload CMS access token + Returns: Publication result """ try: # Parse frontmatter frontmatter = self.parse_frontmatter(markdown_content) - + # Get required fields - title = frontmatter.get('title', 'Untitled') - slug = frontmatter.get('slug') or self.generate_slug(title) - lang = frontmatter.get('lang') or self.detect_language(markdown_content) - - # Determine output path - lang_folder = f'({lang})' - output_dir = os.path.join(self.content_dir, lang_folder) - os.makedirs(output_dir, exist_ok=True) - - output_path = os.path.join(output_dir, f'{slug}.md') - - # Write markdown file (ALWAYS do this) - with open(output_path, 'w', encoding='utf-8') as f: - f.write(markdown_content) - - print(f"\n✓ Saved: {output_path}") - - # Copy images if provided + title = frontmatter.get("title", "Untitled") + slug = frontmatter.get("slug") or self.generate_slug(title) + lang = frontmatter.get("lang") or self.detect_language(markdown_content) + status = frontmatter.get("status", "draft") + description = frontmatter.get("description", "") + + # Extract markdown body (after frontmatter) + body_content = markdown_content + if markdown_content.startswith("---"): + parts = markdown_content.split("---", 2) + if len(parts) >= 3: + body_content = parts[2].strip() + + # Convert markdown to Lexical + lexical_content = self.markdown_to_lexical(body_content) + + # Prepare Payload CMS document + payload_doc = { + "title": title, + "slug": slug, + "content": lexical_content, + "status": status, + "publishedAt": datetime.now().isoformat() + if status == "published" + else None, + } + + if description: + payload_doc["description"] = description + + # 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: - images_output = os.path.join(self.images_dir, slug) - os.makedirs(images_output, exist_ok=True) - for img_path in images: if os.path.exists(img_path): - import shutil - shutil.copy(img_path, images_output) - print(f" ✓ Copied image: {os.path.basename(img_path)}") - - # Git commit and push (OPTIONAL - only if requested and Gitea configured) + uploaded = self._upload_media(img_path, payload_token) + if uploaded: + uploaded_images.append(uploaded) + print(f" ✓ Uploaded image: {os.path.basename(img_path)}") + + # Git commit and push (OPTIONAL) git_result = None - if use_git: - git_result = self.git_commit_and_push(slug, lang) - else: - print(f" ✓ Direct write complete (no git)") - + if use_git and self.website_repo: + git_result = self._git_commit_and_push(payload_doc["slug"], lang) + elif use_git: + print(f" ℹ️ Direct write complete (no git repo configured)") + return { - 'success': True, - 'slug': slug, - 'language': lang, - 'path': output_path, - 'git_result': git_result, - 'method': 'direct_write' if not use_git else 'git_push' + "success": True, + "id": doc_id, + "slug": payload_doc["slug"], + "language": lang, + "action": action, + "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: - return { - 'success': False, - 'error': str(e) + return {"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""" + if not self.website_repo: + return {"success": False, "error": "No git repository configured"} + try: # Check if git repo - if not os.path.exists(os.path.join(self.website_repo, '.git')): - return {'success': False, 'error': 'Not a git repository'} - + if not os.path.exists(os.path.join(self.website_repo, ".git")): + return {"success": False, "error": "Not a git repository"} + # 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 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 - subprocess.run(['git', 'push'], cwd=self.website_repo, check=True, capture_output=True) - - print(f"✓ Committed: {message}") - print(f"✓ Pushed to remote") - + subprocess.run( + ["git", "push"], cwd=self.website_repo, check=True, capture_output=True + ) + + print(f" ✓ Committed: {message}") + print(f" ✓ Pushed to remote") + return { - 'success': True, - 'commit_message': message, - 'triggered_deploy': True + "success": True, + "commit_message": message, + "triggered_deploy": True, } - + except subprocess.CalledProcessError as e: - print(f"✗ Git error: {e.stderr.decode() if e.stderr else str(e)}") - return {'success': False, 'error': 'Git operation failed'} + print(f" ✗ Git error: {e.stderr.decode() if e.stderr else str(e)}") + return {"success": False, "error": "Git operation failed"} except Exception as e: - print(f"✗ Error: {e}") - return {'success': False, 'error': str(e)} + print(f" ✗ Error: {e}") + return {"success": False, "error": str(e)} def main(): - """Test Astro publisher""" - parser = argparse.ArgumentParser(description='Publish to Astro') - 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('--image', action='append', help='Image files to copy') - parser.add_argument('--use-git', action='store_true', help='Use git commit/push (default: direct write only)') - + """Main entry point""" + parser = argparse.ArgumentParser(description="Publish to Payload CMS") + parser.add_argument("--file", required=True, help="Markdown file to publish") + parser.add_argument( + "--website-url", required=True, help="Website URL (e.g., https://example.com)" + ) + 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() - - 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 - with open(args.file, 'r', encoding='utf-8') as f: + with open(args.file, "r", encoding="utf-8") as f: content = f.read() - - # Publish (default: direct write, no git) - publisher = AstroPublisher(args.website_repo) - result = publisher.publish(content, args.image, use_git=args.use_git) - - if result['success']: + + # Publish + publisher = PayloadPublisher(args.website_url, args.website_repo) + result = publisher.publish( + content, images=args.image, use_git=args.use_git, payload_token=payload_token + ) + + if result["success"]: print(f"\n✅ Published successfully!") + print(f" ID: {result['id']}") print(f" Slug: {result['slug']}") print(f" Language: {result['language']}") - print(f" Path: {result['path']}") print(f" Method: {result['method']}") - - if result.get('git_result') and result['git_result'].get('success'): + print(f" Admin: {result['admin_url']}") + + 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" ✓ Deployment triggered") else: 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() diff --git a/skills/seo-multi-channel/scripts/templates/blog.yaml b/skills/seo-multi-channel/scripts/templates/blog.yaml index 51566ab..a8a6633 100644 --- a/skills/seo-multi-channel/scripts/templates/blog.yaml +++ b/skills/seo-multi-channel/scripts/templates/blog.yaml @@ -160,12 +160,12 @@ output: encoding: "utf-8" line_endings: "unix" - astro_integration: - content_collection: "src/content/blog" - language_folders: - thai: "(th)" - english: "(en)" - image_folder: "public/images/blog/{slug}/" + payload_cms_integration: + collection: "posts" + language_prefix: + thai: "th/" + english: "" + image_collection: "media" publishing: auto_publish: "optional (user_choice)" diff --git a/skills/website-creator/SKILL.md b/skills/website-creator/SKILL.md index de84c86..d3de37b 100644 --- a/skills/website-creator/SKILL.md +++ b/skills/website-creator/SKILL.md @@ -1,7 +1,7 @@ --- name: website-creator -description: สร้างเว็บไซต์เต็มรูปแบบด้วย Next.js + Payload CMS พร้อม Workflow สำหรับเว็บใหม่และ Migration ครอบคลุม Design System, Content Collections, Auth, SEO, PDPA Compliance และ Deploy -tags: [nextjs, website, website-development, website-creation, migration, tailwindcss, thai, pdpa, seo, payload-cms, isr, image-generation, picture-it] +description: สร้างเว็บไซต์เต็มรูปแบบด้วย Astro + Tina CMS พร้อม Workflow สำหรับเว็บใหม่และ Migration ครอบคลุม Design System, Content Collections, Auth, SEO, PDPA Compliance และ Deploy +tags: [astro, website, website-development, website-creation, migration, tailwindcss, thai, pdpa, seo, tina-cms, astro-db, image-generation, picture-it] category: software-development related_skills: - spec-driven-development @@ -15,137 +15,160 @@ related_skills: # Website Creator Skill -สร้างเว็บไซต์เต็มรูปแบบด้วย Next.js + Payload CMS +สร้างเว็บไซต์เต็มรูปแบบด้วย Astro + Tina CMS ## Architecture -**Payload admin บังคับใช้ Next.js App Router** -- `@payloadcms/next` ต้องการ Next.js App Router — ไม่สามารถใช้ Astro แทนได้ -- วิธีแก้: Next.js เป็น framework เดียว ทั้ง Payload admin และ frontend +**Astro + Tina CMS Stack:** +- **Astro 6.x** — Static site generator ที่เร็วมาก รองรับ React/Vue/Svelte components +- **Tina CMS** — Self-hosted Git-based CMS สำหรับ visual content editing +- **Astro DB** — Built-in database (libSQL) สำหรับ consent logs และ dynamic content +- **Tailwind CSS 4.x** — ใช้ `@tailwindcss/vite` plugin -**Pattern อ้างอิง:** github.com/payloadcms/payload/tree/main/templates/with-postgres +**Pattern อ้างอิง:** ใช้ template ที่มีอยู่แล้ว: `templates/astro-tina-starter/` -### Next.js App Router Structure +### Astro Project Structure ``` -src/app/ -├── (payload)/ # Payload admin + API -│ ├── admin/[[...segments]]/ ← Payload admin panel (:3000/admin) -│ ├── api/[[...slug]]/ ← REST API (:3000/api/...) -│ ├── api/graphql/ ← GraphQL endpoint -│ ├── api/graphql-playground/ -│ ├── custom.scss -│ └── layout.tsx -│ -└── (frontend)/ # Frontend pages ของเรา - ├── layout.tsx - ├── page.tsx ← หน้าแรก - ├── globals.css - └── posts/[[...slug]]/ ← dynamic post pages +src/ +├── components/ # Astro/React components +│ └── consent/ # PDPA consent system +├── content/ # Tina CMS content (MDX) +│ ├── posts/ # Blog posts +│ ├── pages/ # Static pages +│ └── settings/ # Site settings (JSON) +├── layouts/ +│ └── Layout.astro # Main layout +├── pages/ +│ ├── index.astro # Home page +│ └── [...slug].astro # Dynamic pages +├── stores/ # Nano Stores (client state) +├── styles/ +│ └── global.css # Tailwind v4 + @theme +├── db/ +│ └── config.ts # Astro DB schema +└── env.d.ts + +.tina/ # Tina CMS configuration +├── config.ts # Tina config +└── schema.ts # Content schema + +astro.config.mjs # Astro + Tailwind v4 + Tina +package.json ``` -**Database: MongoDB (Default — ใช้ mongooseAdapter)** -- PostgreSQL ถูกตัดออกแล้ว ทุก project ใช้ MongoDB เป็นมาตรฐาน -- ถ้าต้องการ PostgreSQL → ใช้ template อื่นหรือสร้างเอง +### Static vs SSR -### ISR vs Static - -- **Static (default):** หน้าส่วนใหญ่ pre-built HTML + cache -- **Dynamic:** ราคา/ส่วน dynamic ใช้ `revalidate` หรือ client-side fetch -- ปรับได้ใน `generateStaticParams` + `revalidate` tags +- **Static (default):** Pre-built HTML + รันบน CDN +- **SSR:** ใช้ `output: 'server'` สำหรับ dynamic pages +- Astro มี hybrid mode — บางหน้า static บางหน้า dynamic ## Critical Configuration Rules -### 1. payload.config.ts (MongoDB — มาตรฐาน) +### 1. astro.config.mjs (Tailwind v4 + Tina) -```ts -import { mongooseAdapter } from '@payloadcms/db-mongodb' -import { lexicalEditor } from '@payloadcms/richtext-lexical' -import path from 'path' -import { buildConfig } from 'payload' -import { fileURLToPath } from 'url' +```javascript +import { defineConfig } from 'astro/config' +import tailwindcss from '@tailwindcss/vite' +import tina from 'tinacms' -export default buildConfig({ - admin: { - user: Users.slug, - importMap: { baseDir: path.resolve(dirname) }, +export default defineConfig({ + integrations: [ + tina({ + enabled: !!process.env.TINA_TOKEN, + sidebar: { partials: [] }, + }), + ], + vite: { + plugins: [tailwindcss()], // Tailwind v4 ใช้ @tailwindcss/vite + }, + output: 'static', + server: { + port: 4321, }, - collections: [Users, Media, Posts], - editor: lexicalEditor(), - secret: process.env.PAYLOAD_SECRET || '', - typescript: { outputFile: path.resolve(dirname, 'payload-types.ts') }, - db: mongooseAdapter({ - url: process.env.MONGODB_URL || 'mongodb://localhost:27017/my-database', - }), }) ``` -**ติดตั้ง MongoDB adapter:** -```bash -pnpm add @payloadcms/db-mongodb@3.82.0 -``` - -**รัน MongoDB ด้วย Docker:** -```bash -docker run -d --name mongo -p 27017:27017 mongo:7 -``` - -**Environment:** -```env -MONGODB_URL=mongodb://localhost:27017/my-database -PAYLOAD_SECRET=your-secret-here -``` - ### 2. Required Dependencies ```json { "dependencies": { - "@payloadcms/next": "^3.82.0", - "@payloadcms/db-postgres": "^3.82.0", - "@payloadcms/richtext-lexical": "^3.82.0", - "@payloadcms/ui": "^3.82.0", - "payload": "^3.82.0", - "next": "^16.2.3", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "sharp": "^0.34.2", - "cross-env": "^7.0.3" + "astro": "^6.1.7", + "@tinacms/cli": "^2.1.0", + "tinacms": "^2.2.0", + "@astrojs/db": "^0.14.0", + "nanostores": "^0.11.0", + "@nanostores/preact": "^0.5.0" }, "devDependencies": { - "tailwindcss": "^3.4.0", - "postcss": "^8.4.0", - "autoprefixer": "^10.4.0" + "@tailwindcss/vite": "^4.0.0", + "tailwindcss": "^4.0.0", + "@astrojs/mdx": "^4.0.0", + "typescript": "^5.6.0" } } ``` -**⚠️ ติดตั้ง Tailwind ต้องใช้ v3 (`tailwindcss@3`) ไม่ใช่ v4** — v4 มี syntax ต่างกัน (`@import "tailwindcss"` vs `@tailwind base;`) และ Payload 3.x ยังไม่รองรับ v4 อย่างเป็นทางการ -- ถ้า `npm install -D tailwindcss` ติด peer dep conflict → ใช้ `--legacy-peer-deps` -- ถ้า project มี `"type": "module"` → postcss config ต้องเป็น `postcss.config.cjs` (ไม่ใช่ `.js`) -- globals.css สำหรับ v3: ใช้ `@tailwind base; @tailwind components; @tailwind utilities;` +**CRITICAL: Tailwind v4 ใช้ `@tailwindcss/vite` plugin ไม่ใช่ `@astrojs/tailwind`** -### 3. next.config.ts +### 3. Tailwind v4 Configuration -```ts -import { withPayload } from '@payloadcms/next/withPayload' -import type { NextConfig } from 'next' +Tailwind v4 ไม่มี `tailwind.config.js` — ใช้ CSS `@theme` block แทน: -const nextConfig: NextConfig = { - output: 'standalone', - images: { - localPatterns: [{ pathname: '/api/media/file/**' }], - }, +```css +/* src/styles/global.css */ +@import "tailwindcss"; + +@theme { + /* Fonts */ + --font-sans: "Inter", "Noto Sans Thai", system-ui, sans-serif; + + /* Colors */ + --color-primary-50: #f8fafc; + --color-primary-900: #0f172a; + --color-accent-500: #3b82f6; + + /* Border Radius */ + --radius-sm: 0.25rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; } - -export default withPayload(nextConfig, { devBundleServerPackages: false }) ``` -### 4. Collection Imports — ใช้ `import type` +### 4. Tina CMS Schema -```ts -import type { CollectionConfig } from 'payload' -// ห้ามใช้ `import { CollectionConfig } from 'payload'` — มันคือ type เท่านั้น +```typescript +// .tina/schema.ts +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: 'slug', label: 'Slug', required: true }, + { type: 'datetime', name: 'publishedAt', label: 'Published At' }, + { 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: 'slug', label: 'Slug', required: true }, + { type: 'rich-text', name: 'body', label: 'Body', isBody: true }, + ], + }, + ], +}) ``` --- @@ -205,80 +228,49 @@ import type { CollectionConfig } from 'payload' --- -### Step 4: Setup Next.js + Payload CMS Project +### Step 4: Setup Astro + Tina CMS Project -**ใช้ template ที่มีอยู่แล้ว: `templates/nextjs-payload-starter/`** +**ใช้ script ที่มีอยู่แล้ว:** ```bash -# 1. Copy template ไปที่ project ใหม่ -cp -r ~/.hermes/skills/website-creator/templates/nextjs-payload-starter /path/to/my-website +# สร้าง project ใหม่จาก template +bash skills/website-creator/scripts/new-project.sh my-website -# 2. เข้าไปใน project -cd /path/to/my-website - -# 3. ติดตั้ง dependencies -pnpm install - -# 4. สร้าง .env -cp .env.example .env -# แก้ PAYLOAD_SECRET และ DATABASE_URL - -# 5. Generate types -pnpm generate:types -pnpm generate:importmap - -# 4. รัน dev server -```bash -# Next.js dev default: binds all interfaces, ไม่ต้องใส่ --host -pnpm dev +# หรือระบุ path +bash skills/website-creator/scripts/new-project.sh my-website /path/to/projects/ ``` +**Script ทำอะไร:** +1. Copy template `templates/astro-tina-starter/` +2. เพิ่ม PDPA consent system +3. Copy legal templates (privacy policy, terms) +4. ติดตั้ง dependencies +5. สร้าง .env file +6. Initialize git + **เปิด browser:** -- Frontend: http://localhost:3000 หรือ http://:3000 -- Payload Admin: http://localhost:3000/admin หรือ http://:3000/admin - -**ถ้า port 3000 มี process อื่นใช้ หรือ /admin ขึ้น 404:** -```bash -# Kill ทุก Next.js process แล้วรันใหม่ -pkill -9 -f "next dev" -cd /home/kunthawat/moreminimore-next && rm -rf .next && pnpm dev -``` +- Frontend: http://localhost:4321 +- Tina Admin: http://localhost:4321/admin (dev mode) --- ### Step 5: พัฒนา Components + Pages -**เรียก skills: `spec-driven-development` + `api-and-interface-design` + `ckm:design` + `ckm:ui-styling` + `payload` + `picture-it`** +**เรียก skills: `spec-driven-development` + `api-and-interface-design` + `ckm:design` + `ckm:ui-styling` + `frontend-ui-engineering`** สร้างตาม sitemap ที่วางแผนไว้: - FrontendLayout, Navigation, Footer - Pages (Home, About, Services, Contact) - Blog listing + detail pages - Forms (Contact form ส่ง email จริง) -- Auth (Payload built-in auth) **สำคัญ — แยก Design Layer กับ Content Layer:** Design skill (ui-ux-pro-max) ออกแบบ **หน้าตา + layout + animation** — ไม่ใช่ content structure -Payload Lexical เก็บ **เนื้อหา (ข้อความ, format, links, images)** — ไม่ใช่ layout +Tina CMS เก็บ **เนื้อหา (ข้อความ, format, links, images)** — ไม่ใช่ layout ทั้งสองอยู่คนละ layer กัน → ต้องแยกทำ แล้วมารวมกันตอน integrate -ดู **Design + Payload Integration** ด้านล่าง สำหรับ workflow การรวม design output กับ Payload content - -**ตัวอย่าง:** - -```bash -/skill ckm:ui-styling -"สร้าง components สำหรับเว็บบริษัท: - - Navigation bar (sticky, responsive) - - Hero section - - Service cards - - Footer with contact info - - Button styles - - Form inputs" -``` - --- ### Step 5b: สร้างภาพประกอบด้วย `picture-it` @@ -341,48 +333,22 @@ picture-it text \ picture-it grade -i hero-texted.png --name cinematic -o hero-final.png ``` -**Workflow ตัวอย่าง — Service Illustration:** - -```bash -# 1. Generate scene -picture-it generate \ - --prompt "professional web development workflow, code screen, modern design tools" \ - --size 800x600 \ - --model flux-schnell \ - -o service-illustration.png - -# 2. Remove background if needed -picture-it remove-bg -i service-illustration.png -o service-no-bg.png -``` - -**การเลือก Model:** - -| Task | Model | เหตุผล | -|------|-------|--------| -| Draft/fast | `flux-schnell` | $0.003 — ใช้ได้เลยสำหรับ 90% ของ use cases | -| ต้องการ text ในภาพ | `recraft-v3` | รองรับภาษาไทยในตัว image ได้ดีกว่า | -| ต้องการแก้ไขเฉพาะจุด | `kontext` | $0.04 — สำหรับ localized edits | -| Realistic photo | `banana-pro` | $0.15 — สำหรับ team/about photos | - **การบันทึกภาพไว้ใน project:** ``` -src/app/(frontend)/ -├── assets/ -│ ├── heroes/ ← Hero images -│ ├── services/ ← Service illustrations -│ ├── blog/ ← Blog heroes -│ └── og/ ← Open Graph images +src/ +├── components/ +│ └── Hero.astro # Hero component +└── assets/ + ├── heroes/ # Hero images + ├── services/ # Service illustrations + ├── blog/ # Blog heroes + └── og/ # Open Graph images ``` -**หมายเหตุ:** -- ภาพทุกภาพที่สร้างจาก picture-it ให้เก็บ source file (PNG) ไว้ใน project ด้วย -- สำหรับ OG Image ควรสร้าง template pipeline ที่ consistent ทั้งเว็บ (ใช้ `--font "Kanit"` + `--font-size` คงที่) -- ถ้าต้องการ team photos ที่ realistic ใช้ `banana-pro` และ prompt ที่ระบุชัดเจนเรื่อง nationality/ethnicity เพื่อให้ได้ผลลัพธ์ที่ตรงความต้องการ - --- -### Step 5c: Design + Payload Content Integration +### Step 5c: Design + Tina Content Integration **Design layer กับ Content layer แยกกัน — ค่อยรวมตอน build** @@ -396,22 +362,22 @@ src/app/(frontend)/ │ • Animation specs (150-300ms, ease-out) │ │ • Layout grid, responsive breakpoints │ │ • Interaction states (hover, press, disabled) │ -│ Output: React + Tailwind code (component skeleton) │ +│ Output: Astro + Tailwind code (component skeleton) │ └─────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────┐ -│ Payload CMS — Content Layer │ +│ Tina CMS — Content Layer │ │ • ข้อความ + format (bold, italic, link) │ │ • Headings (H1-H6) │ -│ • Lists, blockquotes, code blocks │ -│ • Images, links │ -│ Output: Lexical JSON (เนื้อหา) │ +│ • Lists, blockquotes, code blocks │ +│ • Images, links │ +│ Output: MDX files (เนื้อหา) │ └─────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────┐ -│ Integration — ครอบ Payload content ด้วย Design │ -│ • Design component ครอบ RichText output │ -│ • Animation class ที่ wrapper element │ +│ Integration — ครอบ Tina content ด้วย Design │ +│ • Design component ครอบ MDX output │ +│ • Animation class ที่ wrapper element │ │ • Design tokens apply ผ่าน Tailwind prose │ └─────────────────────────────────────────────────────────┘ ``` @@ -423,210 +389,135 @@ src/app/(frontend)/ ui-ux-pro-max → Component structure, tokens, animations Output: Component skeleton (ไม่มี content) ↓ -[2] Payload Phase - สร้าง Collections + richText Fields - Output: Content structure ใน Payload +[2] Tina Phase + สร้าง Content Collections + MDX files + Output: Content structure ใน Tina (src/content/) ↓ [3] Content Phase - พิมพ์ content ใน /admin (Lexical editor) - Output: Lexical JSON + พิมพ์ content ใน Tina Admin (/admin) + Output: MDX files ↓ [4] Integration Phase - ครอบ Payload content ด้วย Design components + ครอบ Tina content ด้วย Design components ``` -#### ตัวอย่าง: Component Structure (Design Output) +#### ตัวอย่าง: Page Structure (Design Output) Design skill อาจให้ component แบบนี้: -```tsx +```astro +--- // ❌ สิ่งที่ design skill อาจให้มา — hardcode content -
-

Welcome to Our Site

// hardcode -

Amazing content here...

// hardcode +--- + +
+

Welcome to Our Site

+

Amazing content here...

``` ต้องแปลงเป็น: -```tsx -// ✅ ครอบ Payload content ด้วย design component -import { RichText } from '@payloadcms/richtext-lexical' +```astro +--- +// ✅ ครอบ Tina content ด้วย design component +import { Container, Heading, Prose } from '@/components/ui'; -
-

{post.title}

- {post.heroContent && ( - +interface Props { + title: string; + body: string; // จาก MDX frontmatter หรือ Tina query +} + +const { title, body } = Astro.props; +--- + + + {title} + {body && ( + + + )} -
+ ``` -#### Payload Collection: แบ่ง Content Fields ตาม Section +#### Tina Content Collections -กำหนดว่า content แต่ส่วนเก็บใน field ไหน: +กำหนดว่า content แต่ส่วนเก็บใน collection ไหน: -```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 section - { name: 'features', type: 'array', +```typescript +// .tina/schema.ts +const schema = defineSchema({ + collections: [ + { + name: 'post', + label: 'Posts', + path: 'src/content/posts', fields: [ - { name: 'heading', type: 'text' }, - { name: 'content', type: 'richText' }, // content ในแต่ละ feature card - ] + { type: 'string', name: 'title', label: 'Title', required: true }, + { type: 'string', name: 'slug', label: 'Slug', required: true }, + { type: 'string', name: 'description', label: 'Description' }, + { type: 'datetime', name: 'publishedAt', label: 'Published At' }, + { 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 }, + ], + }, + { + name: 'settings', + label: 'Settings', + path: 'src/content/settings', + format: 'json', + fields: [ + { type: 'string', name: 'siteName', label: 'Site Name' }, + { type: 'string', name: 'siteDescription', label: 'Description' }, + ], }, - { name: 'testimonial', type: 'richText' }, - { name: 'featuredImage', type: 'upload', relationTo: 'media' }, - { name: 'status', type: 'select', options: [...], defaultValue: 'draft' }, ], -} +}) ``` -#### วิธี Integrate: Design Components + Payload Content +#### วิธี Integrate: Design Components + Tina Content -```tsx -// src/app/(frontend)/posts/[slug]/page.tsx -import { getPost } from '@/lib/payload-helpers' -import { RichText } from '@payloadcms/richtext-lexical' -import { cn } from '@/lib/utils' +```astro +--- +// src/pages/blog/[slug].astro +import Layout from '@/layouts/Layout.astro'; +import { Container, Heading, Prose } from '@/components/ui'; -// 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', -} +// Astro ดึง content จาก MDX files +const { slug } = Astro.params; -function HeroSection({ title, content }: { title: string; content: any }) { - return ( -
-

{title}

- {content && ( -
- {/* Payload content → RichText → design wrapper */} - -
- )} -
- ) -} +// ด้วย Astro content collections +const { post } = Astro.props; +--- -function FeatureCard({ heading, content }: { heading: string; content: any }) { - return ( -
-

{heading}

- {content && } -
- ) -} - -function FeaturesGrid({ features }: { features: any[] }) { - return ( -
-
- {features.map((f, i) => ( - - ))} -
-
- ) -} - -export default async function PostPage({ params }: { params: { slug: string } }) { - const post = await getPost(params.slug) - if (!post) return
Not found
- - return ( -
- {/* Hero: design component ครอบ Payload content */} - - - {/* Features: layout จาก design, content จาก Payload */} - {post.features?.length > 0 && ( - - )} - - {/* Testimonial: design wrapper + Payload content */} - {post.testimonial && ( -
- -
- )} -
- ) -} + + + + {post.data.title} + + + {post.data.description && ( +

{post.data.description}

+ )} + + + + + +
+
``` -#### Animation ทำยังไง - -Animation apply ที่ **wrapper element** ไม่ใช่ที่ content — เพราะ Lexical JSON เก็บแค่ content structure ไม่เก็บ animation - -```tsx -// Design layer: ui-ux-pro-max ให้ animation spec -const animation = { - hero: 'animate-hero-in', - card: 'animate-card-in', - stagger: 'delay-100', // stagger ระหว่าง cards -} - -// Content layer: Payload content อยู่ใน richText -// Integration: รวม animation class กับ Payload output - - - -// Apply animation ที่ wrapper element -
- -
- -// หรือใช้ wrapper component - - - -``` - -#### Tailwind Prose Setup - -ติดตั้ง typography plugin เพื่อ style richText output: - -```bash -pnpm add @tailwindcss/typography -``` - -```ts -// tailwind.config.ts -plugins: [require('@tailwindcss/typography')], -``` - -ใช้ class `prose` กับ `` component: - -```tsx - -``` - -#### สรุป: Design + Payload Layer - -| 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 | - -**หลักการ:** Design skill สร้าง "ภาชนะ" — Payload สร้าง "เนื้อหา" — Integration ค่อยรวมกัน - --- ### Step 7: PDPA Compliance @@ -647,98 +538,103 @@ plugins: [require('@tailwindcss/typography')], - ถ้า reject → ไม่ load GA4/marketing scripts **3.2 Consent Logging (PDPA)** -- เก็บ log ลง Payload collection ชื่อ `consent-logs` -- ระบุ: action, purpose, analytics/marketing/functional flags, ip, userAgent, timestamp, previousConsent, newConsent +- เก็บ log ลง Astro DB (ไม่ต้อง MongoDB ภายนอก) +- ระบุ: action, purpose, analytics/marketing/functional flags, ip, userAgent, timestamp **ไฟล์ที่ต้องสร้าง:** -1. **`src/collections/ConsentLogs.ts`** - Payload collection: -```ts -import type { CollectionConfig } from 'payload' +1. **`ConsentBanner.astro`** — Consent banner component (มีใน template) -const ConsentLogs: CollectionConfig = { - slug: 'consent-logs', - admin: { useAsTitle: 'timestamp', defaultColumns: ['timestamp', 'action', 'purpose', 'ip'] }, - access: { - create: () => true, // public endpoint - read: () => true, - update: () => false, // immutable - delete: () => false, // immutable +```astro +--- +// src/components/consent/ConsentBanner.astro +import { consentStore } from '@/stores/consent'; +--- + + + + +``` + +2. **`db/config.ts`** — Astro DB schema: + +```typescript +// src/db/config.ts +import { defineDb, column } from 'astro:db'; + +export const ConsentLog = defineTable({ + columns: { + id: column serial({ 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({ default: { now: true } }), + sessionId: column text({ optional: true }), }, - fields: [ - { name: 'action', type: 'select', required: true, options: [ - { label: 'Accept', value: 'accept' }, - { label: 'Reject', value: 'reject' }, - { label: 'Update', value: 'update' }, - ]}, - { name: 'purpose', type: 'select', required: true, options: [ - { label: 'Analytics', value: 'analytics' }, - { label: 'Marketing', value: 'marketing' }, - { label: 'Functional', value: 'functional' }, - { label: 'All', value: 'all' }, - ]}, - { name: 'analytics', type: 'checkbox', defaultValue: false }, - { name: 'marketing', type: 'checkbox', defaultValue: false }, - { name: 'functional', type: 'checkbox', defaultValue: false }, - { name: 'userAgent', type: 'text', admin: { readOnly: true } }, - { name: 'ip', type: 'text', admin: { readOnly: true } }, - { name: 'timestamp', type: 'date', required: true, admin: { readOnly: true } }, - { name: 'previousConsent', type: 'json', admin: { readOnly: true } }, - { name: 'newConsent', type: 'json', admin: { readOnly: true } }, - ], -} -export default ConsentLogs +}); ``` -2. **`src/app/api/consent/route.ts`** - API endpoint (Next.js App Router): -```ts -import { NextRequest, NextResponse } from 'next/server' -import { getPayload } from 'payload' -import config from '@/payload.config' +3. **`src/pages/api/consent.ts`** — API endpoint: -export async function POST(request: NextRequest) { - const payload = await getPayload({ config: await config }) - const body = await request.json() - const { action, purpose, analytics, marketing, functional, previousConsent } = body +```typescript +// src/pages/api/consent.ts +import type { APIRoute } from 'astro'; +import { db, ConsentLog } from 'astro:db'; - const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown' - const userAgent = request.headers.get('user-agent') || 'unknown' - - const log = await payload.create({ - collection: 'consent-logs', - data: { - action, purpose, - analytics: analytics ?? false, - marketing: marketing ?? false, - functional: functional ?? false, - userAgent, ip, - timestamp: new Date().toISOString(), - previousConsent: previousConsent || null, - newConsent: { analytics, marketing, functional }, - }, - }) - return NextResponse.json({ success: true, doc: log }) -} +export const POST: APIRoute = async ({ request }) => { + const body = await request.json(); + const { action, purpose, analytics, marketing, functional } = body; + + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown'; + const userAgent = request.headers.get('user-agent') || 'unknown'; + + await db.insert(ConsentLog).values({ + action, + purpose, + analytics, + marketing, + functional, + userAgent, + ip, + }); + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +}; ``` -3. **`src/components/cookie-banner.tsx`** - Client component ที่เก็บ localStorage + call API +4. **เพิ่มใน astro.config.mjs:** -4. **เพิ่ม ConsentLogs ใน `payload.config.ts`:** -```ts -import { ConsentLogs } from './collections/ConsentLogs' -// ... -collections: [Users, Media, Snacks, Orders, ConsentLogs], -``` +```javascript +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import tailwindcss from '@tailwindcss/vite'; +import tina from 'tinacms'; -5. **ติดตั้ง MongoDB adapter (ถ้าใช้ MongoDB):** -```bash -pnpm add @payloadcms/db-mongodb@3.49.1 -``` -```ts -import { mongooseAdapter } from '@payloadcms/db-mongodb' -db: mongooseAdapter({ - url: process.env.MONGODB_URL || 'mongodb://localhost:27017/my-database', -}) +export default defineConfig({ + integrations: [tina()], + vite: { plugins: [tailwindcss()] }, + output: 'static', + // Astro DB รวมอยู่แล้ว ไม่ต้องติดตั้งเพิ่ม +}); ``` **3.3 Right to be Forgotten** @@ -748,26 +644,36 @@ db: mongooseAdapter({ ### Step 8: SEO Setup -**เรียก skills: `seo-analyzers` + `seo-geo` + `seo-multi-channel` + `payload` (sub-skill)** +**เรียก skills: `seo-analyzers` + `seo-geo` + `seo-multi-channel`** -1. **Payload SEO Analyzer Plugin** (`@consilioweb/seo-analyzer`): - - ติดตั้งและเพิ่มใน `payload.config.ts` plugins array - - ทำให้ `/admin` มี SEO dashboard, score tracking, redirect manager - - ดู: **SEO Analyzer Plugin Integration** ด้านล่าง +1. **Meta tags ทุกหน้า** — ใช้ frontmatter จาก MDX หรือ static +2. **sitemap.xml** — Astro ใช้ `@astrojs/sitemap` +3. **robots.txt** +4. **Open Graph images** — **ใช้ `picture-it` สร้าง OG image template ที่ consistent** +5. **JSON-LD structured data** +6. **Thai language optimization** -2. **Meta tags ทุกหน้า** — ใช้ meta fields จาก Payload collection หรือ static -3. **sitemap.xml** — Next.js dynamic route หรือ `@payloadcms/plugin-seo` -4. **robots.txt** -5. **Open Graph images** — **ใช้ `picture-it` สร้าง OG image template ที่ consistent** -6. **JSON-LD structured data** -7. **Thai language optimization** +```bash +# ติดตั้ง sitemap +npx astro add sitemap +``` + +```javascript +// astro.config.mjs +import sitemap from '@astrojs/sitemap'; + +export default defineConfig({ + site: 'https://example.com', + integrations: [sitemap(), tina()], +}); +``` --- ### Step 9: Preview + QA ```bash -pnpm dev --host 0.0.0.0 +npm run dev ``` **ก่อน QA — เรียก skills ตามลำดับ:** @@ -813,15 +719,13 @@ pnpm dev --host 0.0.0.0 ### ความหมายของ "Existing Repo" -Repo ที่มีโครงสร้าง Next.js + Payload แล้ว แต่ยังไม่สมบูรณ์ — เช่น: +Repo ที่มีโครงสร้าง Astro + Tina แล้ว แต่ยังไม่สมบูรณ์ — เช่น: - มี starter template แต่ไม่มี content - มี design tokens แล้วแต่ยังไม่มี pages - มีบางหน้าแต่ต้องการปรับปรุงเนื้อหา/design **ไม่ใช่** 新规 creation — มี codebase อยู่แล้ว ปรับในที่เดิม -**รวมถึง Migration ของ Content เข้า Payload CMS** — Posts และ Pages ต้องย้ายเข้า Payload backend เพื่อให้แก้ไขจาก `/admin` ได้ทันทีหลัง login - --- ### ขั้นตอน @@ -829,581 +733,243 @@ Repo ที่มีโครงสร้าง Next.js + Payload แล้ว ``` [1] ตรวจสอบ repo ที่มี (terminal/find ดูโครงสร้าง) │ -[2] สำรวจ: payload.config.ts, components, pages, design tokens +[2] สำรวจ: astro.config.mjs, components, pages, design tokens │ [3] สำรวจ content ที่มี (Posts, Pages จากเว็บเดิม) → วางแผน Migration │ -[4] สร้าง Payload Collections (Posts + Pages) ถ้ายังไม่มี +[4] Migrate content เข้า Tina CMS │ -[5] Migrate content เข้า Payload CMS +[5] ตรวจสอบ /admin ว่าเห็น Posts + Pages พร้อมแก้ไขได้ │ -[6] ตรวจสอบ /admin ว่าเห็น Posts + Pages พร้อมแก้ไขได้ +[6] Integrate: อัปเดต Pages ให้อ่านจาก Tina แทน hardcode + │ (ดู **Design + Tina Integration** ด้านบน) │ -[7] Integrate: อัปเดต Frontend ให้อ่านจาก Payload แทน hardcode - │ (ดู **Design + Payload Integration** ด้านบน) +[7] สรุปสิ่งที่มี vs สิ่งที่ขาด → แผน Sitemap │ -[8] สรุปสิ่งที่มี vs สิ่งที่ขาด → แผน Sitemap +[8] ถามคำถามที่ขาด (content, portfolio, pricing, dark/light) │ -[9] ถามคำถามที่ขาด (content, portfolio, pricing, dark/light) - │ -[10] รอ approve +[9] รอ approve ↓ จากนั้น → Workflow A Step 3 เป็นต้นไป (พัฒนา Components + Pages) ``` -### [1] ตรวจสอบ Repo ที่มี +--- + +## Migration: Next.js + Payload → Astro + Tina + +**ใช้ script ที่มีอยู่แล้ว:** ```bash -# ดูโครงสร้างไฟล์ (ไม่เอา node_modules/.next) -find /path/to/repo -maxdepth 3 -type f \ - -not -path '*/node_modules/*' \ - -not -path '*/.next/*' - -# ดู payload.config.ts, tailwind.config.ts -# ดู globals.css, Navigation, Footer -# ดู src/app/ (frontend) pages +# Migrate existing website ไป Astro + Tina +bash skills/website-creator/scripts/migrate-tina.sh /path/to/existing-site /path/to/migrated-site ``` -### [2] สำรวจสิ่งที่มี vs ขาด +**Script ทำอะไร:** +1. Detect source technology (Astro, Next.js, Remix, etc.) +2. Analyze source content +3. Copy Astro+Tina template +4. Migrate content (MD/MDX files) +5. Add PDPA consent system +6. Create Tina schema +7. Generate migration report -| หมวด | ต้องตรวจ | -|------|---------| -| Design | colors, fonts, tailwind config, globals.css, btn/card components | -| Pages | หน้าอะไรบ้างใน `(frontend)` | -| Collections | Payload collections มีอะไร — **ต้องมี `Posts` และ `Pages` collection สำหรับแก้ไขจาก `/admin`** | -| Content Migration | Posts/Pages จากเว็บเก่าถูกย้ายเข้า Payload หรือยัง | -| Auth | login/register pages | -| PDPA | CookieBanner, ConsentLogs | -| SEO | meta, sitemap, robots | +### Content Migration Checklist -**Content Migration (Posts & Pages → Payload CMS):** - -ถ้าเว็บเดิมมี posts หรือ pages อยู่แล้ว → ต้องสร้าง Payload collections (`Posts`, `Pages`) และย้ายข้อมูลเข้า Payload backend ทันที เพื่อให้: -- Login เข้า `/admin` แล้วเห็น posts/pages พร้อมแก้ไข -- ใช้ Payload rich-text editor แก้ไข content ได้โดยตรง -- ไม่ต้องแก้ไขโค้ดเมื่อเพิ่ม/ลบ post - -**วิธีตรวจสอบ content เดิม:** - -### [3] วางแผน Content Migration - -ค้นหา content เดิมที่ต้องย้าย: -```bash -# ดูว่ามีไฟล์ content อะไรอยู่แล้ว -find /path/to/repo/src -type f \( -name "*.md" -o -name "*.mdx" -o -name "*.json" \) | head -30 - -# ดู posts/pages ใน frontend directory -ls /path/to/repo/src/app/\(frontend\)/** -``` - -วางแผน: content อยู่ในรูปไฟล์ไหน (MD/MDX/JSON) มีกี่รายการ แต่ละรายการมี fields อะไรบ้าง - -### [4] สร้าง Payload Collections (Posts + Pages) - -ถ้ายังไม่มี `src/collections/Posts.ts` และ `Pages.ts` → สร้างตามนี้: - -1. **`src/collections/Posts.ts`**: -```ts -import type { CollectionConfig } from 'payload' - -const Posts: CollectionConfig = { - slug: 'posts', - admin: { - useAsTitle: 'title', - defaultColumns: ['title', 'slug', 'status', 'updatedAt'], - }, - access: { read: () => true }, - fields: [ - { name: 'title', type: 'text', required: true }, - { name: 'slug', type: 'text', required: true }, - { name: 'status', type: 'select', - options: [{ label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }], - defaultValue: 'draft' }, - { name: 'excerpt', type: 'text' }, - { name: 'content', type: 'richText' }, - { name: 'featuredImage', type: 'upload', relationTo: 'media' }, - { name: 'publishedAt', type: 'date' }, - { name: 'updatedAt', type: 'date', admin: { readOnly: true } }, - ], -} -export default Posts -``` - -2. **`src/collections/Pages.ts`**: -```ts -import type { CollectionConfig } from 'payload' - -const Pages: CollectionConfig = { - slug: 'pages', - admin: { - useAsTitle: 'title', - defaultColumns: ['title', 'slug', 'status'], - }, - access: { read: () => true }, - fields: [ - { name: 'title', type: 'text', required: true }, - { name: 'slug', type: 'text', required: true }, - { name: 'status', type: 'select', - options: [{ label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }], - defaultValue: 'draft' }, - { name: 'content', type: 'richText' }, - { name: 'updatedAt', type: 'date', admin: { readOnly: true } }, - ], -} -export default Pages -``` - -3. **เพิ่มใน `payload.config.ts`**: -```ts -import { Posts } from './collections/Posts' -import { Pages } from './collections/Pages' -// ... -collections: [Users, Media, Posts, Pages], -``` - -4. **Generate types + restart dev server** หลังเพิ่ม collection: -```bash -pnpm generate:types && pnpm generate:importmap -# restart dev server -``` - -### [5] ย้าย Content เข้า Payload - -**วิธีที่ 1 — เข้า `/admin` ด้วยตัวเอง (แนะนำ ถ้า content น้อย):** -- ล็อกอิน `/admin` -- ไปที่ Posts → กด New Post → กรอก content จากเว็บเดิม -- ไปที่ Pages → กด New Page → กรอก content จากเว็บเดิม - -**วิธีที่ 2 — Seed Script (ถ้า content เยอะ):** -เขียน script ใช้ Payload SDK ย้ายทีละหลายรายการ: - -```ts -// src/scripts/migrate-content.ts -import { getPayload } from 'payload' -import config from '../payload.config' - -const payload = await getPayload({ config: await config }) - -// ตัวอย่าง: ย้าย post เดียว -await payload.create({ - collection: 'posts', - data: { - title: 'ชื่อบทความ', - slug: 'slug-url-friendly', - status: 'published', - excerpt: 'คำอธิบายย่อ', - content: { root: { children: [...] } }, // Lexical JSON - publishedAt: new Date().toISOString(), - }, -}) -``` - -รัน seed script: -```bash -npx tsx src/scripts/migrate-content.ts -``` - -### [6] ตรวจสอบ /admin - -หลังย้ายเสร็จ → login `/admin` แล้วตรวจ: - -**Posts collection:** -- [ ] เห็น `Posts` ใน sidebar ซ้าย -- [ ] กดเปิด post → เห็น title, slug, content, excerpt ถูกต้อง -- [ ] แก้ไข post จาก Payload rich-text editor ได้ -- [ ] ลบ post จาก /admin → หายจาก frontend ทันที - -**Pages collection:** -- [ ] เห็น `Pages` ใน sidebar ซ้าย -- [ ] กดเป็น page → เห็น title, slug, content ถูกต้อง -- [ ] แก้ไข page จาก Payload editor ได้ -- [ ] ลบ page จาก /admin → หายจาก frontend ทันที - -**Frontend sync:** (ดู **Design + Payload Integration** ด้านบน สำหรับรายละเอียด) -- [ ] หน้า blog listing แสดง posts จาก Payload ถูกต้อง -- [ ] หน้า static pages แสดง content จาก Payload ถูกต้อง -- [ ] `` component แสดง Lexical JSON เป็น HTML ถูกต้อง -- [ ] Design tokens (Tailwind prose) apply กับ richText output ถูกต้อง -- [ ] Animation classes apply ที่ wrapper elements ถูกต้อง - -**ถ้าผ่านทุกข้อ → Content Migration สำเร็จ!** ต่อด้วย Workflow A Step 3 (พัฒนา Components + Pages) - -**รอ user approve ก่อนดำเนินต่อ** +| จาก Payload | ไป Tina | +|------------|---------| +| `payload.find()` | Astro content collections | +| `RichText` component | MDX files | +| `payload.config.ts` collections | `.tina/schema.ts` collections | +| MongoDB/PostgreSQL | Astro DB (built-in) | +| `/admin` | `/admin` (Tina visual editor) | +| Payload Auth | Tina Auth (optional) | --- -## Lexical RichText Rendering in Frontend +## Astro Content Collections -Payload เก็บ richText เป็น **Lexical JSON** — ต้อง serialize เป็น HTML ก่อนแสดงใน Next.js +Astro ใช้ content collections สำหรับ type-safe content management: -### วิธีที่ 1: serializeLexical (แนะนำ) +### สร้าง Collection -```tsx -// src/lib/payload-helpers.ts -import { serializeLexical } from '@payloadcms/richtext-lexical' -import { getPayload } from 'payload' -import config from '@/payload.config' +```typescript +// src/content/config.ts +import { defineCollection, z } from 'astro:content'; -export async function getPost(slug: string) { - const payload = await getPayload({ config }) - const { docs } = await payload.find({ - collection: 'posts', - where: { slug: { equals: slug } }, - depth: 2, - }) - return docs[0] ?? null +const postsCollection = defineCollection({ + type: 'content', + schema: z.object({ + title: z.string(), + description: z.string().optional(), + publishedAt: z.date().optional(), + author: z.string().optional(), + image: z.string().optional(), + }), +}); + +const pagesCollection = defineCollection({ + type: 'content', + schema: z.object({ + title: z.string(), + description: z.string().optional(), + }), +}); + +export const collections = { + posts: postsCollection, + pages: pagesCollection, +}; +``` + +### ใช้ Collection + +```astro +--- +// src/pages/blog/[slug].astro +import { getCollection } from 'astro:content'; + +export async function getStaticPaths() { + const posts = await getCollection('posts'); + return posts.map((post) => ({ + params: { slug: post.slug }, + props: { post }, + })); } -export async function getPage(slug: string) { - const payload = await getPayload({ config }) - const { docs } = await payload.find({ - collection: 'pages', - where: { slug: { equals: slug } }, - depth: 1, - }) - return docs[0] ?? null -} -``` +const { post } = Astro.props; +const { Content } = await post.render(); +--- -```tsx -// src/app/(frontend)/posts/[slug]/page.tsx -import { getPost } from '@/lib/payload-helpers' -import { RichText } from '@payloadcms/richtext-lexical' - -export default async function PostPage({ params }: { params: { slug: string } }) { - const post = await getPost(params.slug) - if (!post) return
Not found
- - return ( -
-

{post.title}

- {post.featuredImage && ( - {post.featuredImage.alt} - )} - {post.content && ( - - )} -
- ) -} -``` - -### วิธีที่ 2: serializeLexical (custom HTML) - -ถ้าต้องการ control HTML output มากกว่า: - -```tsx -import { serializeLexical } from '@payloadcms/richtext-lexical' - -const html = serializeLexical({ data: post.content }) - -return ( -
-) -``` - -### RichText CSS (Tailwind + Prose) - -```bash -pnpm add @tailwindcss/typography -``` - -```ts -// tailwind.config.ts -plugins: [require('@tailwindcss/typography')], -``` - -```tsx -// ใช้ className="prose" กับ RichText หรือ dangerouslySetInnerHTML - -``` - -### Payload Config: เปิดใช้ Lexical Editor - -ตรวจสอบว่า `payload.config.ts` มี `lexicalEditor`: - -```ts -import { lexicalEditor } from '@payloadcms/richtext-lexical' - -export default buildConfig({ - editor: lexicalEditor(), - // ... -}) -``` - -### กรณีมี Block Types ใน richText - -ถ้า collection ใช้ `blocks` field (ไม่ใช่ `richText`): - -```tsx -// Blocks ต้อง render ด้วย Payload Block renderer -import { BlockRenderer } from '@payloadcms/richtext-lexical' - - +
+

{post.data.title}

+ +
``` --- -## SEO Analyzer Plugin Integration +## Tina CMS Integration -ติดตั้ง `@consilioweb/seo-analyzer` เพื่อให้ editor เห็น SEO score ใน `/admin` - -### ติดตั้ง +### Local Development ```bash -cd /path/to/project -pnpm add @consilioweb/seo-analyzer +npm run dev ``` -### เพิ่มใน payload.config.ts +Tina จะ available ที่ `http://localhost:4321/admin` -```ts -import { seoAnalyzerPlugin } from '@consilioweb/seo-analyzer' - -export default buildConfig({ - plugins: [ - seoAnalyzerPlugin({ - collections: ['pages', 'posts'], - locale: 'en', // 'fr' (default) | 'en' - siteName: 'My Website', - }), - ], -}) -``` - -### Features ที่ได้ทันที - -| สิ่งที่ได้ | รายละเอียด | -|-----------|-------------| -| **SEO Dashboard** | `/admin/seo` — score table ทุกหน้า | -| **Sitemap Audit** | `/admin/sitemap-audit` — orphan pages, broken links | -| **Redirect Manager** | `/admin/redirects` — 301/302 redirects | -| **SeoAnalyzer sidebar** | ใน editor ของทุก collection ที่ตั้งไว้ | -| **Meta fields** | `meta.title`, `meta.description`, `meta.image` auto-created | -| **Auto-redirect** | เปลี่ยน slug แล้วสร้าง redirect ให้อัตโนมัติ | -| **50+ SEO checks** | title, description, headings, content, images, links, readability | - -### ถ้ามี `@payloadcms/plugin-seo` อยู่แล้ว - -`seoAnalyzerPlugin` จะ skip auto-creation ของ meta fields โดยอัตโนมัติ — ทั้งสอง plugin อยู่ร่วมกันได้ - ---- - -## Content Seeding จาก Hardcode - -เมื่อ frontend พร้อมแสดง content แล้ว ต้องย้าย hardcode content จาก pages เข้า Payload - -### วิธีที่ 1: Seed via Payload Local API - -```ts -// src/scripts/seed-content.ts -import { getPayload } from 'payload' -import config from '../payload.config' - -const payload = await getPayload({ config }) - -// Seed pages -const pages = [ - { title: 'Home', slug: 'home', content: { root: { children: [] } }, status: 'published' }, - { title: 'About Us', slug: 'about', content: { root: { children: [] } }, status: 'published' }, -] - -for (const page of pages) { - await payload.create({ - collection: 'pages', - data: page, - }) -} - -// Seed posts -const posts = [ - { title: 'Getting Started', slug: 'getting-started', content: { root: { children: [] } }, status: 'published' }, -] - -for (const post of posts) { - await payload.create({ - collection: 'posts', - data: post, - }) -} - -console.log('Seeding complete!') -``` +### Production Setup ```bash -npx tsx src/scripts/seed-content.ts +# รัน script สำหรับติดตั้ง Tina backend +bash scripts/install-tina-backend.sh ``` -### วิธีที่ 2: Hardcode Content → Payload ผ่าน Migration Script +**ต้องมี:** +- `TINA_TOKEN` — Production authentication token +- `TINA_CLIENT_ID` — Tina client ID -ถ้ามี content ใน React components อยู่แล้ว: +### Tina Schema Best Practices -```ts -// src/scripts/migrate-from-hardcode.ts -// ดึง content จาก hardcode objects แล้วสร้าง Payload documents -const hardcodedContent = [ - { title: 'Home', slug: 'home', body: '

Welcome to our site

' }, -] - -for (const item of hardcodedContent) { - // แปลง HTML string → Lexical JSON - const lexicalContent = htmlToLexical(item.body) - - await payload.create({ - collection: 'pages', - data: { - title: item.title, - slug: item.slug, - content: lexicalContent, - status: 'published', +```typescript +// .tina/schema.ts +const schema = defineSchema({ + collections: [ + { + name: 'post', + label: 'Blog Posts', + path: 'src/content/posts', + fields: [ + { + type: 'string', + name: 'title', + label: 'Title', + isTitle: true, // ใช้เป็น title ใน admin UI + required: true, + }, + { + type: 'string', + name: 'slug', + label: 'URL Slug', + required: true, + }, + { + type: 'datetime', + name: 'publishedAt', + label: 'Publish Date', + }, + { + type: 'rich-text', + name: 'body', + label: 'Content', + isBody: true, // เป็น body หลัก + }, + ], }, - }) -} + ], +}) ``` --- ## Templates -### Next.js Payload Starter (`templates/nextjs-payload-starter/`) +### Astro Tina Starter (`templates/astro-tina-starter/`) -Base template ที่รวมทุกอย่างพร้อม ใช้สร้างเว็บไซต์ใหม่ได้เลย (PostgreSQL): +Base template ที่รวมทุกอย่างพร้อม: ``` -src/ -├── app/ -│ ├── (payload)/ # Payload admin + API -│ │ ├── admin/[[...segments]]/ -│ │ ├── api/[[...slug]]/ -│ │ ├── api/graphql/ -│ │ ├── api/graphql-playground/ -│ │ ├── custom.scss -│ │ └── layout.tsx -│ └── (frontend)/ # Frontend pages -│ ├── layout.tsx -│ ├── page.tsx -│ ├── globals.css -│ └── posts/[[...slug]]/ -├── collections/ -│ ├── Users.ts # User auth -│ ├── Media.ts # Media uploads -│ └── Posts.ts # Blog posts -├── payload.config.ts -└── index.ts +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 ``` -**Collections ที่มี:** -- `Users` - auth, email-based -- `Media` - image uploads -- `Posts` - title, slug, richText content, featuredImage, status +**Features ที่มี:** +- Astro 6.1.7 + Tina CMS 2.x +- Tailwind CSS 4.x with `@tailwindcss/vite` +- Astro DB for consent logging +- Nano Stores for client state +- Thai language support +- Docker-ready -### portal-mini-store-template (MongoDB) +### PDPA Consent Template (`templates/consent/`) -Template สำหรับ mini store ที่มี cart, checkout, orders ในตัว (MongoDB): +Template สำหรับ PDPA consent system: ``` -git clone https://github.com/dyad-sh/portal-mini-store-template.git -``` - -**Collections ที่มี:** `Users`, `Media`, `Snacks`, `Orders` -**Adapter:** `@payloadcms/db-mongodb` (MongoDB) -**Components ที่มี:** CartSidebar, SiteHeader, CartButton, AddToCartButton - -**เพิ่ม PDPA consent logging:** -```bash -# 1. สร้าง ConsentLogs collection → export default ไม่ใช่ named export! -cp ConsentLogs.ts src/collections/ - -# 2. สร้าง API endpoint -mkdir -p src/app/api/consent - -# 3. เพิ่ม collection ใน payload.config.ts - -### portal-mini-store-template (MongoDB) - -**เปิดเข้าไปใน project:** -cd portal-mini-store-template - -**⚠️ Pitfalls สำคัญมาก:** - -1. **ต้องใช้ `mongooseAdapter` ไม่ใช่ `mongodbAdapter`** - ```ts - // ✅ ถูกต้อง - import { mongooseAdapter } from '@payloadcms/db-mongodb' - db: mongooseAdapter({ url: process.env.MONGODB_URL }) - - // ❌ ผิด — ไม่มี export ชื่อนี้ - import { mongodbAdapter } from '@payloadcms/db-mongodb' - ``` - -2. **ConsentLogs ต้องใช้ `export default`** - ```ts - // ✅ ถูกต้อง — default export - export default buildConfig({ collections: [ConsentLogs] }) - - // ❌ ผิด — named export - export const ConsentLogs = { ... } - ``` - -3. **Template มีปัญหา:** docker-compose ใช้ MongoDB แต่ payload.config.ts เดิมใช้ Vercel Postgres - → ต้องแก้ payload.config.ts ให้ใช้ `mongooseAdapter` และเปลี่ยน package.json - -4. **Dev mode cross-origin warning:** เมื่อเข้าผ่าน IP จะมี warning `allowedDevOrigins` - → ไม่ต้องแก้ มันคือ dev warning ไม่ใช่ error - -**docker-compose สำหรับ MongoDB:** -```bash -cd portal-mini-store-template -docker compose up -d -``` - -**เข้าถึงจาก external IP (เช่น 110.164.146.185):** - -1. เพิ่ม environment ใน docker-compose.yml: -```yaml -payload: - environment: - - NEXT_PUBLIC_SERVER_URL=http://110.164.146.185:3000 - - MONGODB_URL=mongodb://mongo:27017/portal-mini-store -``` - -2. เพิ่ม allowedDevOrigins ใน next.config.ts: -```ts -const nextConfig: NextConfig = { - allowedDevOrigins: ['110.164.146.185', '110.164.146.185:3000'], - // ... -} -``` - -**วิธีสร้าง admin user ผ่าน MongoDB:** - -Users ที่ register ผ่านเว็บจะมี role เป็น 'user' ไม่สามารถเข้า admin ได้ ต้องเปลี่ยน role ผ่าน MongoDB: - -```bash -# เข้า MongoDB container -docker exec -it mongosh - -# เปลี่ยน role เป็น admin -use("portal-mini-store"); -db.users.updateOne( - { email: "your-email@example.com" }, - { $set: { role: "admin" } } -); - -# clear sessions (force re-login) -db.users.updateOne( - { email: "your-email@example.com" }, - { $set: { sessions: [] } } -); +consent/ +├── ConsentBanner.astro # Consent banner component +├── api/ +│ ├── consent.ts # API endpoints +│ └── route.ts # Astro API route +├── db/ +│ └── config.ts # Astro DB ConsentLog table +└── stores/ + └── consent.ts # Nano Stores ``` --- @@ -1416,10 +982,6 @@ db.users.updateOne( docker compose up -d ``` -Services: -- `payload` — Next.js app (port 3000) -- `postgres` — PostgreSQL (port 5432) - ### Production (Multi-stage Dockerfile) ```dockerfile @@ -1436,16 +998,16 @@ RUN pnpm build FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV production -RUN adduser --system --uid 1001 nextjs -RUN mkdir .next && chown nextjs:nodejs .next -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -USER nextjs -EXPOSE 3000 -CMD ["node", "server.js"] +RUN adduser --system --uid 1001 astro +USER astro +EXPOSE 8080 +CMD ["node", "dist/server/entry.mjs"] ``` -**สำคัญ:** ต้องมี `output: 'standalone'` ใน next.config.ts +**Build command:** +```bash +npm run build +``` --- @@ -1463,762 +1025,116 @@ CMD ["node", "server.js"] | Develop Components + Pages | `api-and-interface-design` | API design, component interfaces | | Before QA | `code-review-and-quality` | Full multi-axis code review | | Before QA | `performance-optimization` | Performance audit + fixes | -| QA Testing | `browser-testing-with-devtools` | Real browser testing (รวมเข้ากับ dogfood) | +| QA Testing | `browser-testing-with-devtools` | Real browser testing | | QA Testing | `dogfood` | Exploratory QA of web app | | SEO Planning | `seo-context` | Per-project SEO context | | SEO Audit | `seo-analyzers` | Thai language SEO analysis | | GEO (AI Search) | `seo-geo` | AI Overviews, llms.txt, crawler | | Multi-channel Content | `seo-multi-channel` | Facebook, LinkedIn, blog content | | Deploy | `easypanel-deploy` | Easypanel hosting | -| Payload CMS | `payload` | Collections, fields, hooks, access control, queries, plugins, Lexical editor | -| SEO Plugin | `@consilioweb/seo-analyzer` | 50+ SEO checks, admin dashboard, redirect manager, sitemap audit | -| Design + Payload Integration | `payload-lexical-integration` | แยก design layer กับ content layer, integrate RichText กับ design components | - ---- - -### Issue 1: Frontend routes 404 but / works - -**อาการ:** `/about-us`, `/contact-us`, `/faq`, `/portfolio` เป็น 404 แต่ `/` ทำงานปกติ - -**สาเหตุ:** ไม่มี root `src/app/layout.tsx` — ทำให้ `(payload)` route group ที่มี catch-all `[[...slug]]` route ขัดกับ `(frontend)` group สำหรับ nested routes - -**ทางแก้:** สร้าง root layout: -```tsx -// src/app/layout.tsx -export default function RootLayout({ children }: { children: React.ReactNode }) { - return children -} -``` - -### Issue 2: sitemap.xml and robots.txt routes must export GET - -**รูปแบบผิด:** -```ts -// ❌ ไม่ทำงาน -export default function sitemap(): MetadataRoute.Sitemap { ... } -``` - -**รูปแบบถูกต้อง:** -```ts -// ✅ -export async function GET(): Promise { ... } -``` - -ทั้ง `sitemap.xml/route.ts` และ `robots.txt/route.ts` ต้องใช้ named export `GET` - -### Issue 4: MongoDB seed script — mongodb package path - -**ปัญหา:** `require('mongodb')` ใน `.cjs` file จะหาไม่เจอถ้ารันจาก `/tmp` แทน project root - -**ทางแก้:** รัน seed script จาก project root ที่มี `node_modules/mongodb`: -```bash -node seed-mongo.cjs # ไม่ใช่ node /tmp/seed-mongo.cjs -``` +| Design + Tina Integration | `frontend-ui-engineering` | แยก design layer กับ content layer | --- ## Troubleshooting -### Build fails: Could not find a declaration file for module 'nodemailer' +### Build fails: "Cannot find module '@tailwindcss/vite'" -**Error:** `Could not find a declaration file for module 'nodemailer'` - -**ทางแก้:** สร้าง declaration file: - -```bash -mkdir -p src/types -echo "declare module 'nodemailer'" > src/types/nodemailer.d.ts -``` - -### Dev server serves wrong content (old template) - -**อาการ:** Dev server แสดง content ของ template เก่า แม้ว่าโค้ดปัจจุบันถูกต้องแล้ว - -**สาเหตุ:** มี Next.js dev server หลายตัวทำงานพร้าว (port conflict) +**Error:** `Cannot find module '@tailwindcss/vite'` **ทางแก้:** + ```bash -# Kill ทุก dev server ก่อน -pkill -f "next dev" +# ติดตั้ง @tailwindcss/vite +npm install -D @tailwindcss/vite -# รอให้ port ว่าง -sleep 2 - -# รันใหม่ -pnpm dev +# หรือถ้าใช้ Tailwind v3 syntax อยู่ → ต้องเปลี่ยนเป็น v4 +# ใน global.css: +@import "tailwindcss"; # ไม่ใช่ @tailwind base; @tailwind components; @tailwind utilities; ``` -ตรวจสอบว่าไม่มี server เก่าทำงานอยู่: -```bash -ps aux | grep "next dev" | grep -v grep -``` +### Astro DB not working -### Terminal cwd resets to hermes-agent directory (Next.js dev server starts in wrong place) - -**อาการ:** `bun run dev` รันจาก working directory ของ hermes-agent แทน project directory ทำให้ server ทำงานผิดที่ หรือ 404 ทุก route - -**สาเหตุ:** Hermes agent terminal มี cwd ที่อยู่ไม่ตรงกับ project ที่ต้องการ — `pwd` เป็น `/home/kunthawat/.hermes/hermes-agent` - -**วิธีแก้ — ต้องระบุ workdir ทุกครั้ง:** -```bash -# วิธีที่ 1: ใช้ script file -cat > /path/to/project/start-dev.sh << 'EOF' -#!/bin/bash -cd /home/kunthawat/moreminimore-next -kill $(lsof -t -i:3000) 2>/dev/null || true -sleep 2 -nohup bun run dev --port 3000 > /tmp/next-dev.log 2>&1 & -echo "Server PID: $!" -EOF -bash /path/to/project/start-dev.sh - -# วิธีที่ 2: ใช้ workdir parameter ทุกครั้ง -terminal(command="bun run dev --port 3000", workdir="/home/kunthawat/moreminimore-next") - -# ตรวจสอบ port ว่างก่อน start -ss -tlnp | grep 3000 -# ถ้ามี process เก่า: -kill $(ss -tlnp | grep 3000 | grep -o 'pid=[0-9]*' | cut -d= -f2) -``` - -### Turbopack rebuild causes 500 on first request after restart - -**อาการ:** หลัง restart dev server ทุก route คืน 500 แต่หลังจากนั้นทำงานปกติ - -**สาเหตุ:** Turbopack ต้อง rebuild ทุก chunk ใหม่หลัง restart ทำให้ request แรก fail +**Error:** `Cannot find module 'astro:db'` หรือ tables not found **ทางแก้:** + ```bash -# 1. Restart แล้วรอ warmup ก่อน test -bash start-dev.sh && sleep 20 && curl -sI http://localhost:3000/ | head -3 +# Push schema to database +npm run db:push -# 2. ถ้ายัง 500 หลัง warmup → restart อีกครั้ง (Turbopack ต้อง 2-3 cycles) -kill $(ss -tlnp | grep 3000 | grep -o 'pid=[0-9]*' | cut -d= -f2) -sleep 3 -bash start-dev.sh && sleep 20 - -# 3. ถ้ายังมีปัญหา → ลบ .next cache -kill $(ss -tlnp | grep 3000 | grep -o 'pid=[0-9]*' | cut -d= -f2) -rm -rf /path/to/project/.next -bash start-dev.sh && sleep 25 +# ตรวจสอบว่ามี astro:db import ถูกต้อง +import { db, ConsentLog } from 'astro:db'; ``` -### Hero images in src/app/(frontend)/assets/ return 404 +### Tina Admin not loading -**อาการ:** ไฟล์ภาพอยู่ที่ `src/app/(frontend)/assets/heroes/` แต่ `next/image` src `/assets/heroes/xxx.png` คืน 404 - -**สาเหตุ:** ไฟล์ใน `src/app/` directory ไม่ได้ถูก serve เป็น static files — เฉพาะ `public/` เท่านั้นที่ Next.js serve เป็น static +**อาการ:** `/admin` แสดง blank page หรือ error **ทางแก้:** -```bash -# ย้ายจาก src/app ไป public/ -mv src/app/\(frontend\)/assets/heroes/*.png public/assets/heroes/ -# แก้ next.config.ts ให้ allow localPatterns -# localPatterns: [{ pathname: '/api/media/file/**' }, { pathname: '/assets/heroes/**' }] + +1. ตรวจสอบว่า Tina config ถูกต้อง: +```javascript +// astro.config.mjs +import tina from 'tinacms'; + +export default defineConfig({ + integrations: [tina()], +}); ``` -### next/image "does not match images.localPatterns" error +2. ตรวจสอบว่า `.tina/schema.ts` มีอยู่ -**อาการ:** `next/image` src `/assets/heroes/xxx.png` คืน 500 พร้อม error "does not match `images.localPatterns` configured in your `next.config.js`" +3. ลองรัน `npm run dev` ใหม่ -**ทางแก้:** เพิ่ม pattern ใน `next.config.ts`: -```ts -images: { - localPatterns: [ - { pathname: '/api/media/file/**' }, - { pathname: '/assets/heroes/**' }, - ], -}, -``` +### Port already in use -### Payload CMS first request very slow (7-35s) causing sitemap timeout +**Error:** `Port 4321 is already in use` -**อาการ:** `/sitemap.xml` timeout หรือ 500 เพราะ Payload ต้อง warm up - -**ทางแก้:** Sitemap route ต้องมี timeout + fallback: -```ts -const controller = new AbortController() -const timeout = setTimeout(() => controller.abort(), 8000) -try { - const payload = await getPayload({ config }) - // fetch pages/posts -} catch (e) { - console.warn('Sitemap: Payload unavailable, using static fallback') -} finally { - clearTimeout(timeout) -} -// ถ้า Payload fail ทั้งหมด → return static sitemap -``` - -### Syntax error: extra closing parenthesis in Payload queries - -**Error:** `Expected ',', got '}'` หรือ `Expected a semicolon` - -**สาเหตุ:** มักเกิดจาก `.find({ ... } }))` แทนที่จะเป็น `.find({ ... }))` - -**วิธีแก้:** ตรวจสอบว่า `payload.find()`, `payload.create()`, `payload.update()` calls มี closing brackets ถูกต้อง: - -```ts -// ✅ ถูกต้อง -const { docs } = await payload.find({ - collection: 'posts', - where: { slug: { equals: slug } }, -}) - -// ❌ ผิด — extra ) -const { docs } = await payload.find({ collection: 'posts', where: { slug: { equals: slug } } })) -``` - -### Payload admin /admin shows "initializing" forever - -- ตรวจสอบว่า `payload-types.ts` ถูก generate แล้ว -- ตรวจสอบ `importMap` ว่าถูก generate แล้ว: `pnpm generate:importmap` -- ตรวจสอบ DATABASE_URL ว่าถูกต้อง -- ดู logs: `tail /tmp/next-dev.log` หรือ terminal output - -### Build fails: GRAPHQL_GET doesn't exist - -**Error:** `Export GRAPHQL_GET doesn't exist in target module` - -**ทางแก้:** ใช้ `GRAPHQL_PLAYGROUND_GET` แทน - -```ts -// ✅ ถูกต้อง -import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' -// ❌ ผิด — ไม่มี export นี้ -import { GRAPHQL_GET } from '@payloadcms/next/routes' -``` - -### Build fails: relation does not exist / table does not exist - -**Error:** `error: relation "posts" does not exist` หรือ `42P01` - -**สาเหตุ:** Next.js พยายาม pre-render pages ที่เรียก Payload ตอน build แต่ tables ยังไม่มี - -**ทางแก้ - ทั้ง 2 อย่าง:** - -```tsx -// 1. บอก Next.js ว่าหน้านี้ dynamic ไม่ต้อง pre-render -export const dynamic = 'force-dynamic' - -// 2. wrap Payload calls ใน try/catch -export default async function HomePage() { - let posts: any[] = [] - try { - const payload = await getPayload({ config }) - const { docs } = await payload.find({ collection: 'posts', limit: 10 }) - posts = docs - } catch (e) { - console.warn('Could not fetch posts:', e) - } - // ... render -} -``` - -### Dev mode ช้ามาก (7-35 วินาที) แม้ warm up แล้ว - -**สาเหตุ:** Payload ต้อง pull schema from database ทุก request ใน dev mode - -**ทางแก้:** ใช้ **production standalone mode** แทน +**ทางแก้:** ```bash -# Build -pnpm build +# หา process ที่ใช้ port +lsof -i :4321 -# Run production (เร็วมาก ~400ms vs 7-35s) -node .next/standalone/server.js +# kill process +kill + +# หรือรัน port อื่น +npm run dev -- --port 4322 ``` -**ต้องมี `output: 'standalone'` ใน next.config.ts** +### MDX content not rendering -### PostgreSQL connection fails / wrong credentials +**อาการ:** MDX files ไม่แสดง content -ถ้าใช้ Docker: ดู credentials จริงจาก container: - -```bash -docker exec env | grep POSTGRES -``` - -**ตัวอย่าง credentials ที่พบ:** -- user: `payload` (ไม่ใช่ `postgres`) -- password: ดูจาก `POSTGRES_PASSWORD` env -- database name: ต้องตรงกับ `POSTGRES_DB` — เช่น `payload` -- port: Docker compose อาจ map ไป port อื่น (เช่น 5555 แทน 5432) - -**หา port ที่ PostgreSQL รันจริง:** -```bash -docker ps --format '{{.Names}} -> {{.Ports}}' # ดู port mappings -ss -tlnp | grep 5432 # ดูว่า port 5432 ถูกใช้หรือเปล่า -``` - -**Database name ต้องตรงกับ `POSTGRES_DB`:** -```bash -# ถ้า docker-compose ตั้ง POSTGRES_DB=payload -# DATABASE_URL ต้องเป็น .../payload ไม่ใช่ .../postgres -DATABASE_URL=postgresql://payload:payloadpass@localhost:5555/payload -``` - -### Tables ไม่ถูกสร้าง / migrate ทำงานแล้วแต่ tables ไม่มี - -**สาเหตุ:** Payload ไม่ได้สร้าง tables อัตโนมัติเมื่อรัน `migrate` command — ต้องให้ Payload runtime สร้าง tables - -**ทางแก้ - วิธีที่แน่นอน:** - -```bash -# 1. ลบ .next cache -rm -rf .next - -# 2. รัน dev server (Payload จะสร้าง tables เองเมื่อมี request) -pnpm dev - -# 3. เปิด browser ไปที่ http://localhost:3000/admin -# รอให้ schema pull เสร็จ (10-30 วินาที) - -# 4. ตรวจสอบว่า tables ถูกสร้างแล้ว -docker exec psql -U -d -c "\dt" -``` - -**หรือใช้ `migrate:fresh` ก่อน dev แต่ต้องมี migration files:** -```bash -mkdir -p src/migrations -pnpm payload migrate:fresh --yes -# ถ้ามี interactive prompt ให้พิมพ์ 'y' -``` - -### Migration interactive prompt causing crash - -- รัน `pnpm payload migrate --yes` ก่อน `pnpm dev` -- ถ้า crash แล้ว refresh browser — มันจะ warm up ใหม่อีกครั้ง -- ถ้า `migrate:fresh` ค้าง → ลองแค่ `pnpm dev` แล้วไป `/admin` ใน browser แทน - -### First request extremely slow (7-30s) then fast - -- **ปกติ** — Payload ต้อง pull schema จาก DB ก่อน request แรกทุกครั้ง -- รอ warm up เสร็จก่อนทดสอบต่อ -- ดู logs ว่า server พร้อมหรือยัง: `tail /tmp/next-dev.log` +**ทางแก้:** +```astro +--- +// ต้องใช้ .render() เพื่อดึง Content component +const { Content } = await post.render(); --- -### Common Pitfalls Discovered from moreminimore-redesign Project - -**1. Tailwind not pre-installed** -- Template ที่มี `globals.css` มากับ `@tailwind` directives อาจไม่มี Tailwind ติดตั้งใน package.json -- ต้อง `npm install -D tailwindcss@3 postcss autoprefixer --legacy-peer-deps` เสมอ -- หลังติดตั้ง ต้องลบ `.next` cache และ restart dev server - -**2. postcss.config — ESM vs CommonJS** -- Project เป็น ESM (`"type": "module"` ใน package.json) -- ถ้าสร้าง `postcss.config.js` ด้วย `module.exports` → Error: `module is not defined in ES module scope` -- ต้องใช้ `postcss.config.cjs` เท่านั้น หรือใช้ ESM syntax (`export default`) - -**3. Port conflict with root Next.js processes** -- `kill` ธรรมดาลบ root process ไม่ได้ -- ต้องใช้: `fuser -k /tcp` -- Dev server อาจติด port แม้ process ดูตายแล้ว — ตรวจสอบด้วย `fuser /tcp` - -**4. Content seeding via Payload CLI ช้ามาก** -- ถ้าต้อง seed content หลาย items ใช้ MongoDB CLI โดยตรงแทน Payload SDK -```bash -docker cp script.js :/tmp/script.js -docker exec mongosh /tmp/script.js + ``` -**5. ตรวจสอบ dev server ก่อน assume ว่ามันทำงาน** -- ทุกครั้งที่ restart: `curl http://localhost:/ | grep tailwind-classes` -- ถ้า CSS ไม่ทำงานจะเห็น `class=\"...\"` ที่ไม่มี styling ตามมา +### Static build vs SSR ---- +**อาการ:** API routes ไม่ทำงานหลัง build -## Troubleshooting & Common Issues +**ทางแก้:** -### 1. pnpm "specs is not iterable" error +ถ้าต้องการใช้ API routes (เช่น consent API): -**ปัญหา:** pnpm 10.x มี bug กับ some package configurations -**แก้:** ใช้ npm แทน - -```bash -npm install --legacy-peer-deps +```javascript +// astro.config.mjs +export default defineConfig({ + output: 'hybrid', // หรือ 'server' สำหรับทุกหน้า dynamic +}); ``` -### 2. Payload named vs default import error - -**ปัญหา:** `Module has no exported member 'Portfolio'. Did you mean to use 'import Portfolio from'` - -**สาเหตุ:** Payload collections ต้องใช้ `export default` ไม่ใช่ `export const` - -```ts -// ✅ ถูก - collection file ใช้ default export -const Portfolio: CollectionConfig = { ... } -export default Portfolio - -// ❌ ผิด - named export จะมี error ตอน build -export const Portfolio = { ... } -``` - -### 3. MongoDB connection refused (Docker) - -**ปัญหา:** `ECONNREFUSED 127.0.0.1:27017` ใน container - -**แก้:** ต้องใช้ container hostname ไม่ใช่ localhost - -```bash -# หา MongoDB container name และ network -docker ps --format '{{.Names}} {{.Ports}}' -docker inspect --format '{{json .NetworkSettings.Networks}}' - -# หา IP ของ MongoDB container -docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' -``` - -**docker-compose.yml — ต่อไปยัง MongoDB container ที่มีอยู่แล้ว:** - -```yaml -services: - app: - build: . - ports: - - "3002:3000" - environment: - - MONGODB_URL=mongodb://:27017/ - networks: - - - -networks: - : - external: true -``` - -### 4. Port already allocated - -**ปัญหา:** `Bind for 0.0.0.0:3001 failed: port is already allocated` - -**แก้:** ใช้ port อื่น - -```bash -# ดูว่า port ไหนว่าง -ss -tlnp | grep - -# ใน docker-compose.yml -ports: - - "3002:3000" # host:container -``` - -### 5. Docker build "public not found" - -**ปัญหา:** `"/app/public": not found` ใน multi-stage Dockerfile - -**แก้:** ลบบรรทัดนั้นออก — Next.js standalone ไม่ต้องการ public/ - -```dockerfile -# ลบบรรทัดนี้ -# COPY --from=builder --chown=nextjs:nodejs /app/public ./public -``` - -### 6. Payload admin แสดง "initializing" ตลอด - -**สาเหตุ:** ไม่ได้ generate types ก่อน - -```bash -npm run generate:types -npm run generate:importmap -npm run dev -``` - -### 7. TypeScript Build Errors (Payload 3.x) - -#### `relationTo: 'snacks'` — Type not assignable - -```ts -// ❌ ผิด -relationTo: 'snacks', - -// ✅ ถูกต้อง -relationTo: 'snacks' as any, -``` - -#### `editor: { lexical: {} }` — Property does not exist - -```ts -// ❌ ผิด -editor: { lexical: {} }, - -// ✅ ถูกต้อง -editor: {} as any, -``` - -#### Access functions — FieldAccess type mismatch - -```ts -// ❌ ผิด -admin: ({ req: { user } }) => user?.role === 'admin', - -// ✅ ถูกต้อง -admin: (({ req: { user } }) => user?.role === 'admin') as any, -``` - -#### `changeFrequency` — string not assignable to union type - -```ts -// ❌ ผิด -changeFrequency: 'weekly', - -// ✅ ถูกต้อง -changeFrequency: 'weekly' as const, -``` - -#### `payload.auth()` — signature changed in Payload 3.x - -```ts -// ❌ ผิด — Payload 3.x ไม่รองรับ collection ที่นี่ -const { user } = await payload.auth({ headers }) - -// ✅ ถูกต้อง — สร้าง request object เอง -import { createLocalReq } from '@/collections/Users' -const { user } = await createLocalReq({ userId, role: 'admin' }) -``` - -#### `adminsOrOwner` doesn't exist → use `adminsOrSelf` - -```ts -// ❌ ผิด -access: adminsOrOwner, - -// ✅ ถูกต้อง -access: adminsOrSelf, -``` - -#### Where clause in access function - -```ts -// ❌ ผิด — return type ไม่ตรง -update: ({ req: { user } }) => { - if (user?.role === 'admin') return true - return { user: { equals: user.id } } -}, - -// ✅ ถูกต้อง -update: (({ req: { user } }) => { - if (user?.role === 'admin') return true - return { user: { equals: user.id } } -}) as any, -``` - -### 8. `"use client"` + `generateMetadata` in same file → 500 error - -**Error:** Page returns HTTP 500 or Next.js compile error. - -**Root Cause:** Next.js App Router ไม่อนุญาตให้ export `generateMetadata` หรือ `generateStaticParams` จาก component ที่มี `"use client"` directive — metadata functions ต้องอยู่ใน **server component** เท่านั้น - -**Fix:** แยก client-interactive parts (form, stateful UI) ออกเป็นไฟล์ component ต่างหาก: - -```tsx -// ❌ ผิด — page.tsx มีทั้ง "use client" และ generateMetadata -"use client"; -export async function generateMetadata() { ... } -export default function Page() { ... } - -// ✅ ถูกต้อง — page.tsx เป็น server component, แยก form ออกไป -// src/components/ContactForm.tsx -"use client"; -export default function ContactForm() { ... } - -// src/app/contact/page.tsx -export async function generateMetadata() { ... } -import ContactForm from "@/components/ContactForm"; -export default function Page() { return ; } -``` - -### 9. Payload 3.x — First-Time Admin User Setup - -**ปัญหา:** Payload 3.x ไม่มีคำสั่ง `create-user` CLI เหมือน Payload 2.x - -**ทางแก้ — สร้าง admin user ผ่าน Browser:** - -1. เปิด browser ไปที่ `http://localhost:3002/admin` -2. หน้าแรกจะแสดงฟอร์ม "Create your first user" — Email, New Password, Confirm Password -3. กรอกข้อมูลแล้วกด Create -4. หลังจากนั้น admin UI จะเข้าสู่ Dashboard - -### 10. Payload 3.x — API Authentication Flow - -Login → รับ JWT token → ใช้ token ใน `Authorization: Bearer *** header` - -```bash -# Login -curl -s -X POST http://localhost:3002/api/users/login \ - -H 'Content-Type: application/json' \ - -d '{"email":"admin@example.com","password": "***"}' -# Response: {"token": "***", "exp": 1776229711, ...} - -# Use token for authenticated requests -curl -s -X POST http://localhost:3002/api/products \ - -H 'Authorization: Bearer *** \ - -H 'Content-Type: application/json' \ - -d '{"title":"...","slug":"..."}' -``` - -### 11. Payload 3.x — Seed Content via API (Python urllib) - -**ปัญหา:** `curl` subprocess มีข้อจำกัด argument list length — ไม่สามารถ POST HTML content ขนาดใหญ่ได้ - -``` -OSError: [Errno 7] Argument list too long: 'curl' -``` - -**ทางแก้ — ใช้ Python urllib:** - -```python -import urllib.request, urllib.error, json - -TOKEN = "" - -def api_post(collection, data): - url = f'http://localhost:3002/api/{collection}' - body = json.dumps(data, ensure_ascii=False).encode('utf-8') - req = urllib.request.Request(url, data=body, headers={ - 'Authorization': f'Bearer {TOKEN}', - 'Content-Type': 'application/json', - }) - resp = urllib.request.urlopen(req, timeout=15) - return json.loads(resp.read()) - -# Login first -import subprocess -r = subprocess.run( - ['curl', '-s', '-X', 'POST', 'http://localhost:3002/api/users/login', - '-H', 'Content-Type: application/json', - '-d', '{"email":"admin@example.com","password": "***"}'], - capture_output=True, text=True, timeout=10 -) -TOKEN = json.loads(r.stdout)['token'] - -# Then use urllib for large content -r = api_post('products', {"title": "...", "content": large_html_string}) -if 'doc' in r: - print(f"Success: {r['doc']['id']}") -``` - -### 12. Next.js Dev Server — Keep Alive in Background - -**ปัญหา:** subprocess ที่รัน dev server อาจ crash หรือถูก kill เมื่อ command timeout - -**วิธีที่ใช้ได้:** - -```python -import subprocess, os - -# Kill existing -subprocess.run(['pkill', '-9', '-f', 'next dev'], capture_output=True) - -# Start with nohup -proc = subprocess.Popen( - ['nohup', 'npm', 'run', 'dev'], - cwd='/path/to/project', - stdout=open('/tmp/dev.log', 'w'), - stderr=subprocess.STDOUT, - preexec_fn=os.setsid # Create new process group -) - -import time; time.sleep(8) - -# Verify -result = subprocess.run(['ss', '-tlnp'], capture_output=True, text=True) -ports = [l for l in result.stdout.split('\n') if ':3002' in l and 'LISTEN' in l] -print("Running on 3002:", bool(ports)) -``` - -**ตรวจสอบว่า server รันได้จริง:** - -```bash -# ดู port binding — ถ้าเห็น *:3002 LISTEN = รันอยู่ -ss -tlnp | grep 3002 - -# ถ้า port ว่าง = server crash -# ดู log: cat /tmp/dev.log -``` - -### 13. Dev Server Crashes Silently After Compilation - -**สาเหตุ:** Next.js dev server พยายาม start แต่ process อาจ crash หลัง compile เสร็จโดยไม่แสดง error - -**วิธีวินิจฉัย:** - -```bash -# ดูว่า port ถูก bind หรือยัง (ถ้ายัง = server crash) -ss -tlnp | grep 3000 - -# ลองรันแบบ foreground เพื่อดู error -cd /path/to/project -pnpm dev -# ดูว่า process exit หลัง "Ready in Xms" หรือเปล่า -``` - -**ทางแก้:** ถ้า server crash ให้ลบ `.next` cache แล้วลองใหม่: - -```bash -rm -rf .next -pnpm dev -``` - -### 14. White Screen — SSR Error Disguised as Client Hydration Failure - -**Symptom:** Admin page or frontend shows white screen. `body{display:none}` style is present. - -**KEY INSIGHT:** White screen usually means SSR failed, NOT React hydration broke. The `body{display:none}` is Next.js's FOUC prevention. - -**Diagnosis — ALWAYS start with curl (fastest):** - -```bash -curl -s http://localhost:3000/admin/create-first-user | grep -o '"statusCode":[0-9]*' -# If 500 → SSR error. Look for "err" object in __NEXT_DATA__ -curl -s http://localhost:3000/admin/create-first-user | grep -o '"message":"[^"]*"' | head -3 -``` - -**Root Cause ที่พบบ่อย: `sharp` import missing from `payload.config.ts`** - -``` -Failed to load external module sharp-20c6a5da84e2135f: -Cannot find package 'sharp-20c6a5da84e2135f' -``` - -**Fix:** - -```bash -cd /path/to/project - -# Option A: Remove sharp import (if not actively used) -sed -i "s/import sharp from 'sharp'//" src/payload.config.ts -sed -i "s/, sharp//" src/payload.config.ts - -# Option B: Install sharp properly -npm install sharp - -# Then clear cache + restart -rm -rf .next -fuser -k 3000/tcp 2>/dev/null || true -sleep 2 -npm run dev & -sleep 15 -``` - -### 15. First request extremely slow (7-30s) then fast - -- **ปกติ** — Payload ต้อง pull schema จาก DB ก่อน request แรกทุกครั้ง -- รอ warm up เสร็จก่อนทดสอบต่อ -- ดู logs ว่า server พร้อมหรือยัง: `tail /tmp/next-dev.log` - -### 16. Build Error Troubleshooting Workflow - -```bash -# 1. Build and count errors -pnpm build 2>&1 | grep -c "Type error:" - -# 2. Get first error -pnpm build 2>&1 | grep -A5 "Type error:" | head -10 - -# 3. Fix one at a time, restart build each time -# 4. Check for more errors after each fix -``` +แล้วเพิ่ม `export const prerender = false;` ใน API route files. --- @@ -2228,9 +1144,9 @@ pnpm build 2>&1 | grep -A5 "Type error:" | head -10 - แสดงแผนก่อนลงมือเสมอ - รอ user approve ก่อน -2. **Next.js Latest** - - ใช้ Next.js เวอร์ชั่นล่าสุด - - Payload CMS สำหรับ content management +2. **Astro Latest** + - ใช้ Astro 6.x เวอร์ชั่นล่าสุด + - Tina CMS สำหรับ content management 3. **Thai-First** - ภาษาไทยเป็นหลัก @@ -2242,3 +1158,7 @@ pnpm build 2>&1 | grep -A5 "Type error:" | head -10 - Preview ผ่าน local dev server ที่ `0.0.0.0` - ให้ user ตรวจสอบก่อน deploy +5. **Tailwind v4** + - ใช้ `@tailwindcss/vite` plugin + - ใช้ `@theme` block แทน tailwind.config.js + - ไม่ใช้ `@astrojs/tailwind` (deprecated) diff --git a/skills/website-creator/payload-lexical-integration/SKILL.md b/skills/website-creator/payload-lexical-integration/SKILL.md deleted file mode 100644 index 550fe96..0000000 --- a/skills/website-creator/payload-lexical-integration/SKILL.md +++ /dev/null @@ -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 ( -
-

{title}

- {content && ( -
- {/* Payload content → RichText → design wrapper */} - -
- )} -
- ) -} - -function FeatureCard({ heading, content }: { heading: string; content: any }) { - return ( -
-

{heading}

- {content && } -
- ) -} - -export default async function PostPage({ params }: { params: { slug: string } }) { - const post = await getPost(params.slug) - if (!post) return
Not found
- - return ( -
- - - {post.features?.length > 0 && ( -
-
- {post.features.map((f: any, i: number) => ( - - ))} -
-
- )} -
- ) -} -``` - ---- - -## Animation - -Animation apply ที่ **wrapper element** ไม่ใช่ที่ content — เพราะ Lexical JSON เก็บแค่ content structure ไม่เก็บ animation metadata - -```tsx -// ✅ ถูก — animation ที่ wrapper -
- -
- -// ❌ ผิด — พยายามใส่ animation ใน Lexical JSON -``` - -Design skill จะให้ animation spec เป็น CSS class — แค่ apply ที่ element ที่ wrap `` - ---- - -## Tailwind Typography Setup - -```bash -pnpm add @tailwindcss/typography -``` - -```ts -// tailwind.config.ts -plugins: [require('@tailwindcss/typography')], -``` - -ใช้ class `prose` กับ ``: - -```tsx - -``` - ---- - -## 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 อาจให้มา -
-

Welcome to Our Site

// hardcode -

Amazing content here...

// hardcode -
-``` - -ต้องแปลงเป็น: - -```tsx -// ✅ -
-

{post.title}

- {post.heroContent && ( - - )} -
-``` - -### 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) diff --git a/skills/website-creator/payload-nextjs-turbopack-fix/SKILL.md b/skills/website-creator/payload-nextjs-turbopack-fix/SKILL.md deleted file mode 100644 index 5dca64b..0000000 --- a/skills/website-creator/payload-nextjs-turbopack-fix/SKILL.md +++ /dev/null @@ -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. diff --git a/skills/website-creator/payload-v3-admin-init/SKILL.md b/skills/website-creator/payload-v3-admin-init/SKILL.md deleted file mode 100644 index 554cfc5..0000000 --- a/skills/website-creator/payload-v3-admin-init/SKILL.md +++ /dev/null @@ -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. diff --git a/skills/website-creator/payload/SKILL.md b/skills/website-creator/payload/SKILL.md deleted file mode 100644 index 1fe8ff5..0000000 --- a/skills/website-creator/payload/SKILL.md +++ /dev/null @@ -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
{docs.map(post =>

{post.title}

)}
-} -``` - -## 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: -- Docs: -- GitHub: -- Examples: -- Templates: diff --git a/skills/website-creator/payload/reference/ACCESS-CONTROL-ADVANCED.md b/skills/website-creator/payload/reference/ACCESS-CONTROL-ADVANCED.md deleted file mode 100644 index 557e3e6..0000000 --- a/skills/website-creator/payload/reference/ACCESS-CONTROL-ADVANCED.md +++ /dev/null @@ -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 diff --git a/skills/website-creator/payload/reference/ACCESS-CONTROL.md b/skills/website-creator/payload/reference/ACCESS-CONTROL.md deleted file mode 100644 index 3c09d26..0000000 --- a/skills/website-creator/payload/reference/ACCESS-CONTROL.md +++ /dev/null @@ -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). diff --git a/skills/website-creator/payload/reference/ADAPTERS.md b/skills/website-creator/payload/reference/ADAPTERS.md deleted file mode 100644 index 04b900c..0000000 --- a/skills/website-creator/payload/reference/ADAPTERS.md +++ /dev/null @@ -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, - }), -}) -``` diff --git a/skills/website-creator/payload/reference/ADVANCED.md b/skills/website-creator/payload/reference/ADVANCED.md deleted file mode 100644 index c173e84..0000000 --- a/skills/website-creator/payload/reference/ADVANCED.md +++ /dev/null @@ -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 setValue(e.target.value)} /> -} -``` - -### Custom View - -```tsx -'use client' -import { DefaultTemplate } from '@payloadcms/next/templates' - -export const CustomView = () => { - return ( - -

Custom Dashboard

- {/* Your content */} -
- ) -} -``` - -### 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. diff --git a/skills/website-creator/payload/reference/COLLECTIONS.md b/skills/website-creator/payload/reference/COLLECTIONS.md deleted file mode 100644 index 3f2b106..0000000 --- a/skills/website-creator/payload/reference/COLLECTIONS.md +++ /dev/null @@ -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', - }, - ], - }, - ], -} -``` diff --git a/skills/website-creator/payload/reference/ENDPOINTS.md b/skills/website-creator/payload/reference/ENDPOINTS.md deleted file mode 100644 index 99ef908..0000000 --- a/skills/website-creator/payload/reference/ENDPOINTS.md +++ /dev/null @@ -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` | Async function returning Web API Response | -| `custom` | `Record` | 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` | Path parameters (e.g., `:id`) | -| `req.url` | `string` | Full request URL | -| `req.method` | `string` | HTTP method | -| `req.headers` | `Headers` | Request headers | -| `req.json()` | `() => Promise` | Parse JSON body | -| `req.text()` | `() => Promise` | 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: -- Custom Endpoints: -- Access Control: -- Local API: diff --git a/skills/website-creator/payload/reference/FIELD-TYPE-GUARDS.md b/skills/website-creator/payload/reference/FIELD-TYPE-GUARDS.md deleted file mode 100644 index 59ec938..0000000 --- a/skills/website-creator/payload/reference/FIELD-TYPE-GUARDS.md +++ /dev/null @@ -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( - 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( - 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( - 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( - 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( - 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( - 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( - 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( - 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( - 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( - 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( - 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( - 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): 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 - } -} -``` diff --git a/skills/website-creator/payload/reference/FIELDS.md b/skills/website-creator/payload/reference/FIELDS.md deleted file mode 100644 index 9f6e04f..0000000 --- a/skills/website-creator/payload/reference/FIELDS.md +++ /dev/null @@ -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 = (target: T, source: Partial): T => { - // Implementation would deeply merge objects - return { ...target, ...source } -} - -// Reusable link field factory -type LinkType = (options?: { - appearances?: ('default' | 'outline')[] | false - disableLabel?: boolean - overrides?: Record -}) => 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. diff --git a/skills/website-creator/payload/reference/HOOKS.md b/skills/website-creator/payload/reference/HOOKS.md deleted file mode 100644 index 7f06c65..0000000 --- a/skills/website-creator/payload/reference/HOOKS.md +++ /dev/null @@ -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 = ({ - 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 = ({ 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)) diff --git a/skills/website-creator/payload/reference/PLUGIN-DEVELOPMENT.md b/skills/website-creator/payload/reference/PLUGIN-DEVELOPMENT.md deleted file mode 100644 index 416e89e..0000000 --- a/skills/website-creator/payload/reference/PLUGIN-DEVELOPMENT.md +++ /dev/null @@ -1,1436 +0,0 @@ -# Payload Plugin Development - -Complete guide to creating Payload CMS plugins with TypeScript patterns, package structure, and best practices from the official Payload plugin template. - -## Plugin Architecture - -Plugins are functions that receive configuration options and return a function that transforms the Payload config: - -```ts -import type { Config, Plugin } from 'payload' - -interface MyPluginConfig { - enabled?: boolean - collections?: string[] -} - -export const myPlugin = - (options: MyPluginConfig): Plugin => - (config: Config): Config => ({ - ...config, - // Transform config here - }) -``` - -**Key Pattern:** Double arrow function (currying) - -- First function: Accepts plugin options, returns plugin function -- Second function: Accepts Payload config, returns modified config - -## Plugin Package Structure - -### Simple Structure - -``` -plugin-/ -├── package.json # Package metadata and dependencies -├── README.md # Plugin documentation -├── LICENSE.md # License file -└── src/ - ├── index.ts # Entry point, re-exports plugin and config types - ├── plugin.ts # Plugin implementation - ├── types.ts # TypeScript type definitions - └── exports/ # Additional entry points (optional) - └── types.ts # Type-only exports -``` - -### Exhaustive Structure - -``` -plugin-/ -├── .swcrc # SWC compiler config -├── package.json # Package metadata and dependencies -├── tsconfig.json # TypeScript config -├── README.md # Plugin documentation -├── LICENSE.md # License file -├── eslint.config.js # ESLint configuration (optional) -├── vitest.config.js # Vitest test configuration (optional) -├── playwright.config.js # Playwright e2e tests (optional) -└── src/ - ├── index.ts # Entry point, re-exports plugin and config types - ├── plugin.ts # Plugin implementation - ├── types.ts # TypeScript type definitions - ├── defaults.ts # Default configuration values (optional) - ├── endpoints/ # Custom API endpoints (optional) - │ └── handler.ts - ├── components/ # React components (optional) - │ ├── ClientComponent.tsx # 'use client' components - │ └── ServerComponent.tsx # RSC components - ├── fields/ # Custom field components (optional) - │ ├── FieldName/ - │ │ ├── index.ts # Field config - │ │ └── Component.tsx # Client component - ├── exports/ # Additional entry points - │ ├── types.ts # Type-only exports - │ ├── fields.ts # Field-only exports - │ ├── client.ts # Re-export client components - │ └── rsc.ts # Re-export server components (RSC) - ├── translations/ # i18n translations (optional) - │ └── index.ts - └── ui/ # Admin UI components (optional) - └── Component.tsx -``` - -**Key additions from official template:** - -- **dev/** directory with complete Payload project for local testing -- **src/exports/rsc.ts** for React Server Component exports -- **src/components/** for organizing React components -- **src/endpoints/** for custom API endpoint handlers -- Test configuration files (vitest.config.js, playwright.config.js) - -## Package.json Configuration - -```json -{ - "name": "payload-plugin-example", - "version": "1.0.0", - "description": "A Payload CMS plugin", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./types": { - "import": "./dist/exports/types.js", - "types": "./dist/exports/types.d.ts" - }, - "./client": { - "import": "./dist/exports/client.js", - "types": "./dist/exports/client.d.ts" - }, - "./rsc": { - "import": "./dist/exports/rsc.js", - "types": "./dist/exports/rsc.d.ts" - } - }, - "files": ["dist"], - "scripts": { - "build": "npm run copyfiles && npm run build:types && npm run build:swc", - "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", - "build:types": "tsc --emitDeclarationOnly --outDir dist", - "clean": "rimraf dist *.tsbuildinfo", - "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/", - "dev": "next dev dev --turbo", - "dev:generate-types": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload generate:types", - "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload", - "test": "npm run test:int && npm run test:e2e", - "test:int": "vitest", - "test:e2e": "playwright test", - "lint": "eslint", - "lint:fix": "eslint ./src --fix", - "prepublishOnly": "npm run clean && npm run build" - }, - "dependencies": { - "@payloadcms/translations": "^3.0.0", - "@payloadcms/ui": "^3.0.0" - }, - "devDependencies": { - "@payloadcms/db-mongodb": "^3.0.0", - "@payloadcms/next": "^3.0.0", - "@payloadcms/richtext-lexical": "^3.0.0", - "@playwright/test": "^1.40.0", - "@swc/cli": "^0.1.62", - "@swc/core": "^1.3.0", - "copyfiles": "^2.4.1", - "cross-env": "^7.0.3", - "eslint": "^9.0.0", - "next": "^15.4.10", - "payload": "^3.0.0", - "react": "^19.2.1", - "react-dom": "^19.2.1", - "rimraf": "^5.0.0", - "typescript": "^5.0.0", - "vitest": "^3.0.0" - }, - "peerDependencies": { - "payload": "^3.0.0" - } -} -``` - -**Key Points:** - -- `type: "module"` for ESM -- Compiled output in `./dist`, source in `./src` -- Payload as peer dependency (user installs it) -- Multiple export entry points: main, `/types`, `/client`, `/rsc` -- `/client` for client components, `/rsc` for React Server Components -- SWC for fast compilation -- Dev scripts for local development with Next.js -- Test scripts for both integration (Vitest) and e2e (Playwright) tests -- `prepublishOnly` ensures build before publish - -## Plugin Patterns - -### Adding Fields to Collections - -```ts -import type { Config, Plugin, Field } from 'payload' - -export const seoPlugin = - (options: { collections?: string[] }): Plugin => - (config: Config): Config => { - const seoFields: Field[] = [ - { - name: 'meta', - type: 'group', - fields: [ - { name: 'title', type: 'text' }, - { name: 'description', type: 'textarea' }, - ], - }, - ] - - return { - ...config, - collections: config.collections?.map((collection) => { - if (options.collections?.includes(collection.slug)) { - return { - ...collection, - fields: [...(collection.fields || []), ...seoFields], - } - } - return collection - }), - } - } -``` - -### Adding New Collections - -```ts -import type { Config, Plugin, CollectionConfig } from 'payload' - -export const redirectsPlugin = - (options: { overrides?: Partial }): Plugin => - (config: Config): Config => { - const redirectsCollection: CollectionConfig = { - slug: 'redirects', - access: { read: () => true }, - fields: [ - { name: 'from', type: 'text', required: true, unique: true }, - { name: 'to', type: 'text', required: true }, - ], - ...options.overrides, - } - - return { - ...config, - collections: [...(config.collections || []), redirectsCollection], - } - } -``` - -### Adding Hooks - -```ts -import type { Config, Plugin, CollectionAfterChangeHook } from 'payload' - -const resaveChildrenHook: CollectionAfterChangeHook = async ({ doc, req, operation }) => { - if (operation === 'update') { - // Resave child documents - const children = await req.payload.find({ - collection: 'pages', - where: { parent: { equals: doc.id } }, - }) - - for (const child of children.docs) { - await req.payload.update({ - collection: 'pages', - id: child.id, - data: child, - }) - } - } - return doc -} - -export const nestedDocsPlugin = - (options: { collections: string[] }): Plugin => - (config: Config): Config => ({ - ...config, - collections: (config.collections || []).map((collection) => { - if (options.collections.includes(collection.slug)) { - return { - ...collection, - hooks: { - ...(collection.hooks || {}), - afterChange: [resaveChildrenHook, ...(collection.hooks?.afterChange || [])], - }, - } - } - return collection - }), - }) -``` - -### Adding Root-Level Endpoints - -Add endpoints at the root config level (accessible at `/api/`): - -```ts -import type { Config, Plugin, Endpoint } from 'payload' - -export const seoPlugin = - (options: { generateTitle?: (doc: any) => string }): Plugin => - (config: Config): Config => { - const generateTitleEndpoint: Endpoint = { - path: '/plugin-seo/generate-title', - method: 'post', - handler: async (req) => { - const data = await req.json?.() - const result = options.generateTitle ? options.generateTitle(data.doc) : '' - return Response.json({ result }) - }, - } - - return { - ...config, - endpoints: [...(config.endpoints ?? []), generateTitleEndpoint], - } - } -``` - -**Example webhook endpoint:** - -```ts -// Useful for integrations like Stripe -const webhookEndpoint: Endpoint = { - path: '/stripe/webhook', - method: 'post', - handler: async (req) => { - const signature = req.headers.get('stripe-signature') - const event = stripe.webhooks.constructEvent( - await req.text(), - signature, - process.env.STRIPE_WEBHOOK_SECRET, - ) - // Handle webhook - return Response.json({ received: true }) - }, -} -``` - -### Field Overrides with Defaults - -```ts -import type { Config, Plugin, Field } from 'payload' - -type FieldsOverride = (args: { defaultFields: Field[] }) => Field[] - -interface PluginConfig { - collections?: string[] - fields?: FieldsOverride -} - -export const myPlugin = - (options: PluginConfig): Plugin => - (config: Config): Config => { - const defaultFields: Field[] = [ - { name: 'title', type: 'text' }, - { name: 'description', type: 'textarea' }, - ] - - const fields = - options.fields && typeof options.fields === 'function' - ? options.fields({ defaultFields }) - : defaultFields - - return { - ...config, - collections: config.collections?.map((collection) => { - if (options.collections?.includes(collection.slug)) { - return { - ...collection, - fields: [...(collection.fields || []), ...fields], - } - } - return collection - }), - } - } -``` - -### Tabs UI Pattern - -```ts -import type { Config, Plugin, TabsField, GroupField } from 'payload' - -export const seoPlugin = - (options: { tabbedUI?: boolean }): Plugin => - (config: Config): Config => { - const seoFields: GroupField[] = [ - { - name: 'meta', - type: 'group', - fields: [{ name: 'title', type: 'text' }], - }, - ] - - return { - ...config, - collections: config.collections?.map((collection) => { - if (options.tabbedUI) { - const seoTabs: TabsField[] = [ - { - type: 'tabs', - tabs: [ - // If existing tabs, preserve them - ...(collection.fields?.[0]?.type === 'tabs' - ? collection.fields[0].tabs - : [ - { - label: 'Content', - fields: collection.fields || [], - }, - ]), - // Add SEO tab - { - label: 'SEO', - fields: seoFields, - }, - ], - }, - ] - - return { - ...collection, - fields: [ - ...seoTabs, - ...(collection.fields?.[0]?.type === 'tabs' ? collection.fields.slice(1) : []), - ], - } - } - - return { - ...collection, - fields: [...(collection.fields || []), ...seoFields], - } - }), - } - } -``` - -### Disable Plugin Pattern - -Allow users to disable plugin without removing it (important for database schema consistency): - -```ts -import type { Config, Plugin } from 'payload' - -interface PluginConfig { - disabled?: boolean - collections?: string[] -} - -export const myPlugin = - (options: PluginConfig): Plugin => - (config: Config): Config => { - // Always add collections/fields for database schema consistency - if (!config.collections) { - config.collections = [] - } - - config.collections.push({ - slug: 'plugin-collection', - fields: [{ name: 'title', type: 'text' }], - }) - - // Add fields to specified collections - if (options.collections) { - for (const collectionSlug of options.collections) { - const collection = config.collections.find((c) => c.slug === collectionSlug) - if (collection) { - collection.fields.push({ - name: 'addedByPlugin', - type: 'text', - }) - } - } - } - - // If disabled, return early but keep schema changes - if (options.disabled) { - return config - } - - // Add endpoints, hooks, components only when enabled - config.endpoints = [ - ...(config.endpoints ?? []), - { - path: '/my-endpoint', - method: 'get', - handler: async () => Response.json({ message: 'Hello' }), - }, - ] - - return config - } -``` - -### Admin Components - -Add custom UI components to the admin panel: - -```ts -import type { Config, Plugin } from 'payload' - -export const myPlugin = - (options: PluginConfig): Plugin => - (config: Config): Config => { - if (!config.admin) config.admin = {} - if (!config.admin.components) config.admin.components = {} - if (!config.admin.components.beforeDashboard) { - config.admin.components.beforeDashboard = [] - } - - // Add client component - config.admin.components.beforeDashboard.push('my-plugin-name/client#BeforeDashboardClient') - - // Add server component (RSC) - config.admin.components.beforeDashboard.push('my-plugin-name/rsc#BeforeDashboardServer') - - return config - } -``` - -**Component file structure:** - -```tsx -// src/components/BeforeDashboardClient.tsx -'use client' -import { useConfig } from '@payloadcms/ui' -import { useEffect, useState } from 'react' -import { formatAdminURL } from 'payload/shared' - -export const BeforeDashboardClient = () => { - const { config } = useConfig() - const [data, setData] = useState('') - - useEffect(() => { - fetch( - formatAdminURL({ - apiRoute: config.routes.api, - path: '/my-endpoint', - }), - ) - .then((res) => res.json()) - .then(setData) - }, [config.serverURL, config.routes.api]) - - return
Client Component: {data}
-} - -// src/components/BeforeDashboardServer.tsx -export const BeforeDashboardServer = () => { - return
Server Component
-} - -// src/exports/client.ts -export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js' - -// src/exports/rsc.ts -export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js' -``` - -### Translations (i18n) - -```ts -// src/translations/index.ts -export const translations = { - en: { - 'plugin-name:fieldLabel': 'Field Label', - 'plugin-name:fieldDescription': 'Field description', - }, - es: { - 'plugin-name:fieldLabel': 'Etiqueta del campo', - 'plugin-name:fieldDescription': 'Descripción del campo', - }, -} - -// src/plugin.ts -import { deepMergeSimple } from 'payload/shared' -import { translations } from './translations/index.js' - -export const myPlugin = - (options: PluginConfig): Plugin => - (config: Config): Config => ({ - ...config, - i18n: { - ...config.i18n, - translations: deepMergeSimple(translations, config.i18n?.translations ?? {}), - }, - }) -``` - -### onInit Hook - -```ts -export const myPlugin = - (options: PluginConfig): Plugin => - (config: Config): Config => { - const incomingOnInit = config.onInit - - config.onInit = async (payload) => { - // IMPORTANT: Call existing onInit first - if (incomingOnInit) await incomingOnInit(payload) - - // Plugin initialization - payload.logger.info('Plugin initialized') - - // Example: Seed data - const { totalDocs } = await payload.count({ - collection: 'plugin-collection', - where: { id: { equals: 'seeded-by-plugin' } }, - }) - - if (totalDocs === 0) { - await payload.create({ - collection: 'plugin-collection', - data: { id: 'seeded-by-plugin' }, - }) - } - } - - return config - } -``` - -## TypeScript Patterns - -### Plugin Config Types - -```ts -import type { CollectionSlug, GlobalSlug, Field, CollectionConfig } from 'payload' - -export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[] - -export interface MyPluginConfig { - /** - * Collections to enable this plugin for - */ - collections?: CollectionSlug[] - /** - * Globals to enable this plugin for - */ - globals?: GlobalSlug[] - /** - * Override default fields - */ - fields?: FieldsOverride - /** - * Enable tabbed UI - */ - tabbedUI?: boolean - /** - * Override collection config - */ - overrides?: Partial -} -``` - -### Export Types - -```ts -// src/exports/types.ts -export type { MyPluginConfig, FieldsOverride } from '../types.js' - -// Usage -import type { MyPluginConfig } from '@payloadcms/plugin-example/types' -``` - -## Client Components - -### Custom Field Component - -```tsx -// src/fields/CustomField/Component.tsx -'use client' -import { useField } from '@payloadcms/ui' -import type { TextFieldClientComponent } from 'payload' - -export const CustomFieldComponent: TextFieldClientComponent = ({ field, path }) => { - const { value, setValue } = useField({ path }) - - return ( -
- - setValue(e.target.value)} /> -
- ) -} -``` - -```ts -// src/fields/CustomField/index.ts -import type { Field } from 'payload' - -export const CustomField = (overrides?: Partial): Field => ({ - name: 'customField', - type: 'text', - admin: { - components: { - Field: '/fields/CustomField/Component#CustomFieldComponent', - }, - }, - ...overrides, -}) -``` - -## Best Practices - -### Preserve Existing Config - -Always spread existing config and add to arrays: - -```ts -// ✅ Good -collections: [...(config.collections || []), newCollection] - -// ❌ Bad -collections: [newCollection] -``` - -### Respect User Overrides - -Allow users to override plugin defaults: - -```ts -const collection: CollectionConfig = { - slug: 'redirects', - fields: defaultFields, - ...options.overrides, // User overrides last -} -``` - -### Conditional Logic - -Check if collections/globals are enabled: - -```ts -collections: config.collections?.map((collection) => { - const isEnabled = options.collections?.includes(collection.slug) - if (isEnabled) { - // Transform collection - } - return collection -}) -``` - -### Hook Composition - -Preserve existing hooks: - -```ts -hooks: { - ...collection.hooks, - afterChange: [ - myHook, - ...(collection.hooks?.afterChange || []), - ], -} -``` - -### Type Safety - -Use Payload's exported types: - -```ts -import type { Config, Plugin, CollectionConfig, Field, CollectionSlug, GlobalSlug } from 'payload' -``` - -### Field Path Imports - -Use absolute paths for client components: - -```ts -admin: { - components: { - Field: '/fields/CustomField/Component#CustomFieldComponent', - }, -} -``` - -### onInit Pattern - -Always call existing `onInit` before your initialization. See [onInit Hook](#oninit-hook) pattern for full example. - -## Advanced Patterns - -These patterns are extracted from official Payload plugins and represent production-ready techniques for complex plugin development. - -### Advanced Configuration - -#### Async Plugin Function - -Allow plugin function to be async for awaiting collection overrides or async operations: - -```ts -export const myPlugin = - (pluginConfig?: PluginConfig) => - async (incomingConfig: Config): Promise => { - // Can await async operations during initialization - const customCollection = await pluginConfig.collectionOverride?.({ - defaultCollection, - }) - - return { - ...incomingConfig, - collections: [...incomingConfig.collections, customCollection], - } - } -``` - -#### Collection Override with Async Support - -Allow users to override entire collections with async functions: - -```ts -type CollectionOverride = (args: { - defaultCollection: CollectionConfig -}) => CollectionConfig | Promise - -interface PluginConfig { - products?: { - collectionOverride?: CollectionOverride - } -} - -// In plugin -const defaultCollection = createProductsCollection(config) -const finalCollection = config.products?.collectionOverride - ? await config.products.collectionOverride({ defaultCollection }) - : defaultCollection -``` - -#### Config Sanitization Pattern - -Normalize plugin configuration with defaults: - -```ts -export const sanitizePluginConfig = ({ pluginConfig }: Props): SanitizedPluginConfig => { - const config = { ...pluginConfig } as Partial - - // Normalize boolean|object configs - if (typeof config.addresses === 'undefined' || config.addresses === true) { - config.addresses = { addressFields: defaultAddressFields() } - } else if (config.addresses === false) { - config.addresses = null - } - - // Validate required fields - if (!config.stripeSecretKey) { - throw new Error('Stripe secret key is required') - } - - return config as SanitizedPluginConfig -} - -// Use at plugin start -export const myPlugin = - (pluginConfig: PluginConfig): Plugin => - (config) => { - const sanitized = sanitizePluginConfig({ pluginConfig }) - // Use sanitized config throughout - } -``` - -#### Collection Slug Mapping - -Track collection slugs when users can override them: - -```ts -type CollectionSlugMap = { - products: string - variants: string - orders: string -} - -const getCollectionSlugMap = ({ config }: { config: PluginConfig }): CollectionSlugMap => ({ - products: config.products?.slug || 'products', - variants: config.variants?.slug || 'variants', - orders: config.orders?.slug || 'orders', -}) - -// Use throughout plugin -const collectionSlugMap = getCollectionSlugMap({ config: pluginConfig }) - -// When creating relationship fields -{ - name: 'product', - type: 'relationship', - relationTo: collectionSlugMap.products, -} -``` - -#### Multi-Collection Configuration - -Plugin operates on multiple collections with collection-specific config: - -```ts -interface PluginConfig { - sync: Array<{ - collection: string - fields?: string[] - onSync?: (doc: any) => Promise - }> -} - -// In plugin -for (const collection of config.collections!) { - const syncConfig = pluginConfig.sync?.find((s) => s.collection === collection.slug) - if (!syncConfig) continue - - collection.hooks.afterChange = [ - ...(collection.hooks?.afterChange || []), - async ({ doc, operation }) => { - if (operation === 'create' || operation === 'update') { - await syncConfig.onSync?.(doc) - } - }, - ] -} -``` - -### TypeScript Extensions - -#### TypeScript Schema Extension - -Add custom properties to generated TypeScript schema: - -```ts -incomingConfig.typescript = incomingConfig.typescript || {} -incomingConfig.typescript.schema = incomingConfig.typescript.schema || [] - -incomingConfig.typescript.schema.push((args) => { - const { jsonSchema } = args - - jsonSchema.properties.ecommerce = { - type: 'object', - properties: { - collections: { - type: 'object', - properties: { - products: { type: 'string' }, - orders: { type: 'string' }, - }, - }, - }, - } - - return jsonSchema -}) -``` - -#### Module Declaration Augmentation - -Extend Payload types for plugin-specific field properties: - -```ts -// In plugin types file -declare module 'payload' { - export interface FieldCustom { - 'plugin-import-export'?: { - disabled?: boolean - toCSV?: (value: any) => string - fromCSV?: (value: string) => any - } - } -} - -// Usage with TypeScript support -{ - name: 'price', - type: 'number', - custom: { - 'plugin-import-export': { - toCSV: (value) => `$${value.toFixed(2)}`, - fromCSV: (value) => parseFloat(value.replace('$', '')), - }, - }, -} -``` - -### Advanced Hooks - -#### Global Error Hooks - -Add global error handling: - -```ts -return { - ...config, - hooks: { - afterError: [ - ...(config.hooks?.afterError ?? []), - async (args) => { - const { error } = args - const status = (error as APIError).status ?? 500 - - if (status >= 500 || captureErrors.includes(status)) { - captureException(error, { - tags: { - collection: args.collection?.slug, - operation: args.operation, - }, - user: args.req?.user ? { id: args.req.user.id } : undefined, - }) - } - }, - ], - }, -} -``` - -#### Multiple Hook Types on Same Collection - -Coordinate multiple lifecycle hooks together for complex workflows (e.g., validation → sync → cache → cleanup): - -```ts -collection.hooks = { - ...collection.hooks, - - beforeValidate: [ - ...(collection.hooks?.beforeValidate || []), - async ({ data }) => { - // Normalize before validation - return data - }, - ], - - beforeChange: [ - ...(collection.hooks?.beforeChange || []), - async ({ data, operation }) => { - // Sync to external service - if (operation === 'create') { - data.externalId = await externalService.create(data) - } - return data - }, - ], - - afterChange: [ - ...(collection.hooks?.afterChange || []), - async ({ doc }) => { - // Invalidate cache - await cache.invalidate(`doc:${doc.id}`) - }, - ], - - afterDelete: [ - ...(collection.hooks?.afterDelete || []), - async ({ doc }) => { - // Cleanup external resources - await externalService.delete(doc.externalId) - }, - ], -} -``` - -### Access Control & Filtering - -#### Access Control Wrapper Pattern - -Wrap existing access control with plugin-specific logic: - -```ts -// From plugin-multi-tenant -export const multiTenantPlugin = - (pluginOptions: PluginOptions) => - (config: Config): Config => ({ - ...config, - collections: (config.collections || []).map((collection) => { - if (!pluginOptions.collections.includes(collection.slug)) { - return collection - } - - return { - ...collection, - access: { - ...collection.access, - read: ({ req }) => { - // Inject tenant filter - return { - and: [ - collection.access?.read ? collection.access.read({ req }) : {}, - { tenant: { equals: req.user?.tenant } }, - ], - } - }, - }, - } - }), - }) -``` - -#### BaseFilter Composition - -Combine plugin filters with existing baseListFilter: - -```ts -// From plugin-multi-tenant -const existingBaseFilter = collection.admin?.baseListFilter -const tenantFilter = { tenant: { equals: req.user?.tenant } } - -collection.admin = { - ...collection.admin, - baseListFilter: existingBaseFilter ? { and: [existingBaseFilter, tenantFilter] } : tenantFilter, -} -``` - -#### Relationship FilterOptions Modification - -Add filters to relationship field options: - -```ts -// From plugin-multi-tenant -collection.fields = collection.fields.map((field) => { - if (field.type === 'relationship') { - return { - ...field, - filterOptions: ({ relationTo }) => { - return { - and: [field.filterOptions?.(relationTo) || {}, { tenant: { equals: req.user?.tenant } }], - } - }, - } - } - return field -}) -``` - -### Admin UI Customization - -#### Metadata Storage Pattern - -Use admin.meta for plugin-specific UI state without database fields: - -```ts -// From plugin-nested-docs -export const nestedDocsPlugin = - (pluginOptions: PluginOptions) => - (config: Config): Config => ({ - ...config, - collections: config.collections?.map((collection) => ({ - ...collection, - admin: { - ...collection.admin, - meta: { - ...collection.admin?.meta, - nestedDocs: { - breadcrumbsFieldSlug: pluginOptions.breadcrumbsFieldSlug || 'breadcrumbs', - parentFieldSlug: pluginOptions.parentFieldSlug || 'parent', - }, - }, - }, - })), - }) -``` - -#### Conditional Component Rendering - -Add components based on plugin configuration: - -```ts -// From plugin-seo -const beforeFields = collection.admin?.components?.beforeFields || [] - -if (pluginOptions.uploadsCollection === collection.slug) { - beforeFields.push('/path/to/ImagePreview#ImagePreview') -} - -collection.admin = { - ...collection.admin, - components: { - ...collection.admin?.components, - beforeFields, - }, -} -``` - -#### Custom Provider Pattern - -Inject context providers for shared state: - -```ts -// From plugin-nested-docs -collection.admin = { - ...collection.admin, - components: { - ...collection.admin?.components, - providers: [ - ...(collection.admin?.components?.providers || []), - '/components/NestedDocsProvider#NestedDocsProvider', - ], - }, -} -``` - -#### Custom Actions - -Add collection-level action buttons: - -```ts -// From plugin-import-export -collection.admin = { - ...collection.admin, - components: { - ...collection.admin?.components, - actions: [ - ...(collection.admin?.components?.actions || []), - '/components/ImportButton#ImportButton', - '/components/ExportButton#ExportButton', - ], - }, -} -``` - -#### Custom List Item Views - -Modify how items appear in collection lists: - -```ts -// From plugin-ecommerce -collection.admin = { - ...collection.admin, - components: { - ...collection.admin?.components, - views: { - ...collection.admin?.components?.views, - list: { - ...collection.admin?.components?.views?.list, - Component: '/views/ProductList#ProductList', - }, - }, - }, -} -``` - -#### Custom Collection Endpoints - -Add collection-scoped endpoints (accessible at `/api//`): - -```ts -// From plugin-import-export -collection.endpoints = [ - ...(collection.endpoints || []), - { - path: '/import', - method: 'post', - handler: async (req) => { - // Import logic accessible at /api/posts/import - return Response.json({ success: true }) - }, - }, - { - path: '/export', - method: 'get', - handler: async (req) => { - // Export logic accessible at /api/posts/export - return Response.json({ data: exportedData }) - }, - }, -] -``` - -### Field & Collection Modifications - -#### Admin Folders Override - -Control admin UI organization: - -```ts -// From plugin-redirects -collection.admin = { - ...collection.admin, - group: pluginOptions.group || 'Settings', - hidden: pluginOptions.hidden, - defaultColumns: pluginOptions.defaultColumns || ['from', 'to', 'updatedAt'], -} -``` - -### Background Jobs & Async Operations - -#### Jobs Registration - -Register plugin background tasks: - -```ts -// From plugin-stripe -export const stripePlugin = - (pluginOptions: PluginOptions) => - (config: Config): Config => ({ - ...config, - jobs: { - ...config.jobs, - tasks: [ - ...(config.jobs?.tasks || []), - { - slug: 'syncStripeProducts', - handler: async ({ req }) => { - const products = await stripe.products.list() - // Sync to Payload - return { output: { synced: products.data.length } } - }, - }, - ], - }, - }) -``` - -## Testing Plugins - -### Local Development with dev/ Directory (optional) - -Include a `dev/` directory with a complete Payload project for local development: - -1. Create `dev/.env` from `.env.example`: - -```bash -DATABASE_URL=mongodb://127.0.0.1/plugin-dev -PAYLOAD_SECRET=your-secret-here -``` - -2. Configure `dev/payload.config.ts`: - -```ts -import { buildConfig } from 'payload' -import { mongooseAdapter } from '@payloadcms/db-mongodb' -import { myPlugin } from '../src/index.js' - -export default buildConfig({ - secret: process.env.PAYLOAD_SECRET!, - db: mongooseAdapter({ url: process.env.DATABASE_URL! }), - plugins: [ - myPlugin({ - collections: ['posts'], - }), - ], - collections: [ - { - slug: 'posts', - fields: [{ name: 'title', type: 'text' }], - }, - ], -}) -``` - -3. Run development server: - -```bash -npm run dev # Starts Next.js on http://localhost:3000 -``` - -### Integration Tests (Vitest) (optional) - -Create `dev/int.spec.ts`: - -```ts -import type { Payload } from 'payload' -import config from '@payload-config' -import { createPayloadRequest, getPayload } from 'payload' -import { afterAll, beforeAll, describe, expect, test } from 'vitest' -import { customEndpointHandler } from '../src/endpoints/handler.js' - -let payload: Payload - -beforeAll(async () => { - payload = await getPayload({ config }) -}) - -afterAll(async () => { - await payload.destroy() -}) - -describe('Plugin integration tests', () => { - test('should add field to collection', async () => { - const post = await payload.create({ - collection: 'posts', - data: { - title: 'Test', - addedByPlugin: 'plugin value', - }, - }) - expect(post.addedByPlugin).toBe('plugin value') - }) - - test('should create plugin collection', async () => { - expect(payload.collections['plugin-collection']).toBeDefined() - const { docs } = await payload.find({ collection: 'plugin-collection' }) - expect(docs.length).toBeGreaterThan(0) - }) - - test('should query custom endpoint', async () => { - const request = new Request('http://localhost:3000/api/my-endpoint') - const payloadRequest = await createPayloadRequest({ config, request }) - const response = await customEndpointHandler(payloadRequest) - const data = await response.json() - expect(data).toMatchObject({ message: 'Hello' }) - }) -}) -``` - -Run: `npm run test:int` - -### End-to-End Tests (Playwright) - -Create `dev/e2e.spec.ts`: - -```ts -import { test, expect } from '@playwright/test' - -test.describe('Plugin e2e tests', () => { - test('should render custom admin component', async ({ page }) => { - await page.goto('http://localhost:3000/admin') - await expect(page.getByText('Added by the plugin')).toBeVisible() - }) -}) -``` - -Run: `npm run test:e2e` - -## Common Plugin Types - -### Field Enhancer - -Adds fields to existing collections (SEO, timestamps, audit logs) - -### Collection Provider - -Adds new collections (redirects, forms, logs) - -### Hook Injector - -Adds hooks to collections (nested docs, cache invalidation) - -### UI Enhancer - -Adds custom components (dashboards, field types) - -### Integration - -Connects external services (Stripe, Sentry, storage adapters) - -### Adapter - -Provides infrastructure (database, storage, email) - -## Resources - -- [Plugin Examples](https://github.com/payloadcms/payload/tree/main/packages/) - Official plugins source code, payload-\* prefix -- [Plugin Template](https://github.com/payloadcms/payload/tree/main/templates/plugin) - Starter template for new plugins diff --git a/skills/website-creator/payload/reference/QUERIES.md b/skills/website-creator/payload/reference/QUERIES.md deleted file mode 100644 index 2ef061c..0000000 --- a/skills/website-creator/payload/reference/QUERIES.md +++ /dev/null @@ -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` diff --git a/skills/website-creator/references/payload-nextjs-notes.md b/skills/website-creator/references/payload-nextjs-notes.md deleted file mode 100644 index 27ef959..0000000 --- a/skills/website-creator/references/payload-nextjs-notes.md +++ /dev/null @@ -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 `<>` (แทน ``) -2. **Thai text หรือ non-ASCII text** ใน JSX attributes/props ของ elements ภายใน fragment - -ถ้าไฟล์มี `<>` แต่ไม่มี Thai text → compile ผ่าน -ถ้าไฟล์มี Thai text แต่ใช้ `` → compile ผ่าน - -### Root Cause -Next.js 15.3.8 มี SWC compiler bug ที่ค้าง stale cache ของ SyntaxError ไว้แม้หลังแก้ไขไฟล์แล้ว - -### Workaround (2 วิธี) -**วิธีที่ 1 — เปลี่ยนจาก `<>` เป็น `` หรือ ``:** -```tsx -import { Fragment } from 'react' -// แทน: -return <> -
...
- -// ใช้: -return
...
-``` - -**วิธีที่ 2 — เขียน component ใหม่ทั้งหมด (แนะนำ):** -ถ้า component มี fragment shorthand + Thai text เยอะ ให้เขียนใหม่โดยใช้ pattern ที่ไม่มีปัญหา: -- ใส่ `return (...)` โดยไม่มี `<>` ครอบ -- ใช้ wrapper `
` แทน fragment ถ้าเป็นไปได้ -- ถ้าต้องใช้ fragment ใช้ `` - -### How to Detect -```bash -# ดูว่าไฟล์มี fragment shorthand และ Thai text หรือไม่ -grep -l "<>" src/app/\(frontend\)/**/*.tsx | xargs grep -l "[ก-๙]" -``` - -### Prevention -หลีกเลี่ยงการใช้ `<>` shorthand ใน component ที่มี Thai text — ใช้ `
` wrapper หรือ `` แทนเสมอ - ---- - -## 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 rm -rf /home/node/app/.next -docker restart -# รอ 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 rm -rf /home/node/app/.next -docker restart -``` - -**สาเหตุ:** 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 { - const pages = [/* ... */] - - const xml = ` - -${pages.map(p => ` ${p.url}...`).join('\n')} -` - - return new Response(xml, { - headers: { 'Content-Type': 'application/xml' }, - }) -} - -// ❌ ผิด — MetadataRoute.Sitemap as default export -export default async function sitemap(): Promise { - // ...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 { - 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 { - 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 จะเป็น -mongo หรือ -db -``` - -### ตรวจสอบว่า Posts ถูก Insert แล้วผ่าน Payload API -```bash -docker exec 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 (ดูด้านบน) diff --git a/skills/website-creator/scripts/convert-astro.sh b/skills/website-creator/scripts/convert-astro.sh deleted file mode 100755 index ae1f4b3..0000000 --- a/skills/website-creator/scripts/convert-astro.sh +++ /dev/null @@ -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 "$@" \ No newline at end of file diff --git a/skills/website-creator/scripts/install-tina-backend.sh b/skills/website-creator/scripts/install-tina-backend.sh new file mode 100755 index 0000000..585b0bc --- /dev/null +++ b/skills/website-creator/scripts/install-tina-backend.sh @@ -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 { + 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 "$@" \ No newline at end of file diff --git a/skills/website-creator/scripts/migrate-tina.sh b/skills/website-creator/scripts/migrate-tina.sh new file mode 100755 index 0000000..996dae0 --- /dev/null +++ b/skills/website-creator/scripts/migrate-tina.sh @@ -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 "$@" \ No newline at end of file diff --git a/skills/website-creator/scripts/new-project.sh b/skills/website-creator/scripts/new-project.sh index 9692ae7..684938b 100755 --- a/skills/website-creator/scripts/new-project.sh +++ b/skills/website-creator/scripts/new-project.sh @@ -1,119 +1,79 @@ #!/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 -# Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -# Default values PROJECT_NAME="${1:-}" PROJECT_PATH="${2:-.}" -# Get skill directory SKILL_DIR="$(dirname "$(dirname "$(readlink -f "$0")")")" -TEMPLATE_DIR="$SKILL_DIR/templates/nextjs-payload-starter" +TEMPLATE_DIR="$SKILL_DIR/templates/astro-tina-starter" -#------------------------------------------------------------------------------- -# Helper functions -#------------------------------------------------------------------------------- - -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" -} +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") [project-name] [project-path] - สร้าง Next.js + Payload CMS project ใหม่จาก Template +Create new Astro + Tina CMS project from template Arguments: - project-name ชื่อ project (optional) - project-path ที่อยู่ project (default: current directory) + project-name Project name (optional) + project-path Project location (default: current directory) Examples: $(basename "$0") my-website $(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 } -#------------------------------------------------------------------------------- -# Pre-flight checks -#------------------------------------------------------------------------------- - check_requirements() { - log_info "ตรวจสอบความต้องการของระบบ..." + log_info "Checking requirements..." - # Check git if ! command -v git &> /dev/null; then - log_error "git ไม่พบ กรุณาติดตั้ง git ก่อน" + log_error "git not found" exit 1 fi - # Check node if ! command -v node &> /dev/null; then - log_error "node.js ไม่พบ กรุณาติดตั้ง node.js ก่อน" + log_error "node.js not found" exit 1 fi NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) 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 fi - # Check npm if ! command -v npm &> /dev/null; then - log_error "npm ไม่พบ กรุณาติดตั้ง npm ก่อน" + log_error "npm not found" exit 1 fi - # Check template exists if [ ! -d "$TEMPLATE_DIR" ]; then - log_error "ไม่พบ Next.js Payload Starter Template: $TEMPLATE_DIR" + log_error "Template not found: $TEMPLATE_DIR" exit 1 fi - log_success "ความต้องการของระบบผ่าน (git, node $(node -v), npm)" + log_success "Requirements OK (git, node $(node -v), npm)" } -#------------------------------------------------------------------------------- -# Create project directory -#------------------------------------------------------------------------------- - setup_directory() { local actual_project_path="$PROJECT_PATH" @@ -121,13 +81,11 @@ setup_directory() { actual_project_path="$PROJECT_PATH/$PROJECT_NAME" fi - # Create directory mkdir -p "$actual_project_path" - # Check if directory is empty if [ "$(ls -A "$actual_project_path" | wc -l)" -gt 0 ]; then - log_warning "Directory ไม่ว่าง: $actual_project_path" - read -p "ดำเนินต่อ? (y/n): " -n 1 -r + log_warning "Directory not empty: $actual_project_path" + read -p "Continue? (y/n): " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then exit 1 @@ -138,32 +96,37 @@ setup_directory() { log_info "Project path: $PROJECT_PATH" } -#------------------------------------------------------------------------------- -# 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/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 - 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 เสร็จสมบูรณ์" + log_success "Template copied" } -#------------------------------------------------------------------------------- -# Copy legal templates -#------------------------------------------------------------------------------- +copy_consent_system() { + 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() { - log_info "คัดลอก PDPA templates..." + log_info "Copying PDPA legal templates..." mkdir -p "$PROJECT_PATH/src/content/pages" @@ -175,168 +138,92 @@ copy_legal_templates() { cp "$SKILL_DIR/templates/terms-of-service.md" "$PROJECT_PATH/src/content/pages/" fi - log_success "คัดลอก PDPA templates เสร็จสมบูรณ์" + log_success "Legal templates copied" } -#------------------------------------------------------------------------------- -# Install dependencies -#------------------------------------------------------------------------------- - install_dependencies() { - log_info "ติดตั้ง dependencies..." + log_info "Installing dependencies..." cd "$PROJECT_PATH" npm install - log_success "ติดตั้ง dependencies เสร็จสมบูรณ์" + log_success "Dependencies installed" } -#------------------------------------------------------------------------------- -# Setup environment -#------------------------------------------------------------------------------- - setup_environment() { - log_info "ตั้งค่า environment..." + log_info "Setting up environment..." cd "$PROJECT_PATH" if [ ! -f ".env" ]; then if [ -f ".env.example" ]; then cp .env.example .env - log_success "สร้าง .env จาก .env.example" - log_warning "กรุณาแก้ไข .env และใส่ DATABASE_URL ที่ถูกต้อง" + log_success "Created .env from .env.example" else cat > .env << 'EOF' -# Payload CMS -PAYLOAD_SECRET=change-this-secret-key-at-least-32-characters -DATABASE_URL=postgresql://user:password@localhost:5432/mydb - -# Server -SERVER_URL=http://localhost:4321 -NODE_ENV=development +PUBLIC_SITE_URL=http://localhost:4321 +TINA_TOKEN=your-tina-token EOF - log_success "สร้าง .env เริ่มต้น" - log_warning "กรุณาแก้ไข .env และใส่ DATABASE_URL ที่ถูกต้อง" + log_success "Created default .env" fi - else - log_info ".env มีอยู่แล้ว" 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() { - log_info "เริ่มต้น git..." + log_info "Initializing git..." cd "$PROJECT_PATH" if [ ! -d ".git" ]; then git init git add . - git commit -m "Initial commit: Next.js + Payload CMS starter" - log_success "เริ่มต้น git เสร็จสมบูรณ์" - else - log_info "git repo มีอยู่แล้ว" + git commit -m "Initial commit: Astro + Tina CMS starter" + log_success "Git initialized" fi } -#------------------------------------------------------------------------------- -# Show project structure -#------------------------------------------------------------------------------- - show_structure() { - log_info "โครงสร้าง project:" + log_info "Project structure:" cd "$PROJECT_PATH" 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 } -#------------------------------------------------------------------------------- -# Main -#------------------------------------------------------------------------------- - main() { echo "==============================================" - echo " Next.js + Payload CMS Project Creator" - echo " Using Next.js Payload Starter" + echo " Astro + Tina CMS Project Creator" echo "==============================================" echo "" - # Parse arguments if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then print_usage exit 0 fi - # Run steps check_requirements setup_directory copy_template + copy_consent_system copy_legal_templates install_dependencies setup_environment - create_ai_rules init_git show_structure echo "" echo "==============================================" - log_success "สร้าง Next.js + Payload CMS project เสร็จสมบูรณ์!" + log_success "Project created successfully!" echo "==============================================" echo "" - echo "ขั้นตอนถัดไป:" + echo "Next steps:" echo " 1. cd $PROJECT_PATH" - echo " 2. แก้ไข .env (MONGODB_URL, PAYLOAD_SECRET)" - echo " 3. npm install" - echo " 4. npm run dev" - echo " 5. เปิด http://localhost:3002/admin สำหรับ Payload admin" + echo " 2. npm run dev" + echo " 3. Open http://localhost:4321" + echo "" + echo "For Tina CMS admin:" + echo " - npm run dev" + echo " - Visit http://localhost:4321/admin" echo "" } -main "$@" +main "$@" \ No newline at end of file diff --git a/skills/website-creator/templates/astro-tina-starter/.tina/config.ts b/skills/website-creator/templates/astro-tina-starter/.tina/config.ts new file mode 100644 index 0000000..21faba4 --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/.tina/config.ts @@ -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', + }, + }, +}); \ No newline at end of file diff --git a/skills/website-creator/templates/astro-tina-starter/.tina/schema.ts b/skills/website-creator/templates/astro-tina-starter/.tina/schema.ts new file mode 100644 index 0000000..08dab72 --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/.tina/schema.ts @@ -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'], + }, + ], + }, + ], +}) diff --git a/skills/website-creator/templates/astro-tina-starter/AGENTS.md b/skills/website-creator/templates/astro-tina-starter/AGENTS.md new file mode 100644 index 0000000..08ec486 --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/AGENTS.md @@ -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 diff --git a/skills/website-creator/templates/astro-tina-starter/Dockerfile b/skills/website-creator/templates/astro-tina-starter/Dockerfile new file mode 100644 index 0000000..6e7980d --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/skills/website-creator/templates/astro-tina-starter/README.md b/skills/website-creator/templates/astro-tina-starter/README.md new file mode 100644 index 0000000..6fc684d --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/README.md @@ -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 diff --git a/skills/website-creator/templates/astro-tina-starter/astro.config.mjs b/skills/website-creator/templates/astro-tina-starter/astro.config.mjs new file mode 100644 index 0000000..f8210ce --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/astro.config.mjs @@ -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, + }, +}) \ No newline at end of file diff --git a/skills/website-creator/templates/astro-tina-starter/db/config.ts b/skills/website-creator/templates/astro-tina-starter/db/config.ts new file mode 100644 index 0000000..0ebd67d --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/db/config.ts @@ -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, + }, +}); \ No newline at end of file diff --git a/skills/website-creator/templates/astro-tina-starter/db/seed.ts b/skills/website-creator/templates/astro-tina-starter/db/seed.ts new file mode 100644 index 0000000..b9522f6 --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/db/seed.ts @@ -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') +} diff --git a/skills/website-creator/templates/astro-tina-starter/package.json b/skills/website-creator/templates/astro-tina-starter/package.json new file mode 100644 index 0000000..37a94de --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/package.json @@ -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" + } +} diff --git a/skills/website-creator/templates/astro-tina-starter/public/favicon.svg b/skills/website-creator/templates/astro-tina-starter/public/favicon.svg new file mode 100644 index 0000000..5cc28de --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + A + diff --git a/skills/website-creator/templates/astro-tina-starter/src/components/Header.astro b/skills/website-creator/templates/astro-tina-starter/src/components/Header.astro new file mode 100644 index 0000000..163a664 --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/src/components/Header.astro @@ -0,0 +1,27 @@ +--- +interface Props { + siteName?: string +} + +const { siteName = "Astro Tina Starter" } = Astro.props +--- + +
+ +
diff --git a/skills/website-creator/templates/astro-tina-starter/src/content/config.ts b/skills/website-creator/templates/astro-tina-starter/src/content/config.ts new file mode 100644 index 0000000..7bf9732 --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/src/content/config.ts @@ -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, +} diff --git a/skills/website-creator/templates/astro-tina-starter/src/content/posts/welcome.mdx b/skills/website-creator/templates/astro-tina-starter/src/content/posts/welcome.mdx new file mode 100644 index 0000000..2fd8ebe --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/src/content/posts/welcome.mdx @@ -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! diff --git a/skills/website-creator/templates/astro-tina-starter/src/content/settings/site.json b/skills/website-creator/templates/astro-tina-starter/src/content/settings/site.json new file mode 100644 index 0000000..8192b18 --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/src/content/settings/site.json @@ -0,0 +1,5 @@ +{ + "siteName": "Astro Tina Starter", + "siteDescription": "Astro 6 + Tina CMS starter template with Thai language support", + "language": "th" +} diff --git a/skills/website-creator/templates/astro-tina-starter/src/layouts/Layout.astro b/skills/website-creator/templates/astro-tina-starter/src/layouts/Layout.astro new file mode 100644 index 0000000..1428be4 --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/src/layouts/Layout.astro @@ -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 +--- + + + + + + + + + {title} + + + + + diff --git a/skills/website-creator/templates/astro-tina-starter/src/pages/index.astro b/skills/website-creator/templates/astro-tina-starter/src/pages/index.astro new file mode 100644 index 0000000..fa84050 --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/src/pages/index.astro @@ -0,0 +1,47 @@ +--- +import Layout from "@/layouts/Layout.astro" +--- + + +
+
+

+ Welcome to Astro Tina Starter +

+

+ A modern starter template with Astro 6, Tina CMS, Tailwind CSS 4.x, + and Thai language support. +

+ +
+
+

Tina CMS

+

+ Self-hosted content management with schema-based editing. +

+
+ +
+

Tailwind v4

+

+ Latest Tailwind CSS with @tailwindcss/vite plugin. +

+
+ +
+

Astro DB

+

+ Built-in database for consent logging and more. +

+
+ +
+

Thai Support

+

+ Ready for Thai language content with Noto Sans Thai. +

+
+
+
+
+
diff --git a/skills/website-creator/templates/astro-tina-starter/src/styles/global.css b/skills/website-creator/templates/astro-tina-starter/src/styles/global.css new file mode 100644 index 0000000..4247097 --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/src/styles/global.css @@ -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); +} diff --git a/skills/website-creator/templates/astro-tina-starter/tsconfig.json b/skills/website-creator/templates/astro-tina-starter/tsconfig.json new file mode 100644 index 0000000..3a40daf --- /dev/null +++ b/skills/website-creator/templates/astro-tina-starter/tsconfig.json @@ -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"] +} diff --git a/skills/website-creator/templates/consent/ConsentBanner.astro b/skills/website-creator/templates/consent/ConsentBanner.astro new file mode 100644 index 0000000..9b87291 --- /dev/null +++ b/skills/website-creator/templates/consent/ConsentBanner.astro @@ -0,0 +1,447 @@ +--- +/** + * PDPA Consent Banner Component for Astro + Tina + * Replaces cookie-banner.tsx from Next.js+Payload + * + * Usage: Import and add to your layout + */ + +interface Props { + /** Optional: Custom privacy policy URL */ + privacyPolicyUrl?: string; +} + +const { privacyPolicyUrl = "/privacy-policy" } = Astro.props; +--- + + + + + + \ No newline at end of file diff --git a/skills/website-creator/templates/consent/README.md b/skills/website-creator/templates/consent/README.md index 46afb2c..c3a46dc 100644 --- a/skills/website-creator/templates/consent/README.md +++ b/skills/website-creator/templates/consent/README.md @@ -1,61 +1,70 @@ # PDPA Consent Logging Template -Template สำหรับเพิ่ม PDPA consent logging ใน Next.js + Payload CMS (MongoDB) +Template สำหรับเพิ่ม PDPA consent logging ใน Astro + Tina (Astro DB) ## Files ``` consent/ -├── collections/ -│ └── ConsentLogs.ts # Payload collection สำหรับ consent logs +├── ConsentBanner.astro # Consent banner component ├── api/ -│ └── route.ts # API endpoint สำหรับบันทึก consent -├── cookie-banner.tsx # CookieBanner component -└── README.md +│ └── consent.ts # API endpoints (GET, POST, DELETE) +├── db/ +│ └── 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 -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 -เพิ่ม `` ใน `src/app/(frontend)/layout.tsx`: +เพิ่ม `` ใน `src/layouts/Layout.astro`: -```tsx -import { CookieBanner } from '@/components/cookie-banner' +```astro +--- +import ConsentBanner from '../components/consent/ConsentBanner.astro'; +--- -export default function RootLayout({ children }) { - return ( - - - {children} - - - - ) -} -``` - -### 5. เพิ่ม Collection ใน payload.config.ts - -```ts -import ConsentLogs from './collections/ConsentLogs' - -export default buildConfig({ - collections: [Users, Media, Snacks, Orders, ConsentLogs], - // ... -}) + + + + + + ``` ## API @@ -80,7 +89,7 @@ export default buildConfig({ { "success": true, "doc": { - "id": "...", + "id": 1, "action": "accept", "purpose": "all", "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 สำคัญ -1. **ใช้ `mongooseAdapter` ไม่ใช่ `mongodbAdapter`** -2. **ConsentLogs ต้องใช้ `export default`** ไม่ใช่ named export +1. **Astro DB ต้องรันบน server-side** - ใช้ `APIRoute` import +2. **Nano Stores รันบน client-side** - ใช้ `