feat: migrate website-creator from Next.js+Payload to Astro+Tina CMS
Major changes: - Replace Payload CMS with Tina CMS (self-hosted) - Add Astro DB for consent logging (PDPA compliant) - Update Tailwind v3 to v4 (@tailwindcss/vite plugin) - Add astro-tina-starter template - Rewrite consent template for Astro (ConsentBanner.astro, Astro DB, Nano Stores) - Add install-tina-backend.sh for self-hosted Tina per customer - Rename convert-astro.sh to migrate-tina.sh - Add AGENTS.md template for generated websites - Delete all Payload/Next.js files Technical updates: - Astro DB using defineDb with eq operators for queries - Tailwind v4 with @theme block - Tina CMS local development mode - Proper Astro API routes for consent Research-verified with official documentation (April 2026)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'tinacms';
|
||||
import { schema } from './schema';
|
||||
|
||||
export default defineConfig({
|
||||
schema,
|
||||
ui: {
|
||||
navigation: {
|
||||
'content/posts': { label: 'Posts' },
|
||||
'content/pages': { label: 'Pages' },
|
||||
},
|
||||
},
|
||||
media: {
|
||||
tina: {
|
||||
publicFolder: 'public',
|
||||
mediaRoot: 'uploads',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { defineSchema } from 'tinacms'
|
||||
|
||||
export const schema = defineSchema({
|
||||
collections: [
|
||||
{
|
||||
name: 'post',
|
||||
label: 'Posts',
|
||||
path: 'src/content/posts',
|
||||
format: 'mdx',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
label: 'Title',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'description',
|
||||
label: 'Description',
|
||||
},
|
||||
{
|
||||
type: 'datetime',
|
||||
name: 'publishedAt',
|
||||
label: 'Published At',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'category',
|
||||
label: 'Category',
|
||||
options: ['news', 'blog', 'tutorial'],
|
||||
},
|
||||
{
|
||||
type: 'rich-text',
|
||||
name: 'body',
|
||||
label: 'Body',
|
||||
isBody: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'page',
|
||||
label: 'Pages',
|
||||
path: 'src/content/pages',
|
||||
format: 'mdx',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
label: 'Title',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'description',
|
||||
label: 'Description',
|
||||
},
|
||||
{
|
||||
type: 'rich-text',
|
||||
name: 'body',
|
||||
label: 'Body',
|
||||
isBody: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
label: 'Settings',
|
||||
path: 'src/content/settings',
|
||||
format: 'json',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'siteName',
|
||||
label: 'Site Name',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'siteDescription',
|
||||
label: 'Site Description',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'language',
|
||||
label: 'Language',
|
||||
options: ['th', 'en', 'th-en'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
198
skills/website-creator/templates/astro-tina-starter/AGENTS.md
Normal file
198
skills/website-creator/templates/astro-tina-starter/AGENTS.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Astro Tina Starter - Agent Knowledge Base
|
||||
|
||||
**Generated:** 2026-04-17
|
||||
**Version:** 1.0.0
|
||||
**Type:** Astro 6 + Tina CMS Starter Template
|
||||
|
||||
---
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Starter template for building websites with Astro 6, Tina CMS, and Tailwind CSS 4.x.
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Component | Technology | Version |
|
||||
|-----------|------------|---------|
|
||||
| Framework | Astro | 6.1.7 |
|
||||
| CMS | Tina CMS | 2.x |
|
||||
| Styling | Tailwind CSS | 4.x |
|
||||
| Database | Astro DB | 0.14.x |
|
||||
| State | Nano Stores | 0.11.x |
|
||||
|
||||
### Key Features
|
||||
|
||||
- Self-hosted Tina CMS with schema-based content
|
||||
- Tailwind CSS 4.x using `@tailwindcss/vite` plugin
|
||||
- Astro DB for consent logging (PDPA compliant)
|
||||
- Thai language support with Noto Sans Thai
|
||||
- Docker-ready deployment
|
||||
|
||||
---
|
||||
|
||||
## PROJECT STRUCTURE
|
||||
|
||||
```
|
||||
astro-tina-starter/
|
||||
├── .tina/
|
||||
│ ├── config.ts # Tina CMS configuration
|
||||
│ └── schema.ts # Content schema definitions
|
||||
├── db/
|
||||
│ ├── config.ts # Astro DB schema
|
||||
│ └── seed.ts # Database seed script
|
||||
├── src/
|
||||
│ ├── styles/
|
||||
│ │ └── global.css # Tailwind v4 styles + @theme
|
||||
│ ├── layouts/
|
||||
│ │ └── Layout.astro
|
||||
│ ├── pages/
|
||||
│ │ └── index.astro
|
||||
│ ├── components/
|
||||
│ │ └── Header.astro
|
||||
│ └── content/
|
||||
│ ├── config.ts # Astro content collections
|
||||
│ ├── posts/ # Blog posts (MDX)
|
||||
│ ├── pages/ # Static pages (MDX)
|
||||
│ └── settings/ # Site settings (JSON)
|
||||
├── public/
|
||||
│ └── favicon.svg
|
||||
├── Dockerfile
|
||||
├── astro.config.mjs
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IMPORTANT CONVENTIONS
|
||||
|
||||
### Tailwind CSS 4.x Setup
|
||||
|
||||
**CRITICAL:** This template uses `@tailwindcss/vite` plugin, NOT `@astrojs/tailwind`.
|
||||
|
||||
```javascript
|
||||
// astro.config.mjs
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```css
|
||||
/* src/styles/global.css */
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #1a1a1a;
|
||||
--color-accent: #3b82f6;
|
||||
}
|
||||
```
|
||||
|
||||
### Tina CMS Content
|
||||
|
||||
Tina CMS manages content in `src/content/`:
|
||||
- `posts/` - Blog posts (MDX format)
|
||||
- `pages/` - Static pages (MDX format)
|
||||
- `settings/` - Site settings (JSON format)
|
||||
|
||||
Schema defined in `.tina/schema.ts`.
|
||||
|
||||
### Astro DB Schema
|
||||
|
||||
Consent log table for PDPA compliance in `db/config.ts`.
|
||||
|
||||
---
|
||||
|
||||
## CREDENTIALS
|
||||
|
||||
No external API credentials required for this template.
|
||||
|
||||
### Optional Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TINA_TOKEN` | Tina CMS production authentication |
|
||||
| `TINA_CLIENT_ID` | Tina CMS client ID |
|
||||
| `DATABASE_URL` | Custom database connection (optional) |
|
||||
|
||||
---
|
||||
|
||||
## COMMANDS
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Development
|
||||
npm run dev # Full dev (Tina + Astro)
|
||||
npm run dev:astro # Astro only
|
||||
npm run dev:tina # Tina CMS only
|
||||
|
||||
# Build
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
|
||||
# Database
|
||||
npm run db:push # Push schema to database
|
||||
npm run db:seed # Seed database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PDPA COMPLIANCE
|
||||
|
||||
Template includes consent logging via Astro DB:
|
||||
|
||||
```typescript
|
||||
// db/config.ts
|
||||
export const ConsentLog = defineTable({
|
||||
columns: {
|
||||
action: text(),
|
||||
purpose: text(),
|
||||
analytics: boolean(),
|
||||
marketing: boolean(),
|
||||
functional: boolean(),
|
||||
userAgent: text(),
|
||||
ip: text(),
|
||||
timestamp: text(),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **NEVER** use `@astrojs/tailwind` (deprecated)
|
||||
- **ALWAYS** use `@tailwindcss/vite` for Tailwind v4
|
||||
- **NEVER** commit environment files (.env)
|
||||
|
||||
---
|
||||
|
||||
## DEPLOYMENT
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t astro-tina-starter .
|
||||
docker run -p 8080:80 astro-tina-starter
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
# Serve dist/ folder with any static server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NOTES
|
||||
|
||||
- Tina CMS admin: http://localhost:4321/admin
|
||||
- Astro default port: 4321
|
||||
- Tina dev server: 3001
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
104
skills/website-creator/templates/astro-tina-starter/README.md
Normal file
104
skills/website-creator/templates/astro-tina-starter/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Astro Tina Starter
|
||||
|
||||
Astro 6.1.7 + Tina CMS starter template with Tailwind CSS 4.x
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Astro 6.1.7
|
||||
- **CMS:** Tina CMS (self-hosted)
|
||||
- **Styling:** Tailwind CSS 4.x with `@tailwindcss/vite`
|
||||
- **Database:** Astro DB (LibSQL)
|
||||
- **State:** Nano Stores + React
|
||||
- **Language:** TypeScript
|
||||
|
||||
## Features
|
||||
|
||||
- Self-hosted Tina CMS with schema-based content
|
||||
- Tailwind CSS 4.x using `@tailwindcss/vite` plugin
|
||||
- Astro DB for consent logging (PDPA compliant)
|
||||
- Nano Stores for client-side state management
|
||||
- Thai language support foundation
|
||||
- Docker-ready deployment
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Tina CMS Access
|
||||
|
||||
During development, access Tina CMS at:
|
||||
- http://localhost:4321/admin
|
||||
|
||||
For production, you'll need a TINA_TOKEN environment variable.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
astro-tina-starter/
|
||||
├── .tina/
|
||||
│ ├── config.ts # Tina CMS configuration
|
||||
│ └── schema.ts # Content schema definitions
|
||||
├── db/
|
||||
│ ├── config.ts # Astro DB schema (consent logs)
|
||||
│ └── seed.ts # Database seed script
|
||||
├── src/
|
||||
│ ├── styles/
|
||||
│ │ └── global.css # Tailwind v4 styles
|
||||
│ ├── layouts/
|
||||
│ │ └── Layout.astro
|
||||
│ ├── pages/
|
||||
│ │ └── index.astro
|
||||
│ ├── components/
|
||||
│ │ └── Header.astro
|
||||
│ └── content/
|
||||
│ └── config.ts # Tina content collections
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Tailwind CSS 4.x
|
||||
|
||||
This template uses Tailwind CSS 4.x with the `@tailwindcss/vite` plugin.
|
||||
The configuration is done via CSS `@theme` block in `src/styles/global.css`.
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #1a1a1a;
|
||||
--color-accent: #3b82f6;
|
||||
}
|
||||
```
|
||||
|
||||
## Astro DB
|
||||
|
||||
The template includes a consent-log table for PDPA compliance:
|
||||
|
||||
```ts
|
||||
// db/config.ts
|
||||
export const ConsentLog = defineTable({
|
||||
columns: {
|
||||
action: text(),
|
||||
purpose: text(),
|
||||
analytics: boolean(),
|
||||
marketing: boolean(),
|
||||
functional: boolean(),
|
||||
userAgent: text(),
|
||||
ip: text(),
|
||||
timestamp: text(),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import tina from 'tinacms'
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
tina({
|
||||
enabled: !!process.env.TINA_TOKEN,
|
||||
sidebar: {
|
||||
partials: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@components': path.resolve(__dirname, './src/components'),
|
||||
'@layouts': path.resolve(__dirname, './src/layouts'),
|
||||
'@styles': path.resolve(__dirname, './src/styles'),
|
||||
'@content': path.resolve(__dirname, './src/content'),
|
||||
},
|
||||
},
|
||||
},
|
||||
output: 'static',
|
||||
build: {
|
||||
assets: '_assets',
|
||||
},
|
||||
server: {
|
||||
port: 4321,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineDb, defineTable, column } from 'astro:db';
|
||||
|
||||
const ConsentLog = defineTable({
|
||||
columns: {
|
||||
id: column.number({ primaryKey: true }),
|
||||
action: column.text(),
|
||||
purpose: column.text(),
|
||||
analytics: column.boolean({ default: false }),
|
||||
marketing: column.boolean({ default: false }),
|
||||
functional: column.boolean({ default: false }),
|
||||
userAgent: column.text({ optional: true }),
|
||||
ip: column.text({ optional: true }),
|
||||
timestamp: column.date(),
|
||||
sessionId: column.text({ optional: true }),
|
||||
},
|
||||
});
|
||||
|
||||
export default defineDb({
|
||||
tables: {
|
||||
ConsentLog,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { db } from 'astro:db'
|
||||
import { sql } from 'astro/db'
|
||||
|
||||
export default async function seed() {
|
||||
// Seed default settings if needed
|
||||
console.log('Database seeded successfully')
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "astro-tina-starter",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "Astro 6 + Tina CMS starter template with Tailwind CSS 4.x",
|
||||
"scripts": {
|
||||
"dev": "tinacms dev --port 3001 & astro dev",
|
||||
"dev:astro": "astro dev",
|
||||
"dev:tina": "tinacms dev --port 3001",
|
||||
"build": "tinacms build && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"db:push": "astro db push",
|
||||
"db:seed": "astro db seed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/db": "^0.14.3",
|
||||
"@nanostores/react": "^0.7.3",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"astro": "^6.1.7",
|
||||
"nanostores": "^0.11.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tina": "^2.1.4",
|
||||
"tinacms": "^2.2.4",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3b82f6"/>
|
||||
<stop offset="100%" style="stop-color:#1d4ed8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="6" fill="url(#grad)"/>
|
||||
<text x="16" y="22" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white" text-anchor="middle">A</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,27 @@
|
||||
---
|
||||
interface Props {
|
||||
siteName?: string
|
||||
}
|
||||
|
||||
const { siteName = "Astro Tina Starter" } = Astro.props
|
||||
---
|
||||
|
||||
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-primary-200">
|
||||
<nav class="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<a href="/" class="font-bold text-xl text-primary-900 hover:text-accent-600 transition-colors">
|
||||
{siteName}
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="/" class="text-primary-600 hover:text-primary-900 transition-colors">
|
||||
Home
|
||||
</a>
|
||||
<a href="/blog" class="text-primary-600 hover:text-primary-900 transition-colors">
|
||||
Blog
|
||||
</a>
|
||||
<a href="/about" class="text-primary-600 hover:text-primary-900 transition-colors">
|
||||
About
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineCollection, z } from "astro:content"
|
||||
|
||||
const postCollection = defineCollection({
|
||||
type: "content",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
publishedAt: z.date().optional(),
|
||||
category: z.enum(["news", "blog", "tutorial"]).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const pageCollection = defineCollection({
|
||||
type: "content",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const settingsCollection = defineCollection({
|
||||
type: "data",
|
||||
schema: z.object({
|
||||
siteName: z.string(),
|
||||
siteDescription: z.string(),
|
||||
language: z.enum(["th", "en", "th-en"]).default("th"),
|
||||
}),
|
||||
})
|
||||
|
||||
export const collections = {
|
||||
posts: postCollection,
|
||||
pages: pageCollection,
|
||||
settings: settingsCollection,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: Welcome to Astro Tina Starter
|
||||
description: A modern starter template with Astro 6, Tina CMS, and Thai language support.
|
||||
publishedAt: 2026-04-17
|
||||
category: blog
|
||||
---
|
||||
|
||||
Welcome to our new blog built with Astro and Tina CMS!
|
||||
|
||||
## Features
|
||||
|
||||
- **Tina CMS** - Self-hosted content management
|
||||
- **Tailwind CSS v4** - Latest styling with @tailwindcss/vite
|
||||
- **Astro DB** - Built-in database support
|
||||
- **Thai Support** - Ready for Thai language content
|
||||
|
||||
Stay tuned for more updates!
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"siteName": "Astro Tina Starter",
|
||||
"siteDescription": "Astro 6 + Tina CMS starter template with Thai language support",
|
||||
"language": "th"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import "@/styles/global.css"
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const {
|
||||
title = "Astro Tina Starter",
|
||||
description = "Astro 6 + Tina CMS starter template",
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="th">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="bg-primary-50 text-primary-900 min-h-screen">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<main>
|
||||
<section class="px-6 py-24 max-w-4xl mx-auto">
|
||||
<h1 class="text-4xl md:text-5xl font-bold tracking-tight mb-6">
|
||||
Welcome to Astro Tina Starter
|
||||
</h1>
|
||||
<p class="text-lg text-primary-600 mb-8 max-w-2xl">
|
||||
A modern starter template with Astro 6, Tina CMS, Tailwind CSS 4.x,
|
||||
and Thai language support.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="p-6 bg-white rounded-xl border border-primary-200">
|
||||
<h2 class="text-xl font-semibold mb-3">Tina CMS</h2>
|
||||
<p class="text-primary-600">
|
||||
Self-hosted content management with schema-based editing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-white rounded-xl border border-primary-200">
|
||||
<h2 class="text-xl font-semibold mb-3">Tailwind v4</h2>
|
||||
<p class="text-primary-600">
|
||||
Latest Tailwind CSS with @tailwindcss/vite plugin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-white rounded-xl border border-primary-200">
|
||||
<h2 class="text-xl font-semibold mb-3">Astro DB</h2>
|
||||
<p class="text-primary-600">
|
||||
Built-in database for consent logging and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-white rounded-xl border border-primary-200">
|
||||
<h2 class="text-xl font-semibold mb-3">Thai Support</h2>
|
||||
<p class="text-primary-600">
|
||||
Ready for Thai language content with Noto Sans Thai.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
@@ -0,0 +1,57 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", "Noto Sans Thai", system-ui, sans-serif;
|
||||
--font-serif: "Merriweather", Georgia, serif;
|
||||
|
||||
--color-primary-50: #f8fafc;
|
||||
--color-primary-100: #f1f5f9;
|
||||
--color-primary-200: #e2e8f0;
|
||||
--color-primary-300: #cbd5e1;
|
||||
--color-primary-400: #94a3b8;
|
||||
--color-primary-500: #64748b;
|
||||
--color-primary-600: #475569;
|
||||
--color-primary-700: #334155;
|
||||
--color-primary-800: #1e293b;
|
||||
--color-primary-900: #0f172a;
|
||||
--color-primary-950: #020617;
|
||||
|
||||
--color-accent-50: #eff6ff;
|
||||
--color-accent-100: #dbeafe;
|
||||
--color-accent-200: #bfdbfe;
|
||||
--color-accent-300: #93c5fd;
|
||||
--color-accent-400: #60a5fa;
|
||||
--color-accent-500: #3b82f6;
|
||||
--color-accent-600: #2563eb;
|
||||
--color-accent-700: #1d4ed8;
|
||||
--color-accent-800: #1e40af;
|
||||
--color-accent-900: #1e3a8a;
|
||||
|
||||
--color-success-500: #22c55e;
|
||||
--color-warning-500: #f59e0b;
|
||||
--color-error-500: #ef4444;
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.5rem;
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-accent-200);
|
||||
color: var(--color-primary-900);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@components/*": ["./src/components/*"],
|
||||
"@layouts/*": ["./src/layouts/*"],
|
||||
"@styles/*": ["./src/styles/*"],
|
||||
"@content/*": ["./src/content/*"]
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
},
|
||||
"include": ["src/**/*", ".tina/**/*", "db/**/*"],
|
||||
"exclude": ["node_modules", "dist", ".astro"]
|
||||
}
|
||||
447
skills/website-creator/templates/consent/ConsentBanner.astro
Normal file
447
skills/website-creator/templates/consent/ConsentBanner.astro
Normal file
@@ -0,0 +1,447 @@
|
||||
---
|
||||
/**
|
||||
* PDPA Consent Banner Component for Astro + Tina
|
||||
* Replaces cookie-banner.tsx from Next.js+Payload
|
||||
*
|
||||
* Usage: Import and add <ConsentBanner /> to your layout
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
/** Optional: Custom privacy policy URL */
|
||||
privacyPolicyUrl?: string;
|
||||
}
|
||||
|
||||
const { privacyPolicyUrl = "/privacy-policy" } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
id="pdpa-consent-banner"
|
||||
class="consent-banner"
|
||||
role="dialog"
|
||||
aria-label="Cookie Consent Banner"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="consent-banner__content">
|
||||
<!-- Main Banner -->
|
||||
<div id="consent-main" class="consent-banner__main">
|
||||
<h3 class="consent-banner__title">
|
||||
🍪 การยินยอมตาม พ.ร.บ.คุ้มครองข้อมูลส่วนบุคคล
|
||||
</h3>
|
||||
<p class="consent-banner__text">
|
||||
เราใช้คุกกี้เพื่อปรับปรุงประสบการณ์การใช้งานเว็บไซต์ของคุณ การเข้าชมเว็บไซต์ต่อถือว่าคุณยินยอมให้เราใช้คุกกี้{' '}
|
||||
<a href={privacyPolicyUrl} class="consent-banner__link">เรียนรู้เพิ่มเติม</a>
|
||||
</p>
|
||||
|
||||
<div class="consent-banner__buttons">
|
||||
<button
|
||||
id="consent-accept-all"
|
||||
class="consent-btn consent-btn--accept"
|
||||
type="button"
|
||||
>
|
||||
ยอมรับทั้งหมด
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="consent-reject-all"
|
||||
class="consent-btn consent-btn--reject"
|
||||
type="button"
|
||||
>
|
||||
ปฏิเสธทั้งหมด
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="consent-show-preferences"
|
||||
class="consent-btn consent-btn--preferences"
|
||||
type="button"
|
||||
>
|
||||
ตั้งค่าคุกกี้
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preferences Panel -->
|
||||
<div id="consent-preferences" class="consent-banner__preferences" style="display: none;">
|
||||
<h3 class="consent-banner__title">ตั้งค่าคุกกี้</h3>
|
||||
|
||||
<p class="consent-banner__text" style="margin-bottom: 1rem; color: #555; font-size: 0.875rem;">
|
||||
จัดการการตั้งค่าคุกกี้ของคุณด้านล่าง
|
||||
</p>
|
||||
|
||||
<div class="consent-banner__options">
|
||||
<!-- Functional Cookies -->
|
||||
<div class="consent-option consent-option--disabled">
|
||||
<div class="consent-option__header">
|
||||
<div>
|
||||
<h4 class="consent-option__title">คุกกี้ที่จำเป็น</h4>
|
||||
<p class="consent-option__desc">
|
||||
จำเป็นสำหรับการทำงานของเว็บไซต์ ไม่สามารปิดได้
|
||||
</p>
|
||||
</div>
|
||||
<span class="consent-option__badge">เปิดอยู่เสมอ</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Cookies -->
|
||||
<div class="consent-option">
|
||||
<div class="consent-option__header">
|
||||
<div>
|
||||
<h4 class="consent-option__title">คุกกี้วิเคราะห์</h4>
|
||||
<p class="consent-option__desc">
|
||||
ช่วยเราเข้าใจว่าผู้เยี่ยมชมใช้งานเว็บไซต์ของเราอย่างไร
|
||||
</p>
|
||||
</div>
|
||||
<label class="consent-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="consent-analytics"
|
||||
name="analytics"
|
||||
class="consent-checkbox__input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketing Cookies -->
|
||||
<div class="consent-option">
|
||||
<div class="consent-option__header">
|
||||
<div>
|
||||
<h4 class="consent-option__title">คุกกี้การตลาด</h4>
|
||||
<p class="consent-option__desc">
|
||||
ใช้ติดตามผู้เยี่ยมชมข้ามเว็บไซต์เพื่อการโฆษณา
|
||||
</p>
|
||||
</div>
|
||||
<label class="consent-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="consent-marketing"
|
||||
name="marketing"
|
||||
class="consent-checkbox__input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="consent-banner__buttons">
|
||||
<button
|
||||
id="consent-save-preferences"
|
||||
class="consent-btn consent-btn--save"
|
||||
type="button"
|
||||
>
|
||||
บันทึกการตั้งค่า
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="consent-back"
|
||||
class="consent-btn consent-btn--back"
|
||||
type="button"
|
||||
>
|
||||
กลับ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.consent-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
|
||||
padding: 1.5rem;
|
||||
z-index: 9999;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.consent-banner__content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.consent-banner__title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.consent-banner__text {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #555;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.consent-banner__link {
|
||||
color: #0066cc;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.consent-banner__link:hover {
|
||||
color: #004499;
|
||||
}
|
||||
|
||||
.consent-banner__buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.consent-btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.consent-btn--accept {
|
||||
background-color: #22c55e;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.consent-btn--accept:hover {
|
||||
background-color: #16a34a;
|
||||
}
|
||||
|
||||
.consent-btn--reject {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.consent-btn--reject:hover {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.consent-btn--preferences {
|
||||
background-color: transparent;
|
||||
color: #0066cc;
|
||||
border: 1px solid #0066cc;
|
||||
}
|
||||
|
||||
.consent-btn--preferences:hover {
|
||||
background-color: #f0f9ff;
|
||||
}
|
||||
|
||||
.consent-btn--save {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.consent-btn--save:hover {
|
||||
background-color: #004499;
|
||||
}
|
||||
|
||||
.consent-btn--back {
|
||||
background-color: transparent;
|
||||
color: #666;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.consent-btn--back:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Preferences Panel */
|
||||
.consent-banner__options {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.consent-option {
|
||||
padding: 1rem;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.consent-option--disabled {
|
||||
background-color: #f9f9f9;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.consent-option__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.consent-option__title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.consent-option__desc {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.consent-option__badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: #e5e5e5;
|
||||
color: #666;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.consent-checkbox__input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hide initially via JS */
|
||||
.consent-banner[hidden] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { consentStore, type ConsentState } from './stores/consent';
|
||||
|
||||
// DOM Elements
|
||||
const banner = document.getElementById('pdpa-consent-banner');
|
||||
const mainPanel = document.getElementById('consent-main');
|
||||
const prefsPanel = document.getElementById('consent-preferences');
|
||||
const analyticsCheckbox = document.getElementById('consent-analytics') as HTMLInputElement;
|
||||
const marketingCheckbox = document.getElementById('consent-marketing') as HTMLInputElement;
|
||||
|
||||
// Button handlers
|
||||
const acceptAllBtn = document.getElementById('consent-accept-all');
|
||||
const rejectAllBtn = document.getElementById('consent-reject-all');
|
||||
const showPrefsBtn = document.getElementById('consent-show-preferences');
|
||||
const savePrefsBtn = document.getElementById('consent-save-preferences');
|
||||
const backBtn = document.getElementById('consent-back');
|
||||
|
||||
// Default consent state
|
||||
const defaultConsent: ConsentState = {
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
hasConsented: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'pdpa_consent';
|
||||
|
||||
// Save consent to localStorage and server
|
||||
async function saveConsent(newConsent: ConsentState) {
|
||||
// Save to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newConsent));
|
||||
|
||||
// Update nanostore
|
||||
consentStore.set(newConsent);
|
||||
|
||||
// Hide banner
|
||||
if (banner) {
|
||||
banner.setAttribute('hidden', 'true');
|
||||
}
|
||||
|
||||
// Log to server
|
||||
try {
|
||||
await fetch('/api/consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: newConsent.hasConsented ? 'accept' : 'reject',
|
||||
purpose: 'all',
|
||||
analytics: newConsent.analytics,
|
||||
marketing: newConsent.marketing,
|
||||
functional: newConsent.functional,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to log consent:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Accept all cookies
|
||||
acceptAllBtn?.addEventListener('click', () => {
|
||||
saveConsent({
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
functional: true,
|
||||
hasConsented: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Reject all cookies
|
||||
rejectAllBtn?.addEventListener('click', () => {
|
||||
saveConsent({
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
hasConsented: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Show preferences panel
|
||||
showPrefsBtn?.addEventListener('click', () => {
|
||||
if (mainPanel && prefsPanel) {
|
||||
mainPanel.style.display = 'none';
|
||||
prefsPanel.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Save custom preferences
|
||||
savePrefsBtn?.addEventListener('click', () => {
|
||||
saveConsent({
|
||||
analytics: analyticsCheckbox?.checked ?? false,
|
||||
marketing: marketingCheckbox?.checked ?? false,
|
||||
functional: true, // Always on
|
||||
hasConsented: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Back to main panel
|
||||
backBtn?.addEventListener('click', () => {
|
||||
if (mainPanel && prefsPanel) {
|
||||
prefsPanel.style.display = 'none';
|
||||
mainPanel.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Check for existing consent on load
|
||||
function initBanner() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Already consented - hide banner
|
||||
if (banner) {
|
||||
banner.setAttribute('hidden', 'true');
|
||||
}
|
||||
// Sync with nanostore
|
||||
consentStore.set(parsed);
|
||||
} catch {
|
||||
// No valid consent - show banner
|
||||
if (banner) {
|
||||
banner.removeAttribute('hidden');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No consent yet - show banner
|
||||
if (banner) {
|
||||
banner.removeAttribute('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
initBanner();
|
||||
</script>
|
||||
@@ -1,61 +1,70 @@
|
||||
# PDPA Consent Logging Template
|
||||
|
||||
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
|
||||
|
||||
เพิ่ม `<CookieBanner />` ใน `src/app/(frontend)/layout.tsx`:
|
||||
เพิ่ม `<ConsentBanner />` ใน `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 (
|
||||
<html>
|
||||
<body>
|
||||
{children}
|
||||
<CookieBanner />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. เพิ่ม Collection ใน payload.config.ts
|
||||
|
||||
```ts
|
||||
import ConsentLogs from './collections/ConsentLogs'
|
||||
|
||||
export default buildConfig({
|
||||
collections: [Users, Media, Snacks, Orders, ConsentLogs],
|
||||
// ...
|
||||
})
|
||||
<html lang="th">
|
||||
<body>
|
||||
<slot />
|
||||
<ConsentBanner />
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## 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** - ใช้ `<script>` tag ใน Astro
|
||||
3. **import ถูกต้อง** - ใช้ `import { db } from 'astro:db'` ไม่ใช่ `defineDb`
|
||||
120
skills/website-creator/templates/consent/api/consent.ts
Normal file
120
skills/website-creator/templates/consent/api/consent.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db, eq } from 'astro:db';
|
||||
import { ConsentLog } from '../db/config';
|
||||
|
||||
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const {
|
||||
action = 'accept',
|
||||
purpose = 'all',
|
||||
analytics = false,
|
||||
marketing = false,
|
||||
functional = false,
|
||||
} = body;
|
||||
|
||||
const ip = clientAddress || 'unknown';
|
||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||
|
||||
const doc = await db.insert(ConsentLog).values({
|
||||
action,
|
||||
purpose,
|
||||
analytics,
|
||||
marketing,
|
||||
functional,
|
||||
ip,
|
||||
userAgent,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
doc,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Consent API error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to log consent',
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
|
||||
let docs;
|
||||
if (sessionId) {
|
||||
docs = await db.select().from(ConsentLog).where(
|
||||
eq(ConsentLog.sessionId, sessionId)
|
||||
);
|
||||
} else {
|
||||
docs = await db.select().from(ConsentLog);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
docs,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Consent GET error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to retrieve consent logs',
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'sessionId is required',
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await db.delete(ConsentLog).where(
|
||||
eq(ConsentLog.sessionId, sessionId)
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
deleted,
|
||||
message: 'All consent records for this session have been deleted',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Right to be forgotten error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to delete consent records',
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@/payload.config'
|
||||
|
||||
/**
|
||||
* DELETE /api/consent - Right to be forgotten (GDPR/PDPA)
|
||||
*
|
||||
* Deletes all consent records for a given session or user
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const payloadConfig = await config
|
||||
const payload = await getPayload({ config: payloadConfig })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Find and delete all consent logs for this session
|
||||
const result = await payload.delete({
|
||||
collection: 'consent-logs',
|
||||
where: {
|
||||
sessionId: { equals: sessionId },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deleted: result.deletedDocs?.length || 0,
|
||||
message: 'All consent records for this session have been deleted'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Right to be forgotten error:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete consent records' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { CollectionConfig, Field } from 'payload'
|
||||
|
||||
// Consent Log Collection - เก็บ log การยินยอมของ users
|
||||
export const ConsentLog: CollectionConfig = {
|
||||
slug: 'consent-logs',
|
||||
admin: {
|
||||
useAsTitle: 'sessionId',
|
||||
defaultColumns: ['sessionId', 'consentType', 'granted', 'createdAt'],
|
||||
description: 'บันทึกการยินยอมของผู้ใช้ตาม PDPA',
|
||||
},
|
||||
access: {
|
||||
// ทุกคนสามารถสร้าง log ได้ (public)
|
||||
create: () => true,
|
||||
// แต่ดูได้เฉพาะ admin
|
||||
read: ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
return user.role === 'admin'
|
||||
},
|
||||
// แก้ไขได้เฉพาะ admin
|
||||
update: ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
return user.role === 'admin'
|
||||
},
|
||||
// ลบได้เฉพาะ admin
|
||||
delete: ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
return user.role === 'admin'
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'sessionId',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Session ID ของผู้ใช้',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'consentType',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Essential', value: 'essential' },
|
||||
{ label: 'Analytics', value: 'analytics' },
|
||||
{ label: 'Marketing', value: 'marketing' },
|
||||
{ label: 'Functional', value: 'functional' },
|
||||
{ label: 'All Accepted', value: 'accept_all' },
|
||||
{ label: 'All Rejected', value: 'reject_all' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'granted',
|
||||
type: 'checkbox',
|
||||
required: true,
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'ยินยอมหรือไม่',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ipAddress',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'IP Address ของผู้ใช้',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userAgent',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Browser User Agent',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'ข้อมูลเพิ่มเติม',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
type: 'date',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'วันที่และเวลาที่ยินยอม',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ data }) => {
|
||||
// เพิ่ม timestamp อัตโนมัติ
|
||||
if (!data.createdAt) {
|
||||
data.createdAt = new Date().toISOString()
|
||||
}
|
||||
return data
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// Consent Settings Collection - เก็บ settings ของ consent banner
|
||||
export const ConsentSettings: CollectionConfig = {
|
||||
slug: 'consent-settings',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
description: 'ตั้งค่า Cookie Consent Banner',
|
||||
},
|
||||
access: {
|
||||
read: () => true, // Public read
|
||||
create: ({ req: { user } }) => !!user && user.role === 'admin',
|
||||
update: ({ req: { user } }) => !!user && user.role === 'admin',
|
||||
delete: ({ req: { user } }) => !!user && user.role === 'admin',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
defaultValue: 'นโยบายคุกกี้',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
defaultValue: 'เราใช้คุกกี้เพื่อปรับปรุงประสบการณ์การใช้งานเว็บไซต์ของคุณ คุณสามารถเลือกได้ว่าจะอนุญาตคุกกี้ประเภทใด',
|
||||
},
|
||||
{
|
||||
name: 'position',
|
||||
type: 'select',
|
||||
defaultValue: 'bottom',
|
||||
options: [
|
||||
{ label: 'ด้านล่าง (Bottom)', value: 'bottom' },
|
||||
{ label: 'ด้านบน (Top)', value: 'top' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
defaultValue: 'light',
|
||||
options: [
|
||||
{ label: 'Light Mode', value: 'light' },
|
||||
{ label: 'Dark Mode', value: 'dark' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'essentialCookies',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'รายชื่อ essential cookies ที่จำเป็นต้องมี',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'analyticsCookies',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'รายชื่อ analytics cookies',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'marketingCookies',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'รายชื่อ marketing cookies',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'functionalCookies',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'รายชื่อ functional cookies',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'isActive',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description: 'แสดง consent banner หรือไม่',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export interface ConsentLogData {
|
||||
action: 'accept' | 'reject' | 'update'
|
||||
purpose: 'analytics' | 'marketing' | 'functional' | 'all'
|
||||
userAgent?: string
|
||||
ip?: string
|
||||
timestamp: string
|
||||
previousConsent?: Record<string, boolean>
|
||||
newConsent?: Record<string, boolean>
|
||||
}
|
||||
|
||||
const ConsentLogs: CollectionConfig = {
|
||||
slug: 'consent-logs',
|
||||
admin: {
|
||||
useAsTitle: 'timestamp',
|
||||
defaultColumns: ['timestamp', 'action', 'purpose', 'ip'],
|
||||
description: 'Log of all consent actions for PDPA compliance',
|
||||
},
|
||||
access: {
|
||||
create: () => true, // Allow anyone to create consent logs (public endpoint)
|
||||
read: () => true, // Allow reading for compliance purposes
|
||||
update: () => false, // Consent logs should not be modified
|
||||
delete: () => false, // Consent logs should not be deleted
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Accept', value: 'accept' },
|
||||
{ label: 'Reject', value: 'reject' },
|
||||
{ label: 'Update', value: 'update' },
|
||||
],
|
||||
admin: {
|
||||
description: 'The type of consent action',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'purpose',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Analytics', value: 'analytics' },
|
||||
{ label: 'Marketing', value: 'marketing' },
|
||||
{ label: 'Functional', value: 'functional' },
|
||||
{ label: 'All', value: 'all' },
|
||||
],
|
||||
admin: {
|
||||
description: 'The purpose of the consent',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'analytics',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Consent for analytics cookies',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'marketing',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Consent for marketing cookies',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'functional',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Consent for functional cookies',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userAgent',
|
||||
type: 'text',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Browser user agent string',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ip',
|
||||
type: 'text',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'IP address of the user',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'timestamp',
|
||||
type: 'date',
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'When the consent was given',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'previousConsent',
|
||||
type: 'json',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Previous consent state (for updates)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'newConsent',
|
||||
type: 'json',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'New consent state',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default ConsentLogs
|
||||
@@ -1,316 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface ConsentState {
|
||||
analytics: boolean
|
||||
marketing: boolean
|
||||
functional: boolean
|
||||
hasConsented: boolean
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
const defaultConsent: ConsentState = {
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
hasConsented: false,
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'pdpa_consent'
|
||||
|
||||
export function CookieBanner() {
|
||||
const [consent, setConsent] = useState<ConsentState>(defaultConsent)
|
||||
const [showBanner, setShowBanner] = useState(false)
|
||||
const [showPreferences, setShowPreferences] = useState(false)
|
||||
|
||||
// Load consent from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored)
|
||||
setConsent(parsed)
|
||||
setShowBanner(false)
|
||||
} catch {
|
||||
setShowBanner(true)
|
||||
}
|
||||
} else {
|
||||
setShowBanner(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save consent to localStorage
|
||||
const saveConsent = async (newConsent: ConsentState) => {
|
||||
// Save to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newConsent))
|
||||
setConsent(newConsent)
|
||||
setShowBanner(false)
|
||||
setShowPreferences(false)
|
||||
|
||||
// Log to server
|
||||
try {
|
||||
await fetch('/api/consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: newConsent.hasConsented ? 'accept' : 'reject',
|
||||
purpose: 'all',
|
||||
...newConsent,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to log consent:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Accept all cookies
|
||||
const acceptAll = () => {
|
||||
saveConsent({
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
functional: true,
|
||||
hasConsented: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Reject all cookies (only functional)
|
||||
const rejectAll = () => {
|
||||
saveConsent({
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
hasConsented: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Save custom preferences
|
||||
const savePreferences = () => {
|
||||
saveConsent({
|
||||
...consent,
|
||||
hasConsented: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Update individual preference
|
||||
const updatePreference = (key: keyof Pick<ConsentState, 'analytics' | 'marketing' | 'functional'>, value: boolean) => {
|
||||
setConsent(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
// If no banner to show, return null
|
||||
if (!showBanner) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 -4px 20px rgba(0, 0, 0, 0.15)',
|
||||
padding: '1.5rem',
|
||||
zIndex: 9999,
|
||||
borderTop: '1px solid #e5e5e5',
|
||||
}}
|
||||
role="dialog"
|
||||
aria-label="Cookie Consent Banner"
|
||||
>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{!showPreferences ? (
|
||||
// Main banner
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '1.125rem', fontWeight: 600 }}>
|
||||
🍪 PDPA Cookie Consent
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 1rem 0', color: '#555', fontSize: '0.9375rem', lineHeight: 1.5 }}>
|
||||
We use cookies to enhance your experience. By continuing to visit this site, you agree to our use of cookies.{' '}
|
||||
<a href="/privacy-policy" style={{ color: '#0066cc' }}>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
style={{
|
||||
padding: '0.625rem 1.25rem',
|
||||
backgroundColor: '#22c55e',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Accept All Cookies
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={rejectAll}
|
||||
style={{
|
||||
padding: '0.625rem 1.25rem',
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#333',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reject All
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowPreferences(true)}
|
||||
style={{
|
||||
padding: '0.625rem 1.25rem',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#0066cc',
|
||||
border: '1px solid #0066cc',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cookie Preferences
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Preferences panel
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '1.125rem', fontWeight: 600 }}>
|
||||
Cookie Preferences
|
||||
</h3>
|
||||
|
||||
<p style={{ margin: '0 0 1rem 0', color: '#555', fontSize: '0.875rem' }}>
|
||||
Manage your cookie preferences below.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
{/* Functional Cookies */}
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '0.75rem',
|
||||
border: '1px solid #e5e5e5'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Functional Cookies</h4>
|
||||
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
|
||||
Essential for the website to function properly. Cannot be disabled.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
backgroundColor: '#e5e5e5',
|
||||
color: '#666',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
Always Active
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Cookies */}
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '0.75rem',
|
||||
border: '1px solid #e5e5e5'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Analytics Cookies</h4>
|
||||
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
|
||||
Help us understand how visitors interact with our website.
|
||||
</p>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consent.analytics}
|
||||
onChange={(e) => updatePreference('analytics', e.target.checked)}
|
||||
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marketing Cookies */}
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '0.75rem',
|
||||
border: '1px solid #e5e5e5'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Marketing Cookies</h4>
|
||||
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
|
||||
Used to track visitors across websites for advertising purposes.
|
||||
</p>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consent.marketing}
|
||||
onChange={(e) => updatePreference('marketing', e.target.checked)}
|
||||
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button
|
||||
onClick={savePreferences}
|
||||
style={{
|
||||
padding: '0.625rem 1.25rem',
|
||||
backgroundColor: '#0066cc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Save Preferences
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowPreferences(false)}
|
||||
style={{
|
||||
padding: '0.625rem 1.25rem',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9375rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
skills/website-creator/templates/consent/db/config.ts
Normal file
38
skills/website-creator/templates/consent/db/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineDb, defineTable, column } from 'astro:db';
|
||||
|
||||
const ConsentLog = defineTable({
|
||||
columns: {
|
||||
id: column.number({ primaryKey: true }),
|
||||
action: column.text(),
|
||||
purpose: column.text(),
|
||||
analytics: column.boolean({ default: false }),
|
||||
marketing: column.boolean({ default: false }),
|
||||
functional: column.boolean({ default: false }),
|
||||
userAgent: column.text({ optional: true }),
|
||||
ip: column.text({ optional: true }),
|
||||
timestamp: column.date(),
|
||||
sessionId: column.text({ optional: true }),
|
||||
},
|
||||
});
|
||||
|
||||
export type ConsentAction = 'accept' | 'reject' | 'update';
|
||||
export type ConsentPurpose = 'analytics' | 'marketing' | 'functional' | 'all';
|
||||
|
||||
export interface ConsentRow {
|
||||
id: number;
|
||||
action: ConsentAction;
|
||||
purpose: ConsentPurpose;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
functional: boolean;
|
||||
userAgent?: string;
|
||||
ip?: string;
|
||||
timestamp: Date;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export default defineDb({
|
||||
tables: {
|
||||
ConsentLog,
|
||||
},
|
||||
});
|
||||
75
skills/website-creator/templates/consent/stores/consent.ts
Normal file
75
skills/website-creator/templates/consent/stores/consent.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { map } from 'nanostores';
|
||||
|
||||
export interface ConsentState {
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
functional: boolean;
|
||||
hasConsented: boolean;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface ConsentLogData extends ConsentState {
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export const defaultConsent: ConsentState = {
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
hasConsented: false,
|
||||
};
|
||||
|
||||
export const consentStore = map<ConsentState>(defaultConsent);
|
||||
export const STORAGE_KEY = 'pdpa_consent';
|
||||
|
||||
export function loadConsent(): ConsentState {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return defaultConsent;
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as ConsentState;
|
||||
consentStore.set(parsed);
|
||||
return parsed;
|
||||
} catch {
|
||||
return defaultConsent;
|
||||
}
|
||||
}
|
||||
return defaultConsent;
|
||||
}
|
||||
|
||||
export function saveConsentLocally(state: ConsentState): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
consentStore.set(state);
|
||||
}
|
||||
|
||||
export function hasAnalyticsConsent(): boolean {
|
||||
const state = consentStore.get();
|
||||
return state.hasConsented && state.analytics;
|
||||
}
|
||||
|
||||
export function hasMarketingConsent(): boolean {
|
||||
const state = consentStore.get();
|
||||
return state.hasConsented && state.marketing;
|
||||
}
|
||||
|
||||
export function hasFunctionalConsent(): boolean {
|
||||
const state = consentStore.get();
|
||||
return state.hasConsented;
|
||||
}
|
||||
|
||||
export function resetConsent(): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
consentStore.set(defaultConsent);
|
||||
}
|
||||
|
||||
export function hasConsented(): boolean {
|
||||
return consentStore.get().hasConsented;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
build
|
||||
*.log
|
||||
.env*.local
|
||||
.DS_Store
|
||||
*.pem
|
||||
@@ -1,5 +0,0 @@
|
||||
# Payload CMS
|
||||
PAYLOAD_SECRET=your-secret-key-here-change-in-production
|
||||
|
||||
# Database (PostgreSQL) - database name must match POSTGRES_DB in docker-compose
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/payload
|
||||
@@ -1,39 +0,0 @@
|
||||
# Multi-stage Dockerfile for Next.js + Payload CMS with PostgreSQL
|
||||
# Requires `output: 'standalone'` in next.config.ts
|
||||
|
||||
FROM node:22-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate && pnpm install --frozen-lockfile
|
||||
|
||||
FROM deps AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,40 +0,0 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
payload:
|
||||
image: node:22-alpine
|
||||
ports:
|
||||
- '3000:3000'
|
||||
volumes:
|
||||
- .:/home/node/app
|
||||
- node_modules:/home/node/app/node_modules
|
||||
working_dir: /home/node/app/
|
||||
command: sh -c "corepack enable && corepack prepare pnpm@9.0.0 --activate && pnpm install && pnpm dev"
|
||||
depends_on:
|
||||
- postgres
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- payload-network
|
||||
|
||||
postgres:
|
||||
restart: always
|
||||
image: postgres:16-alpine
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
POSTGRES_USER: payload
|
||||
POSTGRES_PASSWORD: payloadpass
|
||||
POSTGRES_DB: payload
|
||||
networks:
|
||||
- payload-network
|
||||
|
||||
networks:
|
||||
payload-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
node_modules:
|
||||
@@ -1,31 +0,0 @@
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
import type { NextConfig } from 'next'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(__filename)
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
localPatterns: [
|
||||
{
|
||||
pathname: '/api/media/file/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
output: 'standalone',
|
||||
webpack: (webpackConfig) => {
|
||||
webpackConfig.resolve.extensionAlias = {
|
||||
'.cjs': ['.cts', '.cjs'],
|
||||
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
||||
'.mjs': ['.mts', '.mjs'],
|
||||
}
|
||||
return webpackConfig
|
||||
},
|
||||
turbopack: {
|
||||
root: path.resolve(dirname),
|
||||
},
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig, { devBundleServerPackages: false })
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"name": "nextjs-payload-starter",
|
||||
"version": "1.0.0",
|
||||
"description": "Next.js + Payload CMS starter template with PostgreSQL",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
|
||||
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
||||
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
|
||||
"docker:dev": "docker compose up -d",
|
||||
"docker:dev:logs": "docker compose logs -f",
|
||||
"docker:down": "docker compose down"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/next": "^3.82.1",
|
||||
"@payloadcms/richtext-lexical": "^3.82.1",
|
||||
"@payloadcms/ui": "^3.82.1",
|
||||
"@payloadcms/db-postgres": "^3.82.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"graphql": "^16.8.1",
|
||||
"next": "^16.2.3",
|
||||
"payload": "^3.82.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"sharp": "^0.34.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.9",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "^16.2.3",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0"
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0070f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
footer {
|
||||
background: #f5f5f5;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Next.js + Payload CMS',
|
||||
template: '%s | Next.js + Payload CMS',
|
||||
},
|
||||
description: 'A website built with Next.js and Payload CMS',
|
||||
}
|
||||
|
||||
export default function FrontendLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="th">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { getPayload } from 'payload'
|
||||
import Link from 'next/link'
|
||||
import config from '@payload-config'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function HomePage() {
|
||||
let posts: any[] = []
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
limit: 10,
|
||||
sort: '-createdAt',
|
||||
})
|
||||
posts = docs
|
||||
} catch (e) {
|
||||
// Table might not exist yet - that's OK for initial setup
|
||||
console.warn('Could not fetch posts:', e)
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<header style={{ marginBottom: '3rem', borderBottom: '1px solid #eee', paddingBottom: '1rem' }}>
|
||||
<h1 style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>
|
||||
Next.js + Payload CMS
|
||||
</h1>
|
||||
<p style={{ color: '#666' }}>
|
||||
Welcome to your new website. Edit <code>src/app/(frontend)/page.tsx</code> to customize.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>Recent Posts</h2>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p style={{ color: '#888' }}>
|
||||
No posts yet. Go to{' '}
|
||||
<Link href="/admin" style={{ color: '#0070f3' }}>
|
||||
Admin Panel
|
||||
</Link>{' '}
|
||||
to create your first post.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{posts.map((post) => (
|
||||
<li
|
||||
key={post.id}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
border: '1px solid #eee',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/posts/${post.slug || post.id}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<h3 style={{ margin: 0, marginBottom: '0.5rem' }}>{post.title as string}</h3>
|
||||
{post.createdAt && (
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', color: '#888' }}>
|
||||
{new Date(post.createdAt).toLocaleDateString('th-TH', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer style={{ marginTop: '4rem', paddingTop: '2rem', borderTop: '1px solid #eee' }}>
|
||||
<Link href="/admin" style={{ color: '#0070f3' }}>
|
||||
Go to Admin Panel
|
||||
</Link>
|
||||
</footer>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import { importMap } from '../importMap.js'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params, searchParams, importMap })
|
||||
|
||||
export default Page
|
||||
@@ -1,2 +0,0 @@
|
||||
/* THIS FILE IS GENERATED BY PAYLOAD - RUN `pnpm generate:importmap` AFTER CHANGING COLLECTIONS */
|
||||
export const importMap = {}
|
||||
@@ -1,18 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = REST_GET(config)
|
||||
export const POST = REST_POST(config)
|
||||
export const DELETE = REST_DELETE(config)
|
||||
export const PATCH = REST_PATCH(config)
|
||||
export const PUT = REST_PUT(config)
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
@@ -1,6 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* Run `pnpm generate:importmap` to regenerate */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||
@@ -1,6 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
|
||||
|
||||
export const POST = GRAPHQL_POST(config)
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
@@ -1 +0,0 @@
|
||||
/* Custom styles for Payload admin - add your overrides here */
|
||||
@@ -1,30 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import type { ServerFunctionClient } from 'payload'
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
|
||||
import React from 'react'
|
||||
|
||||
import { importMap } from './admin/importMap.js'
|
||||
import './custom.scss'
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server'
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
})
|
||||
}
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
export default Layout
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
upload: true,
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'updatedAt'],
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'URL-friendly version (e.g. "about-us")',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
],
|
||||
defaultValue: 'draft',
|
||||
admin: {
|
||||
description: 'Control publication status',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
label: 'Page Content',
|
||||
admin: {
|
||||
description: 'Main page content — use the visual editor',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'updatedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ data }) => {
|
||||
if (data.title && !data.slug) {
|
||||
data.slug = data.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9ก-๙]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
}
|
||||
return data
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'createdAt'],
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: () => true,
|
||||
update: () => true,
|
||||
delete: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'URL-friendly version of the title',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
},
|
||||
{
|
||||
name: 'featuredImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
],
|
||||
defaultValue: 'draft',
|
||||
admin: {
|
||||
description: 'Control publication status',
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ data }) => {
|
||||
if (data.title && !data.slug) {
|
||||
data.slug = data.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
}
|
||||
return data
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
auth: true,
|
||||
fields: [
|
||||
// Email added by default
|
||||
],
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as config } from './payload.config'
|
||||
@@ -1,41 +0,0 @@
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import { Users } from './collections/Users'
|
||||
import { Media } from './collections/Media'
|
||||
import { Posts } from './collections/Posts'
|
||||
import { Pages } from './collections/Pages'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
collections: [
|
||||
Users,
|
||||
Media,
|
||||
Posts,
|
||||
Pages,
|
||||
],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.DATABASE_URL || '',
|
||||
},
|
||||
}),
|
||||
sharp,
|
||||
plugins: [],
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@payload-config": ["./src/payload.config.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user