This commit is contained in:
Your Name committed 2026-09-25 05:24:45 +07:00
1 parent 3e742ac468
commit 3ac0cb4f53
2 files changed
+940 -114

No files matched your search

+719 -114
View File
@@ -1,18 +1,44 @@
import { useState, useEffect, useRef } from 'react'
import { API_BASE, type StoreSettings } from '@homaroma/types'
import { Save, Loader2, Sparkles, Download, Upload } from 'lucide-react'
import {
Save,
Loader2,
Sparkles,
Download,
Upload,
Copy,
ClipboardPaste,
FileJson,
Check,
CheckCircle2,
AlertCircle,
Code2,
X,
Bot
} from 'lucide-react'
const defaultThemeTokens = {
brandPrimary: '#FF6B6B', brandPrimaryHover: '#FF4500', brandAccent: '#FFF200',
brandWarm: '#FFA07A', brandCool: '#E0FFFF', brandSuccess: '#98FB98', brandSoft: '#D8BFD8',
surfacePage: '#FFF0F5', surfaceNavRest: '#F9F8F5', surfaceCard: '#FFFFFF',
textMain: '#333333', mutedText: '#666666',
linePrimary: '#00B900', linePrimaryHover: '#009900',
brandPrimary: '#FF6B6B',
brandPrimaryHover: '#FF4500',
brandAccent: '#FFF200',
brandWarm: '#FFA07A',
brandCool: '#E0FFFF',
brandSuccess: '#98FB98',
brandSoft: '#D8BFD8',
surfacePage: '#FFF0F5',
surfaceNavRest: '#F9F8F5',
surfaceCard: '#FFFFFF',
textMain: '#333333',
mutedText: '#666666',
linePrimary: '#00B900',
linePrimaryHover: '#009900',
captionText: '#B0A396',
borderColor: '#E8E0D8',
surfaceMuted: '#EDE6DD',
fontBody: '', fontDisplay: '',
shadowCard: '0 10px 25px rgba(0,0,0,0.05)', shadowCardHover: '0 20px 40px rgba(0,0,0,0.1)',
fontBody: '',
fontDisplay: '',
shadowCard: '0 10px 25px rgba(0,0,0,0.05)',
shadowCardHover: '0 20px 40px rgba(0,0,0,0.1)',
radiusBase: '8px',
radiusCard: '16px',
}
@@ -25,6 +51,90 @@ const FONT_PRESETS = {
} as const
type FontPresetKey = keyof typeof FONT_PRESETS
const LLM_PROMPT_TEXT = `คุณคือ Senior UI/UX Designer และ Design System Architect ระดับมืออาชีพ เชี่ยวชาญการออกแบบ Color Palette, Dark/Light Theme System, และ Visual Identity ให้กับร้านค้าออนไลน์ระดับ Luxury, Minimalist, Cyberpunk, หรือ Modern Lifestyle
[ภารกิจของคุณ]:
สร้าง Theme & Design Settings JSON ที่สมบูรณ์แบบ มีความกลมกลืนของสี (Color Harmony), Contrast Ratio ผ่านเกณฑ์ WCAG AA/AAA เพื่อนำเข้าสู่ระบบ E-commerce ในรูปแบบ Valid JSON Object ตาม Schema ที่กำหนดอย่างเคร่งครัด
[กฎและข้อกำหนดทางเทคนิค]:
1. ผลลัพธ์ต้องเป็น Valid JSON Object ล้วนๆ เริ่มต้นด้วย { และจบด้วย } เท่านั้น (ห้ามมี Markdown Backticks หรือคำเกริ่นนำ)
2. ค่าสีทั้งหมดต้องอยู่ในรูปแบบ HEX Color Code 6 หลัก (เช่น "#A100FF", "#0A0D14")
3. โครงสร้าง JSON ต้องประกอบด้วย:
- "version": "1.0"
- "themeTokens": Object เก็บค่าสีและสไตล์ทั้งหมด
- "scrollRevealEnabled": boolean (true/false)
- "scrollRevealStyle": "fade-up" | "fade-in" | "slide-left" | "zoom-in" | "none"
- "scrollRevealDuration": number (เช่น 600, 700, 800)
- "headerStyle": "single_line" | "two_tier"
[รายละเอียดของ themeTokens แต่ละฟิลด์]:
• กลุ่มสีแบรนด์และไฮไลต์ (Brand & Accents):
- "brandPrimary": สีหลักของแบรนด์ (Primary CTA Button, Active Links)
- "brandPrimaryHover": สีเมื่อชี้เมาส์ที่ปุ่มหลัก
- "brandAccent": สีไฮไลต์สะดุดตา (Badge, Tag, Sale Discount)
- "brandWarm": สีโทนอุ่น (Rating Star, Warm Glow)
- "brandCool": สีโทนเย็น / Light contrast
- "brandSuccess": สีแจ้งเตือนความสำเร็จ (In Stock, Completed)
- "brandSoft": สีรองโทนอ่อน / Pastel highlight
• กลุ่มพื้นผิวและเลเยอร์ (Surfaces & Backgrounds):
- "surfacePage": สีพื้นหลังหลักของหน้าเว็บ
- "surfaceNavRest": สีพื้นหลังของ Header / Navbar
- "surfaceCard": สีพื้นหลังของการ์ดสินค้า / Container Block
- "surfaceMuted": สีพื้นหลังของ Section สลับลาย / Footer
• กลุ่มข้อความและการอ่าน (Typography Colors):
- "textMain": สีข้อความหัวข้อและเนื้อหาหลัก
- "mutedText": สีข้อความรอง / รายละเอียดปลีกย่อย
- "captionText": สีข้อความกำกับ / ตัวเลขสถิติ / วันที่
• กลุ่มเส้นขอบและช่องทางติดต่อ (Borders & Social):
- "borderColor": สีเส้นขอบการ์ดและเส้นแบ่ง Divider
- "linePrimary": สีเขียว LINE Official หรือโทนสีไอคอนแชท
- "linePrimaryHover": สีเมื่อ Hover ไอคอน LINE
• กลุ่มมิติและสเปซ (Elevations & Geometry):
- "fontBody": ฟอนต์เนื้อหา (ปล่อยว่าง "" หรือใส่ Google Fonts)
- "fontDisplay": ฟอนต์หัวข้อ (ปล่อยว่าง "" หรือใส่ฟอนต์ Serif/Display)
- "shadowCard": เงาของการ์ดปกติ
- "shadowCardHover": เงาเมื่อ Hover การ์ด
- "radiusBase": ความโค้งมนของปุ่ม/อินพุต (เช่น "8px")
- "radiusCard": ความโค้งมนของการ์ด (เช่น "16px")
[ตัวอย่าง JSON Template]:
{
"version": "1.0",
"themeTokens": {
"brandPrimary": "#A100FF",
"brandPrimaryHover": "#C23BFF",
"brandAccent": "#FF7A1A",
"brandWarm": "#FF8F45",
"brandCool": "#E7EBF5",
"brandSuccess": "#46D36A",
"brandSoft": "#D16BFF",
"surfacePage": "#0A0D14",
"surfaceNavRest": "#0D1018",
"surfaceCard": "#151A24",
"textMain": "#F5F7FF",
"mutedText": "#A7AFBF",
"linePrimary": "#00B900",
"linePrimaryHover": "#009900",
"captionText": "#7D8598",
"borderColor": "#242A38",
"surfaceMuted": "#111622",
"fontBody": "",
"fontDisplay": "",
"shadowCard": "0 2px 8px rgba(0,0,0,0.30)",
"shadowCardHover": "0 8px 24px rgba(0,0,0,0.40)",
"radiusBase": "8px",
"radiusCard": "16px"
},
"scrollRevealEnabled": true,
"scrollRevealStyle": "fade-up",
"scrollRevealDuration": 700,
"headerStyle": "single_line"
}`
function resolveFontPreset(tokens: Record<string, string>) {
const body = tokens.fontBody?.trim() || ''
const display = tokens.fontDisplay?.trim() || ''
@@ -46,6 +156,15 @@ export default function ThemeSettingsPage() {
const [scrollRevealDuration, setScrollRevealDuration] = useState('700')
const [headerStyle, setHeaderStyle] = useState('single_line')
// JSON Quick Copy / Paste State
const [isJsonBoxOpen, setIsJsonBoxOpen] = useState(false)
const [jsonInput, setJsonInput] = useState('')
const [jsonError, setJsonError] = useState<string | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(null)
// Show Prompt Modal State
const [isPromptModalOpen, setIsPromptModalOpen] = useState(false)
const fontPreset = resolveFontPreset(themeTokens)
const applyFontPreset = (preset: FontPresetKey | 'custom') => {
@@ -67,8 +186,14 @@ export default function ThemeSettingsPage() {
.finally(() => setLoading(false))
}, [])
const handleExport = () => {
const data = {
const showToast = (msg: string) => {
setToastMessage(msg)
setTimeout(() => setToastMessage(null), 2500)
}
// Generate current JSON payload
const getCurrentThemeJson = () => {
return {
version: '1.0',
exportedAt: new Date().toISOString(),
themeTokens,
@@ -77,7 +202,152 @@ export default function ThemeSettingsPage() {
scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
headerStyle,
}
const dataStr = JSON.stringify(data, null, 2)
}
// ── Copy JSON to Clipboard ────────────────────────────────────────────────
const handleCopyJson = async () => {
try {
const dataStr = JSON.stringify(getCurrentThemeJson(), null, 2)
await navigator.clipboard.writeText(dataStr)
showToast('Theme JSON copied to clipboard!')
} catch (err) {
console.error('Failed to copy to clipboard:', err)
alert('Could not copy JSON to clipboard automatically. Please use Export JSON.')
}
}
// ── Copy LLM Prompt to Clipboard ──────────────────────────────────────────
const handleCopyPrompt = async () => {
try {
await navigator.clipboard.writeText(LLM_PROMPT_TEXT)
showToast('LLM Prompt copied to clipboard!')
} catch (err) {
console.error('Failed to copy prompt:', err)
alert('Could not copy prompt to clipboard. Please copy manually.')
}
}
// ── Apply JSON from Text / Object ─────────────────────────────────────────
const applyJsonData = (parsed: any) => {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Invalid JSON format: expected an object')
}
// Extract tokens (support "tokens", "themeTokens", or root dictionary)
const extractedTokens =
(parsed.tokens && typeof parsed.tokens === 'object' && !Array.isArray(parsed.tokens)) ? parsed.tokens :
(parsed.themeTokens && typeof parsed.themeTokens === 'object' && !Array.isArray(parsed.themeTokens)) ? parsed.themeTokens :
(parsed.brandPrimary || parsed.surfacePage || parsed.textMain) ? parsed : null
if (extractedTokens) {
setThemeTokens(cur => ({ ...cur, ...extractedTokens }))
}
// Extract Scroll Reveal
if (parsed.scrollReveal && typeof parsed.scrollReveal === 'object') {
if (typeof parsed.scrollReveal.enabled === 'boolean') {
setScrollRevealEnabled(parsed.scrollReveal.enabled)
}
if (typeof parsed.scrollReveal.style === 'string') {
setScrollRevealStyle(parsed.scrollReveal.style)
}
if (parsed.scrollReveal.duration !== undefined) {
setScrollRevealDuration(parsed.scrollReveal.duration.toString())
}
} else {
if (typeof parsed.scrollRevealEnabled === 'boolean') {
setScrollRevealEnabled(parsed.scrollRevealEnabled)
}
if (typeof parsed.scrollRevealStyle === 'string') {
setScrollRevealStyle(parsed.scrollRevealStyle)
}
if (parsed.scrollRevealDuration !== undefined) {
setScrollRevealDuration(parsed.scrollRevealDuration.toString())
}
}
// Extract Header Style
if (parsed.header && typeof parsed.header === 'object' && typeof parsed.header.style === 'string') {
setHeaderStyle(parsed.header.style)
} else if (typeof parsed.headerStyle === 'string') {
setHeaderStyle(parsed.headerStyle)
}
}
// ── Paste JSON Text Handlers ──────────────────────────────────────────────
const handleApplyJsonText = () => {
setJsonError(null)
if (!jsonInput.trim()) {
setJsonError('Please enter valid JSON text')
return
}
try {
const parsed = JSON.parse(jsonInput)
applyJsonData(parsed)
showToast('Theme settings applied from JSON!')
setIsJsonBoxOpen(false)
setJsonInput('')
} catch (err: any) {
setJsonError(err.message || 'Failed to parse JSON')
}
}
const handlePasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
if (text) {
setJsonInput(text)
setJsonError(null)
} else {
alert('Clipboard is empty or does not contain text.')
}
} catch (err) {
console.error('Clipboard error:', err)
alert('Could not access clipboard. Please paste manually into the textbox.')
}
}
const handleFillSampleJson = () => {
const sample = {
version: "1.0",
themeTokens: {
brandPrimary: "#A100FF",
brandPrimaryHover: "#C23BFF",
brandAccent: "#FF7A1A",
brandWarm: "#FF8F45",
brandCool: "#E7EBF5",
brandSuccess: "#46D36A",
brandSoft: "#D16BFF",
surfacePage: "#0A0D14",
surfaceNavRest: "#0D1018",
surfaceCard: "#151A24",
textMain: "#F5F7FF",
mutedText: "#A7AFBF",
linePrimary: "#00B900",
linePrimaryHover: "#009900",
captionText: "#7D8598",
borderColor: "#242A38",
surfaceMuted: "#111622",
fontBody: "",
fontDisplay: "",
shadowCard: "0 2px 8px rgba(0,0,0,0.30)",
shadowCardHover: "0 8px 24px rgba(0,0,0,0.40)",
radiusBase: "8px",
radiusCard: "16px"
},
scrollRevealEnabled: true,
scrollRevealStyle: "fade-up",
scrollRevealDuration: 700,
headerStyle: "single_line"
}
setJsonInput(JSON.stringify(sample, null, 2))
setJsonError(null)
}
// ── File Export & Import ──────────────────────────────────────────────────
const handleExport = () => {
const dataStr = JSON.stringify(getCurrentThemeJson(), null, 2)
const blob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
@@ -87,6 +357,7 @@ export default function ThemeSettingsPage() {
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
showToast('Theme file exported!')
}
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -98,52 +369,8 @@ export default function ThemeSettingsPage() {
try {
const content = event.target?.result as string
const parsed = JSON.parse(content)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Invalid JSON format: expected an object')
}
// Extract tokens (support "tokens", "themeTokens", or root dictionary)
const extractedTokens =
(parsed.tokens && typeof parsed.tokens === 'object' && !Array.isArray(parsed.tokens)) ? parsed.tokens :
(parsed.themeTokens && typeof parsed.themeTokens === 'object' && !Array.isArray(parsed.themeTokens)) ? parsed.themeTokens :
(parsed.brandPrimary || parsed.surfacePage || parsed.textMain) ? parsed : null
if (extractedTokens) {
setThemeTokens(cur => ({ ...cur, ...extractedTokens }))
}
// Extract Scroll Reveal (support nested scrollReveal object or flat properties)
if (parsed.scrollReveal && typeof parsed.scrollReveal === 'object') {
if (typeof parsed.scrollReveal.enabled === 'boolean') {
setScrollRevealEnabled(parsed.scrollReveal.enabled)
}
if (typeof parsed.scrollReveal.style === 'string') {
setScrollRevealStyle(parsed.scrollReveal.style)
}
if (parsed.scrollReveal.duration !== undefined) {
setScrollRevealDuration(parsed.scrollReveal.duration.toString())
}
} else {
if (typeof parsed.scrollRevealEnabled === 'boolean') {
setScrollRevealEnabled(parsed.scrollRevealEnabled)
}
if (typeof parsed.scrollRevealStyle === 'string') {
setScrollRevealStyle(parsed.scrollRevealStyle)
}
if (parsed.scrollRevealDuration !== undefined) {
setScrollRevealDuration(parsed.scrollRevealDuration.toString())
}
}
// Extract Header Style (support nested header object or flat headerStyle)
if (parsed.header && typeof parsed.header === 'object' && typeof parsed.header.style === 'string') {
setHeaderStyle(parsed.header.style)
} else if (typeof parsed.headerStyle === 'string') {
setHeaderStyle(parsed.headerStyle)
}
alert('Theme settings loaded from JSON! Click "Save Theme Settings" to apply changes.')
applyJsonData(parsed)
showToast('Theme settings loaded from JSON file!')
} catch (err) {
console.error(err)
alert('Failed to parse JSON file. Please make sure it is a valid theme settings JSON.')
@@ -156,6 +383,7 @@ export default function ThemeSettingsPage() {
reader.readAsText(file)
}
// ── Save to Server ────────────────────────────────────────────────────────
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
@@ -171,20 +399,74 @@ export default function ThemeSettingsPage() {
headerStyle,
}),
})
if (res.ok) alert('Saved successfully!')
} catch { alert('Failed to save') } finally { setSaving(false) }
if (res.ok) {
showToast('Theme settings saved successfully!')
} else {
alert('Failed to save settings')
}
} catch {
alert('Failed to save')
} finally {
setSaving(false)
}
}
if (loading) return <div className="flex items-center justify-center p-20"><Loader2 className="w-8 h-8 text-cyan-glow animate-spin" /></div>
return (
<div className="max-w-3xl space-y-8 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="max-w-4xl space-y-8 animate-fade-in pb-20">
{/* Top Header & Action Buttons */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 border-b border-white/5 pb-6">
<div className="space-y-1">
<h1 className="text-3xl font-bold">Theme & Design</h1>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-white">Theme & Design</h1>
{/* Show Prompt Button next to Title */}
<button
type="button"
onClick={() => setIsPromptModalOpen(true)}
className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-xs font-semibold transition-all cursor-pointer shadow-sm hover:scale-105"
title="Show LLM Prompt for ChatGPT / Claude / Gemini"
>
<Bot className="w-3.5 h-3.5 text-purple-400" />
<span>Show Prompt</span>
</button>
</div>
<p className="text-slate-400 text-sm">Color tokens, typography, and scroll animation settings.</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{/* Copy JSON Button */}
<button
type="button"
onClick={handleCopyJson}
className="px-3.5 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/10 text-xs font-semibold flex items-center gap-1.5 transition-all cursor-pointer shadow-sm"
title="Copy full theme settings JSON to clipboard"
>
<Copy className="w-4 h-4 text-purple-400" />
<span>Copy JSON</span>
</button>
{/* Paste JSON Button */}
<button
type="button"
onClick={() => {
setIsJsonBoxOpen(!isJsonBoxOpen)
setJsonError(null)
}}
className={`px-3.5 py-2 rounded-xl text-xs font-semibold flex items-center gap-1.5 transition-all border cursor-pointer shadow-sm ${
isJsonBoxOpen
? 'bg-cyan-glow text-night-950 border-cyan-glow font-bold shadow-cyan-glow/20'
: 'bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border-cyan-glow/20'
}`}
title="Paste theme settings JSON"
>
<FileJson className="w-4 h-4" />
<span>{isJsonBoxOpen ? 'Hide JSON Box' : 'Paste JSON'}</span>
</button>
{/* Hidden File Input for Import */}
<input
type="file"
ref={fileInputRef}
@@ -192,72 +474,371 @@ export default function ThemeSettingsPage() {
accept=".json,application/json"
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="btn-secondary py-2 px-3.5 text-xs font-semibold flex items-center gap-1.5"
className="px-3.5 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/10 text-xs font-semibold flex items-center gap-1.5 transition-all cursor-pointer shadow-sm"
title="Import theme settings from JSON file"
>
<Upload className="w-4 h-4 text-cyan-glow" />
Import JSON
<span>Import File</span>
</button>
<button
type="button"
onClick={handleExport}
className="btn-secondary py-2 px-3.5 text-xs font-semibold flex items-center gap-1.5"
className="px-3.5 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/10 text-xs font-semibold flex items-center gap-1.5 transition-all cursor-pointer shadow-sm"
title="Export theme settings to JSON file"
>
<Download className="w-4 h-4 text-slate-300" />
Export JSON
<Download className="w-4 h-4 text-slate-400" />
<span>Export File</span>
</button>
</div>
</div>
{/* Toast Notification */}
{toastMessage && (
<div className="p-4 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center gap-3 animate-fade-in shadow-lg">
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-semibold">{toastMessage}</span>
</div>
)}
{/* Expandable JSON Paste Box */}
{isJsonBoxOpen && (
<div className="admin-card p-6 border-cyan-glow/30 bg-night-950/80 shadow-2xl space-y-4 animate-fade-in">
<div className="flex items-center justify-between border-b border-white/5 pb-3">
<div className="flex items-center gap-2 text-sm font-bold text-cyan-glow">
<Code2 className="w-4 h-4" />
<span>Paste Theme Settings JSON</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handlePasteFromClipboard}
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-[11px] text-slate-300 hover:text-white flex items-center gap-1 cursor-pointer transition-colors"
title="Paste text directly from clipboard"
>
<ClipboardPaste className="w-3.5 h-3.5 text-cyan-glow" />
<span>Paste Clipboard</span>
</button>
<button
type="button"
onClick={handleFillSampleJson}
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-[11px] text-slate-300 hover:text-white flex items-center gap-1 cursor-pointer transition-colors"
title="Insert sample JSON template"
>
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
<span>Sample Template</span>
</button>
<button
type="button"
onClick={() => setIsJsonBoxOpen(false)}
className="p-1 rounded-lg hover:bg-white/10 text-slate-500 hover:text-white cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<textarea
rows={8}
value={jsonInput}
onChange={(e) => {
setJsonInput(e.target.value)
setJsonError(null)
}}
className="w-full font-mono text-xs p-4 bg-night-900 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-cyan-200 placeholder:text-slate-600 resize-y shadow-inner"
placeholder='{\n "version": "1.0",\n "themeTokens": {\n "brandPrimary": "#A100FF",\n "surfacePage": "#0A0D14",\n "textMain": "#F5F7FF"\n }\n}'
/>
{jsonError && (
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{jsonError}</span>
</div>
)}
<div className="flex items-center justify-between pt-1">
<p className="text-[11px] text-slate-500">
Supports <code className="text-slate-400">themeTokens</code>, animation settings, and header style.
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsJsonBoxOpen(false)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 text-xs font-semibold cursor-pointer"
>
Cancel
</button>
<button
type="button"
onClick={handleApplyJsonText}
className="btn-primary px-5 py-2 text-xs font-bold flex items-center gap-1.5 cursor-pointer shadow-md shadow-cyan-glow/20"
>
<Check className="w-4 h-4" />
<span>Apply to Theme</span>
</button>
</div>
</div>
</div>
)}
{/* ── Show Prompt Modal ── */}
{isPromptModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-night-950/80 backdrop-blur-md animate-fade-in overflow-y-auto">
<div className="w-full max-w-3xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden my-8">
{/* Modal Header */}
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-white/[0.02]">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-purple-500/10 border border-purple-500/20 text-purple-400">
<Bot className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-bold text-white">LLM Theme Generator Prompt</h3>
<p className="text-xs text-slate-400">Copy this prompt to ChatGPT, Claude, or Gemini to create a matching theme</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopyPrompt}
className="btn-primary px-4 py-2 text-xs font-bold flex items-center gap-1.5 cursor-pointer shadow-lg shadow-cyan-glow/10"
>
<Copy className="w-3.5 h-3.5" />
<span>Copy Prompt</span>
</button>
<button
onClick={() => setIsPromptModalOpen(false)}
className="p-2 rounded-xl hover:bg-white/5 text-slate-400 hover:text-white transition-all cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Modal Body */}
<div className="p-6 space-y-4 max-h-[65vh] overflow-y-auto">
<div className="p-4 rounded-xl bg-purple-500/5 border border-purple-500/10 text-purple-300 text-xs leading-relaxed">
💡 <strong>เคล็ดลับ:</strong> เมื่อก๊อปปี้ข้อความด้านล่างไปวางใน LLM สามารถต่อท้ายคำสั่งด้วยโจทย์เฉพาะได้ เช่น <em>"ขอธีมโทน Rose Gold สำหรับร้านเครื่องสำอางผู้หญิง"</em> หรือ <em>"ขอธีม Minimalist Dark Mode สไตล์ญี่ปุ่น"</em> แล้วนำผลลัพธ์ JSON กลับมากด <strong>Paste JSON</strong> ได้ทันที
</div>
<div className="relative group">
<pre className="p-4 rounded-2xl bg-night-950 border border-white/10 font-mono text-xs text-slate-300 leading-relaxed whitespace-pre-wrap select-all overflow-x-auto shadow-inner">
{LLM_PROMPT_TEXT}
</pre>
</div>
</div>
{/* Modal Footer */}
<div className="p-4 px-6 border-t border-white/5 bg-white/[0.01] flex items-center justify-between text-xs text-slate-400">
<span>Template: theme-settings-2026-09-22.json compatible</span>
<button
type="button"
onClick={() => setIsPromptModalOpen(false)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white font-semibold cursor-pointer"
>
Close
</button>
</div>
</div>
</div>
)}
{/* Main Settings Form */}
<form onSubmit={handleSave} className="space-y-8">
{/* Theme Tokens */}
<div className="admin-card p-8 space-y-6">
{/* ── 1. Color Palette Tokens ── */}
<div className="admin-card p-6 md:p-8 space-y-6">
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
<div className="p-2 bg-cyan-glow/10 rounded-lg"><span className="text-xl">🎨</span></div>
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Theme Tokens</h2>
<div className="p-2 bg-cyan-glow/10 rounded-xl text-cyan-glow">
<span className="text-xl">🎨</span>
</div>
<div>
<h2 className="text-lg font-bold text-white">Color Palette & Surfaces</h2>
<p className="text-xs text-slate-400">Core brand colors, background surfaces, text hierarchy, and borders</p>
</div>
</div>
{/* Group 1: Brand & Accent Colors */}
<div className="space-y-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-cyan-glow">Brand & Accent Colors</h3>
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-4">
{[
{ key: 'brandPrimary', label: 'Brand Primary' },
{ key: 'brandPrimaryHover', label: 'Primary Hover' },
{ key: 'brandAccent', label: 'Brand Accent' },
{ key: 'brandWarm', label: 'Brand Warm' },
{ key: 'brandCool', label: 'Brand Cool' },
{ key: 'brandSuccess', label: 'Brand Success' },
{ key: 'brandSoft', label: 'Brand Soft' },
].map(({ key, label }) => (
<div key={key} className="space-y-1.5 p-3 rounded-xl bg-white/[0.02] border border-white/5">
<div className="flex justify-between items-center text-[11px] font-semibold text-slate-300">
<span>{label}</span>
<span className="font-mono text-[10px] text-slate-500 uppercase">{themeTokens[key] || '#000'}</span>
</div>
<input
type="color"
value={themeTokens[key] || '#000000'}
onChange={e => setThemeTokens(cur => ({ ...cur, [key]: e.target.value }))}
className="h-10 w-full rounded-lg border border-white/10 bg-night-950 p-1 cursor-pointer"
/>
</div>
))}
</div>
</div>
{/* Group 2: Surfaces & Backgrounds */}
<div className="space-y-3 pt-4 border-t border-white/5">
<h3 className="text-xs font-bold uppercase tracking-wider text-purple-400">Surfaces & Backgrounds</h3>
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-4">
{[
{ key: 'surfacePage', label: 'Page Background' },
{ key: 'surfaceNavRest', label: 'Navbar Background' },
{ key: 'surfaceCard', label: 'Card Surface' },
{ key: 'surfaceMuted', label: 'Muted Section Surface' },
].map(({ key, label }) => (
<div key={key} className="space-y-1.5 p-3 rounded-xl bg-white/[0.02] border border-white/5">
<div className="flex justify-between items-center text-[11px] font-semibold text-slate-300">
<span>{label}</span>
<span className="font-mono text-[10px] text-slate-500 uppercase">{themeTokens[key] || '#000'}</span>
</div>
<input
type="color"
value={themeTokens[key] || '#000000'}
onChange={e => setThemeTokens(cur => ({ ...cur, [key]: e.target.value }))}
className="h-10 w-full rounded-lg border border-white/10 bg-night-950 p-1 cursor-pointer"
/>
</div>
))}
</div>
</div>
{/* Group 3: Text & Lines */}
<div className="space-y-3 pt-4 border-t border-white/5">
<h3 className="text-xs font-bold uppercase tracking-wider text-emerald-400">Typography & Borders</h3>
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
{[
{ key: 'textMain', label: 'Main Text' },
{ key: 'mutedText', label: 'Muted Text' },
{ key: 'captionText', label: 'Caption Text' },
{ key: 'borderColor', label: 'Border Color' },
{ key: 'linePrimary', label: 'LINE Primary' },
{ key: 'linePrimaryHover', label: 'LINE Hover' },
].map(({ key, label }) => (
<div key={key} className="space-y-1.5 p-3 rounded-xl bg-white/[0.02] border border-white/5">
<div className="flex justify-between items-center text-[11px] font-semibold text-slate-300">
<span>{label}</span>
<span className="font-mono text-[10px] text-slate-500 uppercase">{themeTokens[key] || '#000'}</span>
</div>
<input
type="color"
value={themeTokens[key] || '#000000'}
onChange={e => setThemeTokens(cur => ({ ...cur, [key]: e.target.value }))}
className="h-10 w-full rounded-lg border border-white/10 bg-night-950 p-1 cursor-pointer"
/>
</div>
))}
</div>
</div>
</div>
{/* ── 2. Typography & Font Presets ── */}
<div className="admin-card p-6 md:p-8 space-y-6">
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
<div className="p-2 bg-purple-500/10 rounded-xl text-purple-400">
<span className="text-xl">🔤</span>
</div>
<div>
<h2 className="text-lg font-bold text-white">Typography & Fonts</h2>
<p className="text-xs text-slate-400">Select standard font pairings or define custom font families</p>
</div>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Font Preset</label>
<select value={fontPreset} onChange={e => applyFontPreset(e.target.value as FontPresetKey | 'custom')}
className="admin-input w-full max-w-sm bg-slate-900 border-white/10">
{Object.entries(FONT_PRESETS).map(([key, p]) => <option key={key} value={key}>{p.label}</option>)}
<select
value={fontPreset}
onChange={e => applyFontPreset(e.target.value as FontPresetKey | 'custom')}
className="admin-input w-full max-w-sm bg-night-900 border-white/10"
>
{Object.entries(FONT_PRESETS).map(([key, p]) => (
<option key={key} value={key}>{p.label}</option>
))}
<option value="custom">Custom</option>
</select>
</div>
<div className="grid gap-6 md:grid-cols-2">
{(['brandPrimary', 'brandPrimaryHover', 'brandAccent', 'surfacePage', 'surfaceCard', 'textMain', 'mutedText', 'linePrimary', 'linePrimaryHover', 'captionText', 'borderColor', 'surfaceMuted'] as const).map(key => (
<div key={key} className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">{key.replace(/([A-Z])/g, ' $1').trim()}</label>
<input type="color" value={themeTokens[key] || '#000000'}
onChange={e => setThemeTokens(cur => ({ ...cur, [key]: e.target.value }))}
className="h-12 w-full max-w-sm rounded-xl border border-white/10 bg-slate-900 p-1" />
</div>
))}
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Body Font Family</label>
<input
type="text"
value={themeTokens.fontBody || ''}
onChange={e => setThemeTokens(cur => ({ ...cur, fontBody: e.target.value }))}
className="admin-input w-full text-xs font-mono"
placeholder="Leave blank to use locale preset"
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Display / Heading Font Family</label>
<input
type="text"
value={themeTokens.fontDisplay || ''}
onChange={e => setThemeTokens(cur => ({ ...cur, fontDisplay: e.target.value }))}
className="admin-input w-full text-xs font-mono"
placeholder="Leave blank to use locale preset"
/>
</div>
</div>
</div>
{/* ── 3. Geometry & Shadows ── */}
<div className="admin-card p-6 md:p-8 space-y-6">
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
<div className="p-2 bg-blue-500/10 rounded-xl text-blue-400">
<span className="text-xl">📐</span>
</div>
<div>
<h2 className="text-lg font-bold text-white">Corner Radiuses & Shadows</h2>
<p className="text-xs text-slate-400">Card elevation shadows and border radiuses</p>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
{(['fontBody', 'fontDisplay', 'shadowCard', 'shadowCardHover', 'radiusBase', 'radiusCard'] as const).map(key => (
{[
{ key: 'radiusBase', label: 'Radius Base (Buttons/Inputs)', placeholder: 'e.g. 8px' },
{ key: 'radiusCard', label: 'Radius Card', placeholder: 'e.g. 16px' },
{ key: 'shadowCard', label: 'Shadow Card', placeholder: 'e.g. 0 2px 8px rgba(0,0,0,0.30)' },
{ key: 'shadowCardHover', label: 'Shadow Card Hover', placeholder: 'e.g. 0 8px 24px rgba(0,0,0,0.40)' },
].map(({ key, label, placeholder }) => (
<div key={key} className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">{key.replace(/([A-Z])/g, ' $1').trim()}</label>
<input type="text" value={themeTokens[key] || ''}
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">{label}</label>
<input
type="text"
value={themeTokens[key] || ''}
onChange={e => setThemeTokens(cur => ({ ...cur, [key]: e.target.value }))}
className="admin-input w-full"
placeholder={key.startsWith('font') ? 'Leave blank to use locale preset' : key.startsWith('radius') ? 'e.g. 8px' : undefined} />
className="admin-input w-full text-xs font-mono"
placeholder={placeholder}
/>
</div>
))}
</div>
</div>
{/* Header Layout Style */}
<div className="admin-card p-8 space-y-6">
{/* ── 4. Header Layout Style ── */}
<div className="admin-card p-6 md:p-8 space-y-6">
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
<div className="p-2 bg-amber-500/10 rounded-lg"><span className="text-xl">🧭</span></div>
<div className="p-2 bg-amber-500/10 rounded-xl text-amber-400">
<span className="text-xl">🧭</span>
</div>
<div>
<h2 className="text-lg font-bold uppercase tracking-widest text-white">PC Header Layout Style</h2>
<h2 className="text-lg font-bold text-white">PC Header Layout Style</h2>
<p className="text-xs text-slate-400 mt-0.5">เลือกรูปแบบการจัดวางเมนูส่วนหัวหลักสำหรับหน้าจอแนวกว้าง (PC / Desktop View)</p>
</div>
</div>
@@ -266,9 +847,9 @@ export default function ThemeSettingsPage() {
<button
type="button"
onClick={() => setHeaderStyle('single_line')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition ${
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
headerStyle === 'single_line'
? 'border-amber-500 bg-amber-500/10 text-white'
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
@@ -287,9 +868,9 @@ export default function ThemeSettingsPage() {
<button
type="button"
onClick={() => setHeaderStyle('two_tier')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition ${
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
headerStyle === 'two_tier'
? 'border-amber-500 bg-amber-500/10 text-white'
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
@@ -312,12 +893,14 @@ export default function ThemeSettingsPage() {
</div>
</div>
{/* Scroll Reveal */}
<div className="admin-card p-8 space-y-6">
{/* ── 5. Scroll Reveal Animation ── */}
<div className="admin-card p-6 md:p-8 space-y-6">
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
<div className="p-2 bg-violet-500/10 rounded-lg"><Sparkles className="w-5 h-5 text-violet-400" /></div>
<div className="p-2 bg-violet-500/10 rounded-xl text-violet-400">
<Sparkles className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Scroll Reveal Animation</h2>
<h2 className="text-lg font-bold text-white">Scroll Reveal Animation</h2>
<p className="text-xs text-slate-400 mt-0.5">ควบคุม animation effect ที่แสดงเมื่อ scroll ลงมาในหน้าเว็บ</p>
</div>
</div>
@@ -327,8 +910,11 @@ export default function ThemeSettingsPage() {
<p className="text-sm font-semibold text-white">Enable Scroll Reveal</p>
<p className="text-xs text-slate-400 mt-0.5">เปิดหรือปิด animation ทั้งหมดในเว็บไซต์</p>
</div>
<button type="button" onClick={() => setScrollRevealEnabled(v => !v)}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${scrollRevealEnabled ? 'bg-violet-500' : 'bg-slate-600'}`}>
<button
type="button"
onClick={() => setScrollRevealEnabled(v => !v)}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors cursor-pointer ${scrollRevealEnabled ? 'bg-violet-500' : 'bg-slate-600'}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${scrollRevealEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
@@ -345,9 +931,14 @@ export default function ThemeSettingsPage() {
{ value: 'zoom-in', label: 'Zoom In', icon: '⊕' },
{ value: 'none', label: 'None', icon: '—' },
].map(opt => (
<button key={opt.value} type="button" onClick={() => setScrollRevealStyle(opt.value)}
className={`flex flex-col items-center gap-1 rounded-lg border py-3 px-2 text-xs font-semibold transition ${scrollRevealStyle === opt.value ? 'border-violet-500 bg-violet-500/20 text-violet-300' : 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'}`}>
<span className="text-lg">{opt.icon}</span>{opt.label}
<button
key={opt.value}
type="button"
onClick={() => setScrollRevealStyle(opt.value)}
className={`flex flex-col items-center gap-1 rounded-xl border py-3 px-2 text-xs font-semibold transition cursor-pointer ${scrollRevealStyle === opt.value ? 'border-violet-500 bg-violet-500/20 text-violet-300 shadow-md shadow-violet-500/10' : 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'}`}
>
<span className="text-lg">{opt.icon}</span>
<span>{opt.label}</span>
</button>
))}
</div>
@@ -356,23 +947,37 @@ export default function ThemeSettingsPage() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Animation Duration</label>
<span className="text-sm font-bold text-violet-300">{scrollRevealDuration} ms</span>
<span className="text-sm font-bold text-violet-300 font-mono">{scrollRevealDuration} ms</span>
</div>
<input type="range" min="200" max="1500" step="50" value={scrollRevealDuration}
onChange={e => setScrollRevealDuration(e.target.value)} className="w-full accent-violet-500" />
<input
type="range"
min="200"
max="1500"
step="50"
value={scrollRevealDuration}
onChange={e => setScrollRevealDuration(e.target.value)}
className="w-full accent-violet-500 cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-slate-500">
<span>เร็ว (200ms)</span><span>ช้า (1500ms)</span>
<span>เร็ว (200ms)</span>
<span>ช้า (1500ms)</span>
</div>
</div>
</>
)}
</div>
<button type="submit" disabled={saving}
className="btn-primary py-4 px-12 flex items-center gap-2 ml-auto text-sm">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Theme Settings
</button>
{/* Submit Button */}
<div className="flex justify-end pt-4">
<button
type="submit"
disabled={saving}
className="btn-primary py-4 px-12 flex items-center gap-2 text-sm font-bold shadow-xl shadow-cyan-glow/10 cursor-pointer"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
<span>Save Theme Settings</span>
</button>
</div>
</form>
</div>
)