Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi

This commit is contained in:
Kunthawat Greethong
2026-05-25 16:38:02 +07:00
commit 69f7d8bdda
1689 changed files with 342427 additions and 0 deletions

View 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>

View File

@@ -0,0 +1,147 @@
# PDPA Consent Logging Template
Template สำหรับเพิ่ม PDPA consent logging ใน Astro + Tina (Astro DB)
## Files
```
consent/
├── ConsentBanner.astro # Consent banner component
├── api/
│ └── 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. เพิ่ม Astro DB Schema
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/consent.ts` ไปที่ `src/pages/api/consent.ts`
### 3. เพิ่ม ConsentBanner Component
Copy `ConsentBanner.astro` ไปที่ `src/components/consent/ConsentBanner.astro`
### 4. เพิ่มใน Layout
เพิ่ม `<ConsentBanner />` ใน `src/layouts/Layout.astro`:
```astro
---
import ConsentBanner from '../components/consent/ConsentBanner.astro';
---
<html lang="th">
<body>
<slot />
<ConsentBanner />
</body>
</html>
```
## API
### POST /api/consent
บันทึก consent action
**Request:**
```json
{
"action": "accept",
"purpose": "all",
"analytics": true,
"marketing": false,
"functional": true
}
```
**Response:**
```json
{
"success": true,
"doc": {
"id": 1,
"action": "accept",
"purpose": "all",
"analytics": true,
"marketing": false,
"functional": true,
"userAgent": "Mozilla/5.0...",
"ip": "127.0.0.1",
"timestamp": "2026-04-10T00:00:00.000Z"
}
}
```
### 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. **Astro DB ต้องรันบน server-side** - ใช้ `APIRoute` import
2. **Nano Stores รันบน client-side** - ใช้ `<script>` tag ใน Astro
3. **import ถูกต้อง** - ใช้ `import { db } from 'astro:db'` ไม่ใช่ `defineDb`

View 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' },
});
}
};

View 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,
},
});

View 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;
}