Update skills: add website-creator, mql-developer, ecommerce-astro

Changes:
- Add FAL_KEY and GEMINI_API_KEY to .env.example
- Update picture-it to use ~/.config/opencode/.env (unified creds)
- Remove shodh-memory skill (no longer used)
- Remove alphaear-* skills (deprecated)
- Remove thai-frontend-dev skill (replaced by website-creator)
- Remove theme-factory skill
- Add mql-developer skill (MQL5 trading)
- Add ecommerce-astro skill (Astro e-commerce)
- Add website-creator skill (Next.js + Payload CMS)
- Update install script for new skills
This commit is contained in:
2026-04-16 17:40:27 +07:00
parent 5053ccdba2
commit b26c8199a5
562 changed files with 59030 additions and 37600 deletions

View File

@@ -0,0 +1,9 @@
node_modules
.next
out
dist
build
*.log
.env*.local
.DS_Store
*.pem

View File

@@ -0,0 +1,5 @@
# 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

View File

@@ -0,0 +1,39 @@
# 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"]

View File

@@ -0,0 +1,40 @@
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:

View File

@@ -0,0 +1,31 @@
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 })

View File

@@ -0,0 +1,45 @@
{
"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"
}
}

View File

@@ -0,0 +1,41 @@
* {
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;
}

View File

@@ -0,0 +1,22 @@
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>
)
}

View File

@@ -0,0 +1,84 @@
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>
)
}

View File

@@ -0,0 +1,23 @@
/* 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

View File

@@ -0,0 +1,2 @@
/* THIS FILE IS GENERATED BY PAYLOAD - RUN `pnpm generate:importmap` AFTER CHANGING COLLECTIONS */
export const importMap = {}

View File

@@ -0,0 +1,18 @@
/* 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)

View File

@@ -0,0 +1,6 @@
/* 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)

View File

@@ -0,0 +1,6 @@
/* 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)

View File

@@ -0,0 +1 @@
/* Custom styles for Payload admin - add your overrides here */

View File

@@ -0,0 +1,30 @@
/* 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

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
],
upload: true,
}

View File

@@ -0,0 +1,70 @@
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
},
],
},
}

View File

@@ -0,0 +1,73 @@
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
},
],
},
}

View File

@@ -0,0 +1,12 @@
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [
// Email added by default
],
}

View File

@@ -0,0 +1 @@
export { default as config } from './payload.config'

View File

@@ -0,0 +1,41 @@
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: [],
})

View File

@@ -0,0 +1,28 @@
{
"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"]
}