This commit is contained in:
Your Name committed 2026-10-06 15:35:04 +07:00
1 parent 230716933e
commit 3bbe2bb35e
16 files changed
+1785 -79

No files matched your search

+4
View File
@@ -76,6 +76,7 @@ import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage'
import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
import PosPage from './pages/PosPage'
import UserLlmPromptsPage from './pages/UserLlmPromptsPage'
import { StoreSettingsProvider } from './contexts/StoreSettingsContext'
function App() {
@@ -179,6 +180,9 @@ function App() {
<Route path="/backup/orders" element={<BackupOrdersPage />} />
<Route path="/backup/cms" element={<BackupCmsPage />} />
<Route path="/backup/media" element={<BackupMediaPage />} />
{/* User & LLM Prompts */}
<Route path="/user" element={<Navigate to="/user/llm-prompts" replace />} />
<Route path="/user/llm-prompts" element={<UserLlmPromptsPage />} />
</Route>
</Route>
+37
View File
@@ -18,6 +18,7 @@ import {
BarChart3,
Layers,
Globe2,
User,
Users,
UserCheck,
Image as ImageIcon,
@@ -66,6 +67,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
const isInCms = ['/page-builder', '/cms-mockup', '/media', '/articles'].some(p => location.pathname.startsWith(p))
const isInCustomer = ['/customers', '/subscribers', '/contacts', '/crm'].some(p => location.pathname.startsWith(p)) || isRepeatCustomers
const isInMenuManager = location.pathname.startsWith('/menu-manager')
const isInUser = location.pathname.startsWith('/user')
const isInBackup = location.pathname.startsWith('/backup')
const isInSettings = location.pathname.startsWith('/settings')
@@ -80,6 +82,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
const [cmsOpen, setCmsOpen] = useState(isInCms)
const [customerOpen, setCustomerOpen] = useState(isInCustomer)
const [menuManagerOpen, setMenuManagerOpen] = useState(isInMenuManager)
const [userOpen, setUserOpen] = useState(isInUser)
const [backupOpen, setBackupOpen] = useState(isInBackup)
const [settingsOpen, setSettingsOpen] = useState(isInSettings)
@@ -164,6 +167,10 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
{ icon: LayoutTemplate, label: t('nav.footerLayout', 'Footer Layout & Presentation'), to: '/menu-manager/footer-layout' },
]
const USER_MENU = [
{ icon: Sparkles, label: t('nav.llmPrompts', 'LLM Prompts'), to: '/user/llm-prompts' },
]
const BACKUP_MENU = [
{ icon: DatabaseBackup, label: t('nav.backupDatabase', 'Database Backup'), to: '/backup/database' },
{ icon: Package, label: t('nav.backupCatalog', 'Catalog Backup'), to: '/backup/catalog' },
@@ -561,6 +568,36 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
)}
</div>
{/* User Group */}
<div className="pt-1">
<button
onClick={() => handleGroupClick(setUserOpen)}
title={isCollapsed ? t('nav.user', 'User') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInUser ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
>
<User className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">{t('nav.user', 'User')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${userOpen ? 'rotate-180' : ''}`} />}
</button>
{!isCollapsed && userOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{USER_MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
}
>
<item.icon className="w-4 h-4 shrink-0" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
{/* Backup & Restore Group */}
<div className="pt-1">
<button
@@ -90,6 +90,8 @@ export default function EditorialTileForm({
}
className="admin-input w-full bg-slate-900 border-white/10"
>
<option value="landscape">Landscape 16:9 (แนวนอน)</option>
<option value="wide">Wide 4:3 (แนวนอน 4:3)</option>
<option value="square">Square 1:1 (จัตุรัส)</option>
<option value="portrait">Portrait 3:4 (แนวสูง 3:4)</option>
<option value="tall">Tall Fashion 2:3 (แนวสูง 2:3)</option>
@@ -1,5 +1,5 @@
import React from 'react';
import { UploadCloud, Image as ImageIcon, Plus, Trash2 } from 'lucide-react';
import { UploadCloud, Image as ImageIcon, Plus, Trash2, LayoutGrid, Sparkles } from 'lucide-react';
import { type PageSection } from '@homaroma/types';
interface FeaturedTilesFormProps {
@@ -26,15 +26,59 @@ export default function FeaturedTilesForm({
const payload = editingSection.payload || {};
const tiles = Array.isArray(payload.tiles) ? payload.tiles : [];
// Apply Quick Preset
const handleApplyPreset = (preset: 'editorial' | 'spotlight') => {
if (preset === 'editorial') {
// Style 1: Clean Editorial (4 Columns, Landscape, Left aligned, Arrow link, Warm Cream)
updatePayloadValue('layoutPreset', 'editorial');
updatePayloadValue('columns', 4);
updatePayloadValue('imageAspectRatio', 'landscape');
updatePayloadValue('textAlign', 'left');
updatePayloadValue('ctaStyle', 'arrow_link');
updatePayloadValue('imageRounded', 'md');
updatePayloadValue('backgroundColor', '#F7F4EE');
updatePayloadValue('textColor', '#1E293B');
// Update tiles' aspect ratio to match
const updatedTiles = tiles.map((t: any) => ({
...t,
imageAspectRatio: 'landscape',
buttonText: t.buttonText || { th: 'ดูสินค้า →', en: 'Shop Collection →' }
}));
updatePayloadValue('tiles', updatedTiles);
} else {
// Style 2: Campaign Spotlight (2 Columns, Square, Center aligned, Pill button, White)
updatePayloadValue('layoutPreset', 'spotlight');
updatePayloadValue('columns', 2);
updatePayloadValue('imageAspectRatio', 'square');
updatePayloadValue('textAlign', 'center');
updatePayloadValue('ctaStyle', 'pill_button');
updatePayloadValue('imageRounded', '3xl');
updatePayloadValue('backgroundColor', '#FFFFFF');
updatePayloadValue('textColor', '#111827');
const updatedTiles = tiles.map((t: any) => ({
...t,
imageAspectRatio: 'square',
buttonText: t.buttonText || { th: 'EXPLORE CAMPAIGN', en: 'EXPLORE CAMPAIGN' }
}));
updatePayloadValue('tiles', updatedTiles);
}
};
const handleAddTile = () => {
const currentAspect = payload.imageAspectRatio || 'square';
const currentCta = payload.ctaStyle || 'pill_button';
const defaultBtn = currentCta === 'arrow_link' ? 'Shop Collection →' : 'Shop Now';
const newTiles = [
...tiles,
{
title: { th: 'New Tile Title', en: 'New Tile Title' },
description: { th: '', en: '' },
imageUrl: 'https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=600&q=80',
imageAspectRatio: 'square',
buttonText: { th: 'Shop Now', en: 'Shop Now' },
imageAspectRatio: currentAspect,
buttonText: { th: defaultBtn, en: defaultBtn },
buttonLink: '/shop'
}
];
@@ -73,53 +117,219 @@ export default function FeaturedTilesForm({
return val[editingLang] || '';
};
const currentPreset = payload.layoutPreset || (payload.columns === 4 ? 'editorial' : 'spotlight');
return (
<div className="space-y-6">
{/* Section Header Controls */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 bg-night-950/40 p-4 rounded-2xl border border-white/5">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Section Title (Optional)</label>
{/* ── Quick Style Presets ── */}
<div className="bg-gradient-to-r from-night-950/80 via-night-900/60 to-night-950/80 p-4 rounded-2xl border border-cyan-glow/20 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-cyan-glow" />
<span className="text-xs font-bold uppercase tracking-widest text-white">Quick Style Presets (เทมเพลตสไตล์สำเร็จรูป)</span>
</div>
<span className="text-[10px] text-slate-400 font-mono">เลือกสไตล์เพื่อปรับแต่งทั้งแถบในคลิกเดียว</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => handleApplyPreset('editorial')}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer flex flex-col justify-between ${
currentPreset === 'editorial' && payload.textAlign === 'left' && payload.ctaStyle === 'arrow_link'
? 'bg-cyan-glow/15 border-cyan-glow text-white shadow-lg shadow-cyan-glow/10'
: 'bg-night-900/60 border-white/10 text-slate-300 hover:border-white/20 hover:bg-night-900'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-cyan-300">Preset 1: Clean Editorial (แบบภาพที่ 1)</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-cyan-glow/20 text-cyan-200">4 คอลัมน์</span>
</div>
<p className="text-[11px] text-slate-400 mt-1">
สไตล์ 4 การ์ดแนวนอน (Landscape 16:9) • ข้อความชิดซ้าย • ลิงก์พร้อมลูกศร (Shop Now →)
</p>
</button>
<button
type="button"
onClick={() => handleApplyPreset('spotlight')}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer flex flex-col justify-between ${
currentPreset === 'spotlight' && payload.textAlign === 'center' && payload.ctaStyle === 'pill_button'
? 'bg-cyan-glow/15 border-cyan-glow text-white shadow-lg shadow-cyan-glow/10'
: 'bg-night-900/60 border-white/10 text-slate-300 hover:border-white/20 hover:bg-night-900'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-amber-300">Preset 2: Campaign Spotlight (แบบภาพที่ 2)</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-200">2 คอลัมน์</span>
</div>
<p className="text-[11px] text-slate-400 mt-1">
สไตล์ 2 การ์ดใหญ่ (Square 1:1) • ขอบมน 3xl • ข้อความกึ่งกลาง • ปุ่มแคปซูลวงรี
</p>
</button>
</div>
</div>
{/* ── Section Header & Layout Controls ── */}
<div className="bg-night-950/40 p-5 rounded-2xl border border-white/5 space-y-4">
<div className="flex items-center gap-2 border-b border-white/5 pb-2">
<LayoutGrid className="w-4 h-4 text-cyan-glow" />
<span className="text-xs font-bold uppercase tracking-wider text-slate-300">การจัดวางและโครงสร้าง (Layout & Styling)</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Section Title (หัวข้อหลักด้านบน)</label>
<input
type="text"
value={getLocalizedValue(payload, 'title')}
onChange={(e) => updateLocalizedValue('title', e.target.value)}
className="admin-input w-full"
placeholder="e.g. Summer Campaign"
className="admin-input w-full text-xs"
placeholder="e.g. Featured Campaigns"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Grid Columns</label>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Section Subtitle (หัวข้อย่อย)</label>
<input
type="text"
value={getLocalizedValue(payload, 'subtitle')}
onChange={(e) => updateLocalizedValue('subtitle', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. Discover our curated collections"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Grid Columns (จำนวนคอลัมน์)</label>
<select
value={payload.columns || 2}
onChange={(e) => updatePayloadValue('columns', parseInt(e.target.value))}
className="admin-input w-full bg-slate-900 border-white/10"
className="admin-input w-full text-xs bg-slate-900 border-white/10"
>
<option value={2}>2 Columns (Side-by-side)</option>
<option value={3}>3 Columns</option>
<option value={4}>4 Columns</option>
<option value={2}>2 Columns (ใหญ่เด่น 2 ช่อง)</option>
<option value={3}>3 Columns (ปานกลาง 3 ช่อง)</option>
<option value={4}>4 Columns (แถวยาว 4 ช่องแบบรูปที่ 1)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 pt-2 border-t border-white/5">
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">สัดส่วนรูปภาพ (Default Aspect Ratio)</label>
<select
value={payload.imageAspectRatio || 'square'}
onChange={(e) => {
const val = e.target.value;
updatePayloadValue('imageAspectRatio', val);
// Also optionally update all tiles
const updatedTiles = tiles.map((t: any) => ({ ...t, imageAspectRatio: val }));
updatePayloadValue('tiles', updatedTiles);
}}
className="admin-input w-full text-xs bg-slate-900 border-white/10"
>
<option value="landscape">Landscape 16:9 (แนวนอนแบบรูป 1)</option>
<option value="wide">Wide 4:3 (แนวนอน 4:3)</option>
<option value="square">Square 1:1 (จัตุรัสแบบรูป 2)</option>
<option value="portrait">Portrait 3:4 (แนวตั้ง 3:4)</option>
<option value="tall">Tall 2:3 (แนวสูง 2:3)</option>
<option value="auto">Auto / Natural (รูปต้นฉบับ)</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">การจัดวางข้อความ (Text Alignment)</label>
<select
value={payload.textAlign || 'center'}
onChange={(e) => updatePayloadValue('textAlign', e.target.value)}
className="admin-input w-full text-xs bg-slate-900 border-white/10"
>
<option value="left">ชิดซ้าย (Left - แบบรูป 1)</option>
<option value="center">กึ่งกลาง (Center - แบบรูป 2)</option>
<option value="right">ชิดขวา (Right)</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">สไตล์ปุ่ม / CTA (Button Style)</label>
<select
value={payload.ctaStyle || 'pill_button'}
onChange={(e) => updatePayloadValue('ctaStyle', e.target.value)}
className="admin-input w-full text-xs bg-slate-900 border-white/10"
>
<option value="arrow_link">Text Link + ลูกศร (Shop Now → แบบรูป 1)</option>
<option value="pill_button">ปุ่มแคปซูลวงรี (Outline Capsule แบบรูป 2)</option>
<option value="solid_button">ปุ่มทึบ (Solid Button)</option>
<option value="none">ซ่อนปุ่ม (ไม่มีปุ่ม)</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">ความโค้งมนของภาพ (Corner Radius)</label>
<select
value={payload.imageRounded || '3xl'}
onChange={(e) => updatePayloadValue('imageRounded', e.target.value)}
className="admin-input w-full text-xs bg-slate-900 border-white/10"
>
<option value="none">ขอบคม (None 0px)</option>
<option value="md">มนเรียบหรู (Medium 12px แบบรูป 1)</option>
<option value="2xl">มนเด่น (Rounded 2xl 16px)</option>
<option value="3xl">มนกลมพิเศษ (Rounded 3xl 24px แบบรูป 2)</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2 border-t border-white/5">
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">สีพื้นหลังแถบนี้ (Background Color)</label>
<div className="flex items-center gap-2">
<input
type="color"
value={payload.backgroundColor || '#FDFDF9'}
value={payload.backgroundColor || '#F7F4EE'}
onChange={(e) => updatePayloadValue('backgroundColor', e.target.value)}
className="admin-input w-full h-10 p-1 cursor-pointer"
className="w-10 h-9 p-1 rounded-lg border border-white/10 bg-slate-900 cursor-pointer"
/>
<input
type="text"
value={payload.backgroundColor || '#F7F4EE'}
onChange={(e) => updatePayloadValue('backgroundColor', e.target.value)}
className="admin-input flex-1 text-xs"
placeholder="#F7F4EE หรือ transparent"
/>
</div>
</div>
{/* Tiles List */}
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">สีตัวหนังสือ (Text Color)</label>
<div className="flex items-center gap-2">
<input
type="color"
value={payload.textColor || '#1E293B'}
onChange={(e) => updatePayloadValue('textColor', e.target.value)}
className="w-10 h-9 p-1 rounded-lg border border-white/10 bg-slate-900 cursor-pointer"
/>
<input
type="text"
value={payload.textColor || '#1E293B'}
onChange={(e) => updatePayloadValue('textColor', e.target.value)}
className="admin-input flex-1 text-xs"
placeholder="#1E293B"
/>
</div>
</div>
</div>
</div>
{/* ── Cards List ── */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-xs uppercase font-bold tracking-widest text-slate-300">Campaign Cards ({tiles.length})</h4>
<button
type="button"
onClick={handleAddTile}
className="px-3 py-1.5 bg-cyan-glow text-night-900 font-bold rounded-xl text-xs flex items-center gap-1.5 hover:bg-cyan-glow/85 transition-all cursor-pointer"
className="px-3 py-1.5 bg-cyan-glow text-night-900 font-bold rounded-xl text-xs flex items-center gap-1.5 hover:bg-cyan-glow/85 transition-all cursor-pointer shadow-lg shadow-cyan-glow/10"
>
<Plus className="w-4 h-4" /> Add Card
<Plus className="w-4 h-4 stroke-[3]" /> Add Card
</button>
</div>
@@ -129,13 +339,13 @@ export default function FeaturedTilesForm({
</div>
) : (
tiles.map((tile: any, idx: number) => (
<div key={idx} className="bg-slate-900/60 border border-white/10 p-5 rounded-2xl space-y-4 relative">
<div key={idx} className="bg-slate-900/60 border border-white/10 p-5 rounded-2xl space-y-4 relative hover:border-cyan-glow/20 transition-all">
<div className="flex items-center justify-between border-b border-white/5 pb-2">
<span className="text-xs font-bold text-cyan-glow uppercase tracking-wider">Card #{idx + 1}</span>
<button
type="button"
onClick={() => handleRemoveTile(idx)}
className="p-1 text-slate-400 hover:text-rose-500 transition-colors"
className="p-1 text-slate-400 hover:text-rose-500 transition-colors cursor-pointer"
title="Remove Card"
>
<Trash2 className="w-4 h-4" />
@@ -178,23 +388,25 @@ export default function FeaturedTilesForm({
<div className="space-y-3">
<div>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title</label>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Level 1: Title (หัวข้อหลัก)</label>
<input
type="text"
required
value={getTileLocalizedField(tile, 'title')}
onChange={(e) => updateTileLocalizedField(idx, 'title', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. Here Comes The Sun"
className="admin-input w-full text-xs font-serif"
placeholder="e.g. Weekend Clean หรือ Soft Casual"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Image Aspect Ratio</label>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Card Aspect Ratio</label>
<select
value={tile.imageAspectRatio || 'square'}
value={tile.imageAspectRatio || payload.imageAspectRatio || 'square'}
onChange={(e) => updateTileField(idx, 'imageAspectRatio', e.target.value)}
className="admin-input w-full text-xs bg-slate-900 border-white/10"
>
<option value="landscape">Landscape 16:9 (แนวนอน)</option>
<option value="wide">Wide 4:3 (แนวนอน 4:3)</option>
<option value="square">Square 1:1 (จัตุรัส)</option>
<option value="portrait">Portrait 3:4 (แนวสูง 3:4)</option>
<option value="tall">Tall Fashion 2:3 (แนวสูง 2:3)</option>
@@ -204,24 +416,24 @@ export default function FeaturedTilesForm({
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Text</label>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Level 2: Button / Link Text (ข้อความลิงก์)</label>
<input
type="text"
value={getTileLocalizedField(tile, 'buttonText')}
onChange={(e) => updateTileLocalizedField(idx, 'buttonText', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. Shop Beach Blossom"
placeholder="e.g. Shop Relaxed Sets → หรือ Shop Women →"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Link URL</label>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Link URL (ลิงก์ปลายทาง)</label>
<input
type="text"
value={tile.buttonLink || ''}
onChange={(e) => updateTileField(idx, 'buttonLink', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. /shop/beach-blossom"
placeholder="e.g. /shop/weekend-clean หรือ /categories/women"
/>
</div>
</div>
+3 -1
View File
@@ -110,7 +110,9 @@
"paymentSettings": "Payment Settings",
"themeSettings": "Theme Settings",
"contentSettings": "Content Settings",
"systemMaintenance": "System Maintenance"
"systemMaintenance": "System Maintenance",
"user": "User",
"llmPrompts": "LLM Prompts"
},
"contentTabs": {
"th": "Thai",
+3 -1
View File
@@ -110,7 +110,9 @@
"paymentSettings": "ตั้งค่าช่องทางชำระเงิน",
"themeSettings": "ตั้งค่าธีม & สี",
"contentSettings": "ตั้งค่าเนื้อหาทั่วไป",
"systemMaintenance": "บำรุงรักษาและจัดการระบบ"
"systemMaintenance": "บำรุงรักษาและจัดการระบบ",
"user": "ผู้ใช้งาน (User)",
"llmPrompts": "LLM Prompts (คลังพรอมต์)"
},
"contentTabs": {
"th": "ภาษาไทย",
+879
View File
@@ -0,0 +1,879 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import {
Sparkles,
Search,
Plus,
Copy,
Edit3,
Trash2,
CopyPlus,
Download,
Upload,
RotateCcw,
Tag,
Layers,
FileText,
Palette,
Package,
Compass,
Globe,
Sliders,
X,
Code2,
CheckCircle2,
Loader2,
RefreshCw,
Database
} from 'lucide-react'
import { API_BASE, type LlmPrompt } from '@homaroma/types'
import { getAdminToken } from '../utils/auth'
export default function UserLlmPromptsPage() {
const [prompts, setPrompts] = useState<LlmPrompt[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState<string>('all')
const [selectedTag, setSelectedTag] = useState<string | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(null)
// Edit / Create Modal state
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingPrompt, setEditingPrompt] = useState<LlmPrompt | null>(null)
const [modalTitle, setModalTitle] = useState('')
const [modalCategory, setModalCategory] = useState<LlmPrompt['category']>('custom')
const [modalDescription, setModalDescription] = useState('')
const [modalTags, setModalTags] = useState('')
const [modalContent, setModalContent] = useState('')
// View Modal state (for quick full inspection)
const [viewingPrompt, setViewingPrompt] = useState<LlmPrompt | null>(null)
// File input ref for import
const fileInputRef = useRef<HTMLInputElement | null>(null)
const showToast = (msg: string) => {
setToastMessage(msg)
setTimeout(() => setToastMessage(null), 3000)
}
// Fetch prompts from backend DB
const loadPrompts = async () => {
try {
setLoading(true)
const res = await fetch(`${API_BASE}/llm-prompts`)
if (!res.ok) throw new Error('Failed to load prompts')
const data: LlmPrompt[] = await res.json()
setPrompts(data)
} catch (err: any) {
console.error('Error loading LLM prompts:', err)
showToast('ไม่สามารถโหลดข้อมูล Prompts จากเซิร์ฟเวอร์ได้')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadPrompts()
}, [])
// Copy to clipboard
const handleCopyPrompt = async (text: string, title: string) => {
try {
await navigator.clipboard.writeText(text)
showToast(`คัดลอก "${title}" ลง Clipboard แล้ว!`)
} catch {
alert('ไม่สามารถคัดลอกลง Clipboard ได้ กรุณาคัดลอกด้วยตนเอง')
}
}
// Open Create Modal
const handleOpenCreate = () => {
setEditingPrompt(null)
setModalTitle('')
setModalCategory('custom')
setModalDescription('')
setModalTags('')
setModalContent('')
setIsModalOpen(true)
}
// Open Edit Modal
const handleOpenEdit = (item: LlmPrompt) => {
setEditingPrompt(item)
setModalTitle(item.title)
setModalCategory(item.category)
setModalDescription(item.description || '')
setModalTags(Array.isArray(item.tags) ? item.tags.join(', ') : '')
setModalContent(item.content)
setIsModalOpen(true)
}
// Save (Create or Update to Database)
const handleSaveModal = async () => {
if (!modalTitle.trim()) {
alert('กรุณาระบุชื่อ Prompt')
return
}
if (!modalContent.trim()) {
alert('กรุณาระบุเนื้อหา Prompt')
return
}
const tagList = modalTags
.split(',')
.map(t => t.trim())
.filter(Boolean)
try {
setSaving(true)
const token = getAdminToken()
if (editingPrompt) {
// Update DB
const res = await fetch(`${API_BASE}/llm-prompts/${editingPrompt.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
title: modalTitle.trim(),
category: modalCategory,
description: modalDescription.trim() || null,
tags: tagList,
content: modalContent
})
})
const result = await res.json()
if (!res.ok || !result.success) {
throw new Error(result.error || 'Failed to update prompt')
}
showToast(`บันทึกการแก้ไข "${modalTitle}" ลงฐานข้อมูลสำเร็จ`)
} else {
// Create in DB
const res = await fetch(`${API_BASE}/llm-prompts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
title: modalTitle.trim(),
category: modalCategory,
description: modalDescription.trim() || null,
tags: tagList,
content: modalContent,
isSystem: false
})
})
const result = await res.json()
if (!res.ok || !result.success) {
throw new Error(result.error || 'Failed to create prompt')
}
showToast(`สร้าง Prompt ใหม่ "${modalTitle}" ลงฐานข้อมูลสำเร็จ`)
}
setIsModalOpen(false)
await loadPrompts()
} catch (err: any) {
console.error('Error saving prompt:', err)
alert('เกิดข้อผิดพลาดในการบันทึก: ' + (err.message || 'Unknown error'))
} finally {
setSaving(false)
}
}
// Duplicate prompt
const handleDuplicate = async (item: LlmPrompt) => {
try {
const token = getAdminToken()
const res = await fetch(`${API_BASE}/llm-prompts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
title: `${item.title} (สำเนา)`,
category: item.category,
description: item.description,
tags: item.tags,
content: item.content,
isSystem: false
})
})
const result = await res.json()
if (!res.ok || !result.success) {
throw new Error(result.error || 'Failed to duplicate prompt')
}
showToast(`ทำสำเนา "${item.title}" ลงฐานข้อมูลสำเร็จ`)
await loadPrompts()
} catch (err: any) {
console.error('Error duplicating prompt:', err)
alert('เกิดข้อผิดพลาด: ' + (err.message || 'Unknown error'))
}
}
// Delete prompt from Database
const handleDelete = async (item: LlmPrompt) => {
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบ Prompt "${item.title}" ออกจากฐานข้อมูล?`)) return
try {
const token = getAdminToken()
const res = await fetch(`${API_BASE}/llm-prompts/${item.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`
}
})
const result = await res.json()
if (!res.ok || !result.success) {
throw new Error(result.error || 'Failed to delete prompt')
}
showToast(`ลบ "${item.title}" ออกจากฐานข้อมูลเรียบร้อยแล้ว`)
await loadPrompts()
} catch (err: any) {
console.error('Error deleting prompt:', err)
alert('เกิดข้อผิดพลาดในการลบ: ' + (err.message || 'Unknown error'))
}
}
// Reset to default presets in Database
const handleResetPresets = async () => {
if (!confirm('ต้องการกู้คืนชุด Prompt มาตรฐานของระบบใช่หรือไม่? (Prompt ที่สร้างเองจะไม่สูญหาย)')) return
try {
const token = getAdminToken()
const res = await fetch(`${API_BASE}/llm-prompts/reset-presets`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`
}
})
const result = await res.json()
if (!res.ok || !result.success) {
throw new Error(result.error || 'Failed to reset presets')
}
showToast('คืนค่าชุด Prompt มาตรฐานในฐานข้อมูลเรียบร้อยแล้ว')
await loadPrompts()
} catch (err: any) {
console.error('Error resetting presets:', err)
alert('เกิดข้อผิดพลาด: ' + (err.message || 'Unknown error'))
}
}
// Export JSON
const handleExportJson = () => {
const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(prompts, null, 2))
const downloadAnchor = document.createElement('a')
downloadAnchor.setAttribute('href', dataStr)
downloadAnchor.setAttribute('download', `homaroma_llm_prompts_db_${new Date().toISOString().slice(0, 10)}.json`)
document.body.appendChild(downloadAnchor)
downloadAnchor.click()
downloadAnchor.remove()
showToast('ส่งออกไฟล์ Prompts JSON สำเร็จ')
}
// Import JSON into Database
const handleImportJson = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = async (event) => {
try {
const imported = JSON.parse(event.target?.result as string)
if (!Array.isArray(imported)) {
alert('ไฟล์ JSON ไม่ถูกต้อง: ข้อมูลต้องเป็น Array ของ Prompts')
return
}
const validItems = imported.filter(item => item && item.title && item.content)
if (validItems.length === 0) {
alert('ไม่พบข้อมูล Prompt ที่ถูกต้องในไฟล์')
return
}
const token = getAdminToken()
const res = await fetch(`${API_BASE}/llm-prompts/bulk-import`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(validItems)
})
const result = await res.json()
if (!res.ok || !result.success) {
throw new Error(result.error || 'Failed to import prompts')
}
showToast(`นำเข้าและบันทึกลง Database สำเร็จ ${result.count || validItems.length} รายการ`)
await loadPrompts()
} catch (err: any) {
alert('เกิดข้อผิดพลาดในการนำเข้าไฟล์ JSON: ' + (err as Error).message)
}
}
reader.readAsText(file)
if (fileInputRef.current) fileInputRef.current.value = ''
}
// Categories config
const CATEGORIES = [
{ key: 'all', label: 'ทั้งหมด', icon: Layers },
{ key: 'navigation', label: 'เมนู & Navigation', icon: Compass },
{ key: 'catalog', label: 'สินค้า & แคตตาล็อก', icon: Package },
{ key: 'theme', label: 'ธีม & UI Tokens', icon: Palette },
{ key: 'copywriting', label: 'คำบรรยาย & Story', icon: FileText },
{ key: 'cms', label: 'CMS & Page Builder', icon: Code2 },
{ key: 'translation', label: 'การแปลภาษา', icon: Globe },
{ key: 'custom', label: 'สร้างเอง (Custom)', icon: Sliders },
]
// Filtered prompts
const filteredPrompts = useMemo(() => {
return prompts.filter(item => {
if (selectedCategory !== 'all' && item.category !== selectedCategory) {
return false
}
if (selectedTag && (!Array.isArray(item.tags) || !item.tags.includes(selectedTag))) {
return false
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase()
const matchTitle = item.title.toLowerCase().includes(q)
const matchDesc = item.description?.toLowerCase().includes(q)
const matchContent = item.content.toLowerCase().includes(q)
const matchTags = Array.isArray(item.tags) && item.tags.some(t => t.toLowerCase().includes(q))
return matchTitle || matchDesc || matchContent || matchTags
}
return true
})
}, [prompts, selectedCategory, selectedTag, searchQuery])
// Collect all unique tags for filter badges
const allTags = useMemo(() => {
const set = new Set<string>()
prompts.forEach(p => {
if (Array.isArray(p.tags)) {
p.tags.forEach(t => set.add(t))
}
})
return Array.from(set)
}, [prompts])
const getCategoryColor = (cat: string) => {
switch (cat) {
case 'navigation':
return 'text-blue-400 bg-blue-500/10 border-blue-500/30'
case 'catalog':
return 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
case 'theme':
return 'text-purple-400 bg-purple-500/10 border-purple-500/30'
case 'copywriting':
return 'text-amber-400 bg-amber-500/10 border-amber-500/30'
case 'cms':
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30'
case 'translation':
return 'text-indigo-400 bg-indigo-500/10 border-indigo-500/30'
default:
return 'text-rose-400 bg-rose-500/10 border-rose-500/30'
}
}
return (
<div className="space-y-6 max-w-7xl mx-auto p-6 text-white">
{/* Toast Notification */}
{toastMessage && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-3 bg-cyan-500 text-night-950 font-bold rounded-xl shadow-2xl shadow-cyan-500/30 animate-bounce">
<CheckCircle2 className="w-5 h-5" />
<span>{toastMessage}</span>
</div>
)}
{/* Header Banner */}
<div className="bg-night-800/80 border border-white/10 rounded-2xl p-6 backdrop-blur-xl shadow-xl shadow-black/40 relative overflow-hidden">
<div className="absolute top-0 right-0 w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none -mr-20 -mt-20" />
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-gradient-to-br from-cyan-500/20 to-blue-600/20 border border-cyan-500/30 text-cyan-400">
<Sparkles className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2">
User LLM Prompts & Directives
<span className="text-xs px-2.5 py-0.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 text-cyan-300 font-mono flex items-center gap-1">
<Database className="w-3 h-3 text-cyan-400" />
{prompts.length} Prompts (PostgreSQL)
</span>
</h1>
<p className="text-sm text-slate-400 mt-0.5">
คลังเก็บและจัดการ System Prompts สำหรับ AI/LLM บันทึกตรงลง Database สำหรับสร้างและเชื่อมโยงข้อมูลกับระบบ e-Commerce
</p>
</div>
</div>
</div>
{/* Top Actions */}
<div className="flex items-center flex-wrap gap-2.5">
<button
onClick={handleOpenCreate}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-night-950 font-bold rounded-xl text-sm shadow-lg shadow-cyan-500/20 transition-all cursor-pointer"
>
<Plus className="w-4 h-4 stroke-[3]" />
<span>สร้าง Prompt ใหม่</span>
</button>
<button
onClick={loadPrompts}
title="รีเฟรชข้อมูลจาก Database"
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 hover:text-white rounded-xl text-sm font-medium transition-all cursor-pointer"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin text-cyan-400' : ''}`} />
</button>
<button
onClick={handleExportJson}
title="ส่งออก Prompts ทั้งหมดเป็น JSON"
className="flex items-center gap-1.5 px-3 py-2 bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 hover:text-white rounded-xl text-sm font-medium transition-all cursor-pointer"
>
<Download className="w-4 h-4" />
<span>ส่งออก</span>
</button>
<label
title="นำเข้าไฟล์ JSON ของ Prompts เข้า Database"
className="flex items-center gap-1.5 px-3 py-2 bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 hover:text-white rounded-xl text-sm font-medium transition-all cursor-pointer"
>
<Upload className="w-4 h-4" />
<span>นำเข้า</span>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImportJson}
className="hidden"
/>
</label>
<button
onClick={handleResetPresets}
title="กู้คืน Prompt มาตรฐานทั้งหมดใน Database"
className="flex items-center gap-1.5 px-3 py-2 bg-white/5 hover:bg-white/10 border border-white/10 text-slate-400 hover:text-white rounded-xl text-sm font-medium transition-all cursor-pointer"
>
<RotateCcw className="w-4 h-4" />
<span>คืนค่าระบบ</span>
</button>
</div>
</div>
</div>
{/* Search & Category Filter Navigation */}
<div className="space-y-3">
{/* Search Bar */}
<div className="relative">
<Search className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="ค้นหาตามชื่อ Prompt, ข้อความคำสั่ง, หมวดหมู่ หรือแท็ก..."
className="w-full bg-night-800/90 border border-white/10 rounded-xl pl-12 pr-10 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 shadow-inner"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white p-1"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Categories Tab Navigation */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-thin">
{CATEGORIES.map(cat => {
const Icon = cat.icon
const isActive = selectedCategory === cat.key
const count = cat.key === 'all'
? prompts.length
: prompts.filter(p => p.category === cat.key).length
return (
<button
key={cat.key}
onClick={() => setSelectedCategory(cat.key)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold whitespace-nowrap transition-all cursor-pointer border ${
isActive
? 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40 shadow-sm shadow-cyan-500/10'
: 'bg-night-800/60 text-slate-400 hover:text-white hover:bg-night-800 border-white/5'
}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{cat.label}</span>
<span className={`px-1.5 py-0.2 rounded-full text-[10px] ${
isActive ? 'bg-cyan-500/30 text-white' : 'bg-white/5 text-slate-500'
}`}>
{count}
</span>
</button>
)
})}
</div>
{/* Tag Filters (if any) */}
{allTags.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap pt-1">
<span className="text-xs text-slate-500 flex items-center gap-1 mr-1">
<Tag className="w-3 h-3" /> แท็ก:
</span>
{selectedTag && (
<button
onClick={() => setSelectedTag(null)}
className="px-2 py-0.5 rounded-lg text-xs bg-rose-500/20 text-rose-300 border border-rose-500/30 hover:bg-rose-500/30 cursor-pointer"
>
ล้างตัวกรอง ({selectedTag}) ✕
</button>
)}
{allTags.map(tag => (
<button
key={tag}
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`px-2 py-0.5 rounded-lg text-[11px] font-medium transition-all cursor-pointer border ${
selectedTag === tag
? 'bg-cyan-500 text-night-950 font-bold border-cyan-400'
: 'bg-white/5 text-slate-400 hover:text-white border-white/5'
}`}
>
#{tag}
</button>
))}
</div>
)}
</div>
{/* Loading state */}
{loading ? (
<div className="bg-night-800/40 border border-white/10 rounded-2xl p-16 text-center space-y-3">
<Loader2 className="w-8 h-8 animate-spin text-cyan-400 mx-auto" />
<p className="text-sm text-slate-400">กำลังโหลด Prompts จากฐานข้อมูล...</p>
</div>
) : filteredPrompts.length === 0 ? (
<div className="bg-night-800/40 border border-dashed border-white/10 rounded-2xl p-12 text-center space-y-3">
<div className="w-12 h-12 rounded-full bg-white/5 mx-auto flex items-center justify-center text-slate-500">
<Sparkles className="w-6 h-6" />
</div>
<h3 className="text-base font-semibold text-slate-300">ไม่พบ Prompt ที่ตรงกับเงื่อนไข</h3>
<p className="text-xs text-slate-500 max-w-sm mx-auto">
ลองปรับเปลี่ยนคำค้นหา หรือเลือกหมวดหมู่อื่น หรือกดปุ่ม "สร้าง Prompt ใหม่" ด้านบน
</p>
{(searchQuery || selectedCategory !== 'all' || selectedTag) && (
<button
onClick={() => {
setSearchQuery('')
setSelectedCategory('all')
setSelectedTag(null)
}}
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-cyan-400 rounded-xl text-xs font-semibold cursor-pointer"
>
ล้างตัวกรองทั้งหมด
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{filteredPrompts.map((item) => {
const lines = item.content.split('\n').length
const chars = item.content.length
return (
<div
key={item.id}
className="bg-night-800/80 border border-white/10 hover:border-cyan-500/30 rounded-2xl p-5 flex flex-col justify-between backdrop-blur-sm transition-all duration-200 hover:shadow-xl hover:shadow-cyan-500/5 group"
>
{/* Top Section */}
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className={`px-2.5 py-0.5 rounded-lg text-xs font-semibold border ${getCategoryColor(item.category)} uppercase tracking-wider`}>
{item.category}
</span>
{item.isSystem ? (
<span className="text-[10px] px-2 py-0.5 rounded bg-white/5 border border-white/10 text-slate-400 font-mono">
System Built-in
</span>
) : (
<span className="text-[10px] px-2 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 font-mono">
User Saved (DB)
</span>
)}
</div>
<div>
<h3 className="text-base font-bold text-white group-hover:text-cyan-300 transition-colors line-clamp-1">
{item.title}
</h3>
<p className="text-xs text-slate-400 line-clamp-2 mt-1 min-h-[32px]">
{item.description || 'ไม่มีคำอธิบาย'}
</p>
</div>
{/* Tags */}
{Array.isArray(item.tags) && item.tags.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{item.tags.map(tag => (
<span
key={tag}
onClick={() => setSelectedTag(tag)}
className="text-[10px] px-1.5 py-0.5 rounded bg-white/5 text-slate-400 hover:text-white cursor-pointer"
>
#{tag}
</span>
))}
</div>
)}
{/* Content Preview Box */}
<div
onClick={() => setViewingPrompt(item)}
className="p-3 bg-night-950/80 border border-white/5 rounded-xl cursor-pointer hover:border-cyan-500/20 transition-colors relative group/preview"
>
<pre className="text-[11px] font-mono text-slate-400 line-clamp-4 whitespace-pre-wrap break-words leading-relaxed">
{item.content}
</pre>
<div className="absolute inset-0 bg-gradient-to-t from-night-950/90 via-night-950/20 to-transparent rounded-xl flex items-end justify-center pb-1 opacity-0 group-hover/preview:opacity-100 transition-opacity">
<span className="text-[10px] font-semibold text-cyan-400 bg-night-900/90 px-2 py-0.5 rounded-md border border-cyan-500/30">
คลิกเพื่อดูเต็มจอ
</span>
</div>
</div>
{/* Stats */}
<div className="flex items-center justify-between text-[11px] text-slate-500 font-mono pt-1">
<span>{lines} บรรทัด • {chars.toLocaleString()} ตัวอักษร</span>
<span>{new Date(item.updatedAt).toLocaleDateString()}</span>
</div>
</div>
{/* Bottom Actions */}
<div className="pt-4 border-t border-white/5 flex items-center justify-between gap-2 mt-3">
{/* Copy Button (Primary) */}
<button
onClick={() => handleCopyPrompt(item.content, item.title)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 hover:text-cyan-200 border border-cyan-500/30 rounded-xl text-xs font-semibold transition-all cursor-pointer"
>
<Copy className="w-3.5 h-3.5" />
<span>Copy Prompt</span>
</button>
{/* Secondary Actions */}
<div className="flex items-center gap-1">
<button
onClick={() => handleOpenEdit(item)}
title="แก้ไข Prompt"
className="p-2 text-slate-400 hover:text-white hover:bg-white/5 rounded-lg transition-colors cursor-pointer"
>
<Edit3 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDuplicate(item)}
title="ทำสำเนา (Duplicate)"
className="p-2 text-slate-400 hover:text-white hover:bg-white/5 rounded-lg transition-colors cursor-pointer"
>
<CopyPlus className="w-3.5 h-3.5" />
</button>
{!item.isSystem && (
<button
onClick={() => handleDelete(item)}
title="ลบ Prompt ออกจาก Database"
className="p-2 text-rose-400/80 hover:text-rose-400 hover:bg-rose-500/10 rounded-lg transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
{/* ── View Modal (Inspect Prompt) ─────────────────────────────────── */}
{viewingPrompt && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in">
<div className="bg-night-900 border border-white/10 rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
<div className="p-5 border-b border-white/10 flex items-center justify-between bg-night-800/50">
<div className="flex items-center gap-3">
<span className={`px-2.5 py-0.5 rounded-lg text-xs font-semibold border ${getCategoryColor(viewingPrompt.category)} uppercase`}>
{viewingPrompt.category}
</span>
<h3 className="text-lg font-bold text-white">{viewingPrompt.title}</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleCopyPrompt(viewingPrompt.content, viewingPrompt.title)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-cyan-500 hover:bg-cyan-400 text-night-950 font-bold rounded-lg text-xs transition-colors cursor-pointer"
>
<Copy className="w-3.5 h-3.5" />
<span>Copy Prompt</span>
</button>
<button
onClick={() => setViewingPrompt(null)}
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-5 overflow-y-auto space-y-4 flex-1">
{viewingPrompt.description && (
<p className="text-sm text-slate-400 bg-white/5 p-3 rounded-xl border border-white/5">
💡 {viewingPrompt.description}
</p>
)}
<div className="relative">
<pre className="font-mono text-xs text-slate-200 bg-night-950 p-4 rounded-xl border border-white/10 whitespace-pre-wrap break-words leading-relaxed select-all max-h-[55vh] overflow-y-auto">
{viewingPrompt.content}
</pre>
</div>
</div>
<div className="p-4 border-t border-white/10 bg-night-800/30 flex items-center justify-between text-xs text-slate-400 font-mono">
<span>{viewingPrompt.content.length.toLocaleString()} ตัวอักษร • {viewingPrompt.content.split('\n').length} บรรทัด</span>
<button
onClick={() => {
const target = viewingPrompt
setViewingPrompt(null)
handleOpenEdit(target)
}}
className="text-cyan-400 hover:underline cursor-pointer"
>
เปิดโหมดแก้ไข Prompt นี้
</button>
</div>
</div>
</div>
)}
{/* ── Create / Edit Modal ────────────────────────────────────────── */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in">
<div className="bg-night-900 border border-white/10 rounded-2xl w-full max-w-3xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
{/* Modal Header */}
<div className="p-5 border-b border-white/10 flex items-center justify-between bg-night-800/50">
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<Sparkles className="w-5 h-5 text-cyan-400" />
<span>{editingPrompt ? 'แก้ไข Prompt ใน Database' : 'สร้าง Prompt ใหม่ลง Database'}</span>
</h3>
<button
onClick={() => setIsModalOpen(false)}
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Form */}
<div className="p-6 overflow-y-auto space-y-4 flex-1">
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
ชื่อ Prompt <span className="text-rose-400">*</span>
</label>
<input
type="text"
value={modalTitle}
onChange={(e) => setModalTitle(e.target.value)}
placeholder="เช่น Mega Menu Generator สำหรับเทศกาลปีใหม่"
className="w-full bg-night-950 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
หมวดหมู่
</label>
<select
value={modalCategory}
onChange={(e) => setModalCategory(e.target.value as LlmPrompt['category'])}
className="w-full bg-night-950 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-cyan-500"
>
<option value="navigation">เมนู & Navigation</option>
<option value="catalog">สินค้า & แคตตาล็อก</option>
<option value="theme">ธีม & UI Tokens</option>
<option value="copywriting">คำบรรยาย & Story</option>
<option value="cms">CMS & Page Builder</option>
<option value="translation">การแปลภาษา</option>
<option value="custom">สร้างเอง (Custom)</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
แท็ก (คั่นด้วยจุลภาค)
</label>
<input
type="text"
value={modalTags}
onChange={(e) => setModalTags(e.target.value)}
placeholder="เช่น Menu, JSON, Luxury, Spring"
className="w-full bg-night-950 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
คำอธิบายสรุป
</label>
<input
type="text"
value={modalDescription}
onChange={(e) => setModalDescription(e.target.value)}
placeholder="อธิบายสั้นๆ เกี่ยวกับวัตถุประสงค์ของ Prompt นี้"
className="w-full bg-night-950 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-semibold text-slate-300 uppercase tracking-wider">
เนื้อหา Prompt (System Directives) <span className="text-rose-400">*</span>
</label>
<span className="text-[11px] font-mono text-slate-400">
{modalContent.length.toLocaleString()} chars • {modalContent.split('\n').length} lines
</span>
</div>
<textarea
value={modalContent}
onChange={(e) => setModalContent(e.target.value)}
rows={12}
placeholder="ระบุคำสั่ง คำแนะนำ หรือ JSON Schema สำหรับป้อนให้ AI..."
className="w-full font-mono text-xs bg-night-950 border border-white/10 rounded-xl p-4 text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-500 leading-relaxed resize-y"
/>
</div>
</div>
{/* Modal Footer */}
<div className="p-4 border-t border-white/10 bg-night-800/30 flex items-center justify-end gap-3">
<button
onClick={() => setIsModalOpen(false)}
disabled={saving}
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-slate-300 rounded-xl text-sm font-medium transition-colors cursor-pointer"
>
ยกเลิก
</button>
<button
onClick={handleSaveModal}
disabled={saving}
className="flex items-center gap-2 px-5 py-2 bg-cyan-500 hover:bg-cyan-400 text-night-950 font-bold rounded-xl text-sm shadow-lg shadow-cyan-500/20 transition-all cursor-pointer disabled:opacity-50"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
<span>{saving ? 'กำลังบันทึกลง Database...' : 'บันทึก Prompt'}</span>
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "LlmPrompt" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"category" TEXT NOT NULL DEFAULT 'custom',
"description" TEXT,
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"content" TEXT NOT NULL,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LlmPrompt_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "LlmPrompt_category_idx" ON "LlmPrompt"("category");
-- CreateIndex
CREATE INDEX "LlmPrompt_isSystem_idx" ON "LlmPrompt"("isSystem");
+15
View File
@@ -363,6 +363,21 @@ model BrandKnowledge {
updatedAt DateTime @updatedAt
}
model LlmPrompt {
id Int @id @default(autoincrement())
title String
category String @default("custom")
description String?
tags String[] @default([])
content String
isSystem Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
@@index([isSystem])
}
model Order {
id Int @id @default(autoincrement())
channel String @default("ONLINE") // "ONLINE" | "POS"
+2 -1
View File
@@ -34,6 +34,7 @@ import { toolsRoute } from "./routes/tools";
import { crmRoute } from "./routes/crm";
import { brandKnowledgeRoute } from "./routes/brandKnowledge";
import { categorySectionsRoute } from "./routes/categorySections";
import { llmPromptsRoute } from "./routes/llmPrompts";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
@@ -141,7 +142,7 @@ const app = new Elysia()
return file;
})
.group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute)
app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute).use(llmPromptsRoute)
)
.listen({ port: PORT, hostname: "0.0.0.0" });
+416
View File
@@ -0,0 +1,416 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const DEFAULT_LLM_PROMPTS = [
{
title: "Mega Menu JSON Tree Generator",
category: "navigation",
description: "สร้างโครงสร้างเมนู Header Navigation แบบ Multi-level (Array of Nodes) พร้อม labels ทุกภาษา, href, icon และ badges",
tags: ["Menu", "JSON", "Navigation", "Multilingual"],
isSystem: true,
content: `คุณคือ Senior Full-Stack Navigation & UX Architect สำหรับแพลตฟอร์ม e-Commerce หรู (DBK Platform / HomAroma)
จงออกแบบโครงสร้างเมนู Mega Menu (Main Navigation) ในรูปแบบ JSON Array ตามสเปกด้านล่างนี้ โดยไม่ต้องใส่ Markdown block ครอบ หรือคำอธิบายเสริมใดๆ ให้ตอบเฉพาะ JSON Array เท่านั้น
### โครงสร้างข้อมูล Node แต่ละตัว (MenuItem):
[
{
"id": "menu-unique-id", // string เช่น "nav-scents", "nav-candles"
"label": "English Display Name",
"href": "/shop/category-slug", // URL ภายในเว็บ หรือ # หากมี dropdown
"description": "คำบรรยายสั้นๆ อธิบายหมวดหมู่",
"target": "_self", // "_self" หรือ "_blank"
"badge": "NEW", // ตัวเลือก: null หรือ "NEW", "HOT", "SALE", "BESTSELLER"
"icon": "Sparkles", // Lucide icon เช่น "Flame", "Droplets", "Wind", "Sparkles", "Package"
"displayMode": "mega", // "mega" (กล่องกว้าง 3-4 คอลัมน์) หรือ "dropdown" (เมนูลงมาธรรมดา)
"columns": 3, // จำนวนคอลัมน์ของเมนูย่อย (2, 3 หรือ 4)
"translations": {
"th": { "label": "ชื่อภาษาไทย", "description": "คำบรรยายภาษาไทย" },
"en": { "label": "English Name", "description": "English description" }
},
"children": [
// โครงสร้างลูกแบบเดียวกัน มี children ซ้อนลงไปได้หลายระดับ
]
}
]
### ข้อกำหนด:
1. รองรับการจัดกลุ่มสินค้าอย่างเป็นระเบียบ แบ่งเป็น Header กลุ่มย่อย และไอเทมลูก
2. ทุกรายการควรมี translations ภาษาไทย (th) และอังกฤษ (en) ครบถ้วน
3. ตอบกลับเฉพาะ Pure JSON Array ที่ถูกต้องตามมาตรฐาน JSON เท่านั้น`
},
{
title: "Product Catalog & Scent Formulations Generator",
category: "catalog",
description: "สร้างแคตตาล็อกสินค้าเครื่องหอม เทียนหอม รูมสเปรย์ พร้อม Note กลิ่น (Top/Heart/Base), SKU, ราคา และ Attribute Options",
tags: ["Products", "Catalog", "Aromatherapy", "SKU"],
isSystem: true,
content: `คุณคือ Senior Perfumer และ E-commerce Catalog Specialist เชี่ยวชาญการสร้างข้อมูลผลิตภัณฑ์เครื่องหอมระดับ Luxury & Niche Fragrance
จงช่วยสร้างข้อมูลรายการสินค้า (Products) ในรูปแบบ JSON Array เพื่อนำเข้าสู่ระบบหลังบ้าน DBK Platform
### ข้อมูลสินค้าที่ต้องการในแต่ละ Object:
{
"name": "ชื่อสินค้าภาษาอังกฤษ",
"nameTh": "ชื่อสินค้าภาษาไทย",
"slug": "product-url-slug",
"sku": "HMA-CAT-001",
"categorySlug": "scented-candles",
"price": 890,
"originalPrice": 1290,
"inStock": true,
"stockCount": 50,
"shortDescription": "คำบรรยายสั้นน่าดึงดูด 1-2 ประโยค",
"scentProfile": {
"intensity": "Moderate", // Subtle, Moderate, Strong, Intense
"family": "Woody & Amber", // Floral, Citrus, Woody, Oriental, Fresh
"topNotes": ["Bergamot", "Pink Pepper"],
"heartNotes": ["Atlas Cedar", "Smoked Oud"],
"baseNotes": ["Ambergris", "Madagascar Vanilla"]
},
"attributes": [
{ "name": "Size / Net Weight", "value": "220g / 7.7 oz" },
{ "name": "Burn Time", "value": "Approx. 50-55 Hours" },
{ "name": "Wax Type", "value": "100% Natural Soy & Coconut Wax Blend" }
],
"tags": ["Bestseller", "Relaxation", "Handmade"]
}
### เงื่อนไข:
- อารมณ์ของกลิ่นต้องมีความประณีต อธิบายโน้ตกลิ่นชัดเจนและเป็นไปได้จริง
- ตอบกลับเฉพาะ JSON Array เท่านั้น`
},
{
title: "Dynamic Theme & Color Tokens Generator",
category: "theme",
description: "สร้าง Color Palette JSON สำหรับระบบ Dynamic Theme Tokens (Light/Dark Mode, Glows, Borders, Surfaces)",
tags: ["Theme", "Colors", "Tokens", "UI/UX"],
isSystem: true,
content: `คุณคือ Senior UI/UX Designer และ Design System Architect ระดับมืออาชีพ เชี่ยวชาญการออกแบบ Color Palette, Dark/Light Theme System, และ Visual Identity ให้กับร้านค้าออนไลน์ระดับ Luxury, Minimalist, Cyberpunk, หรือ Modern Lifestyle
กรุณาสร้าง JSON Configuration สำหรับ Theme Tokens ของระบบ e-Commerce ตามสเปกด้านล่าง:
### สเปก JSON Tokens:
{
"themeName": "ชื่อธีม (เช่น Cyberpunk Amber, Midnight Velvet, Nordic Forest)",
"mode": "dark", // "dark" หรือ "light"
"description": "คำอธิบาย Mood & Tone สั้นๆ",
"tokens": {
"primary": "#06b6d4",
"primaryLight": "#22d3ee",
"primaryDark": "#0891b2",
"secondary": "#a855f7",
"background": "#0b0f19",
"surface": "#111827",
"surfaceAlt": "#1f2937",
"text": "#f9fafb",
"textMuted": "#9ca3af",
"border": "#374151",
"glow": "rgba(6, 182, 212, 0.4)",
"fontFamilyHeading": "'Plus Jakarta Sans', sans-serif",
"fontFamilyBody": "'Inter', sans-serif",
"borderRadius": "0.75rem"
}
}
### กฎสำคัญ:
1. สีต้องมีความกลมกลืน (Harmonious contrast ratio) ผ่านเกณฑ์ WCAG AA สำหรับ Text
2. คืนค่าเฉพาะ Valid JSON Object เท่านั้น ห้ามใส่คำบรรยายอื่นนอก JSON`
},
{
title: "Luxury Scent Storytelling & Copywriting",
category: "copywriting",
description: "เขียนคำบรรยายกลิ่นแบบวรรณกรรม Luxury สร้างภาพจินตนาการ พร้อมเรื่องราว บรรยากาศ (Mood & Atmosphere) และเวลาที่เหมาะสม",
tags: ["Copywriting", "Luxury", "Storytelling", "Marketing"],
isSystem: true,
content: `คุณคือ Creative Director และ Luxury Fragrance Copywriter ระดับสากล (สไตล์ Diptyque, Le Labo, Byredo, Jo Malone)
ช่วยเขียนบทบรรยายสินค้ากลิ่นหอมสำหรับ:
- **ชื่อกลิ่น**: [ระบุชื่อกลิ่น]
- **บุคลิกของกลิ่น**: [ระบุ เช่น อบอุ่น สงบ ลึกลับ สดชื่นยามเช้า]
- **กลุ่มเป้าหมาย**: ลูกค้าที่มีรสนิยม มองหาความผ่อนคลายและสร้างบรรยากาศพิเศษในบ้าน
### โครงสร้างเนื้อหาที่ต้องการ:
1. **Hook Title / Headline**: พาดหัวชวนฝัน 1 ประโยคที่สร้างอารมณ์ในทันที
2. **Scent Narrative (120-150 คำ)**: เล่าเรื่องบรรยากาศ เวลา ความทรงจำ และความรู้สึกเมื่อกลิ่นนี้ลอยฟุ้งในห้อง
3. **Olfactory Pyramid (ปิรามิดกลิ่น)**:
- **Top Notes**: (กลิ่นแรกที่สัมผัส - ชัดเจน สดชื่น หรือดึงดูด)
- **Heart Notes**: (หัวใจของกลิ่น - ลึกซึ้ง มีเอกลักษณ์)
- **Base Notes**: (กลิ่นติดตรึง - อบอุ่น ติดทนนาน)
4. **Ideal Setting & Moment**: เวลาและสถานที่ที่เหมาะที่สุด (เช่น ฝนตกยามค่ำคืน, อ่านหนังสือมุมโปรด, ห้องนอนก่อนนอน)
5. **Key Benefit & Mood**: ความรู้สึกที่ได้รับ (เช่น Calming, Uplifting, Grounding, Sensual)
ให้ใช้ภาษาไทยที่สละสลวย มีเสน่ห์ ชวนสัมผัสความอบอุ่น และแปลเวอร์ชันภาษาอังกฤษ (English) แนบมาด้วย`
},
{
title: "CMS Page Builder Section Payloads",
category: "cms",
description: "สร้าง Payload บล็อกเนื้อหาสำหรับ Page Builder เช่น HeroArticle, EditorialCarousel, ScentBreakdown, QuoteCallout",
tags: ["CMS", "PageBuilder", "Payload", "Blocks"],
isSystem: true,
content: `คุณคือ Headless CMS Architect เชี่ยวชาญการกำหนดโครงสร้าง Dynamic Page Sections สำหรับระบบ HomAroma Page Builder
จงสร้าง JSON Array ของ Block Sections สำหรับหน้ารายละเอียดหรือ Landing Page:
### รูปแบบ Section Type ที่ระบบรองรับ:
1. **HeroArticle**: { "type": "HeroArticle", "data": { "title": "...", "subtitle": "...", "ctaText": "...", "ctaLink": "...", "badge": "..." } }
2. **ScentNotesBreakdown**: { "type": "ScentNotesBreakdown", "data": { "topNotes": ["..."], "heartNotes": ["..."], "baseNotes": ["..."], "story": "..." } }
3. **QuoteCallout**: { "type": "QuoteCallout", "data": { "quote": "...", "author": "...", "role": "..." } }
4. **EditorialTile**: { "type": "EditorialTile", "data": { "heading": "...", "paragraph": "...", "alignment": "left" } }
5. **Accordion**: { "type": "Accordion", "data": { "items": [{ "question": "...", "answer": "..." }] } }
ตอบเฉพาะ JSON Array ของ Sections ที่สมบูรณ์ พร้อมรองรับหลายภาษาในแต่ละ Object`
},
{
title: "Multilingual JSON Translation & Localization",
category: "translation",
description: "แปลภาษา JSON Dictionary หรือ Object จากภาษาไทยเป็นอังกฤษ, จีน, ญี่ปุ่น โดยคงโครงสร้าง Key-Value ครบถ้วน",
tags: ["Translation", "i18n", "Localization", "JSON"],
isSystem: true,
content: `คุณคือ Professional Localization Specialist เชี่ยวชาญงานแปล e-Commerce หรูสำหรับภาษา ไทย (TH), อังกฤษ (EN), จีนตัวย่อ (ZH) และ ญี่ปุ่น (JA)
### คำสั่งงาน:
1. รับ JSON Object ต้นฉบับ
2. แปลเฉพาะส่วนของ Value ข้อความ โดย **ห้ามแก้ไข Key** และ **ห้ามเปลี่ยนโครงสร้าง Hierarchy** โดยเด็ดขาด
3. สำนวนการแปลต้องสุภาพ เป็นทางการ และมีระดับ (Luxury E-commerce Tone)
4. คงตัวแปร placeholder เช่น {{name}}, {{count}}, {price} ไว้ตามเดิม
5. ส่งผลลัพธ์กลับมาเป็น JSON Format เดียวกัน 100% ไม่ใส่คำอธิบายใดๆ เสริม`
}
];
// Helper to auto-seed initial presets if DB is empty
async function ensureDefaultPrompts() {
const count = await prisma.llmPrompt.count();
if (count === 0) {
for (const item of DEFAULT_LLM_PROMPTS) {
await prisma.llmPrompt.create({
data: {
title: item.title,
category: item.category,
description: item.description,
tags: item.tags,
content: item.content,
isSystem: item.isSystem,
},
});
}
}
}
export const llmPromptsRoute = new Elysia({ prefix: "/llm-prompts" })
// GET all prompts
.get("/", async () => {
await ensureDefaultPrompts();
const prompts = await prisma.llmPrompt.findMany({
orderBy: [
{ isSystem: "desc" },
{ id: "desc" },
],
});
return prompts;
})
// GET prompt by id
.get("/:id", async ({ params: { id }, set }) => {
const promptId = parseInt(id, 10);
if (isNaN(promptId)) {
set.status = 400;
return { error: "Invalid prompt ID" };
}
const prompt = await prisma.llmPrompt.findUnique({
where: { id: promptId },
});
if (!prompt) {
set.status = 404;
return { error: "Prompt not found" };
}
return prompt;
})
// POST create new prompt
.use(adminOnly)
.post(
"/",
async ({ body, set }) => {
try {
const { title, category, description, tags, content, isSystem } = body;
const newPrompt = await prisma.llmPrompt.create({
data: {
title,
category: category || "custom",
description: description || null,
tags: Array.isArray(tags) ? tags : [],
content,
isSystem: isSystem || false,
},
});
return { success: true, data: newPrompt };
} catch (err: any) {
console.error("[LlmPrompts] Create error:", err);
set.status = 500;
return { success: false, error: err.message || "Failed to create prompt" };
}
},
{
body: t.Object({
title: t.String({ minLength: 1 }),
category: t.Optional(t.String()),
description: t.Optional(t.Nullable(t.String())),
tags: t.Optional(t.Array(t.String())),
content: t.String({ minLength: 1 }),
isSystem: t.Optional(t.Boolean()),
}),
}
)
// PUT update prompt
.put(
"/:id",
async ({ params: { id }, body, set }) => {
try {
const promptId = parseInt(id, 10);
if (isNaN(promptId)) {
set.status = 400;
return { error: "Invalid prompt ID" };
}
const existing = await prisma.llmPrompt.findUnique({
where: { id: promptId },
});
if (!existing) {
set.status = 404;
return { error: "Prompt not found" };
}
const updated = await prisma.llmPrompt.update({
where: { id: promptId },
data: {
title: body.title !== undefined ? body.title : existing.title,
category: body.category !== undefined ? body.category : existing.category,
description: body.description !== undefined ? body.description : existing.description,
tags: body.tags !== undefined ? body.tags : existing.tags,
content: body.content !== undefined ? body.content : existing.content,
},
});
return { success: true, data: updated };
} catch (err: any) {
console.error("[LlmPrompts] Update error:", err);
set.status = 500;
return { success: false, error: err.message || "Failed to update prompt" };
}
},
{
body: t.Object({
title: t.Optional(t.String()),
category: t.Optional(t.String()),
description: t.Optional(t.Nullable(t.String())),
tags: t.Optional(t.Array(t.String())),
content: t.Optional(t.String()),
}),
}
)
// DELETE prompt
.delete("/:id", async ({ params: { id }, set }) => {
try {
const promptId = parseInt(id, 10);
if (isNaN(promptId)) {
set.status = 400;
return { error: "Invalid prompt ID" };
}
await prisma.llmPrompt.delete({
where: { id: promptId },
});
return { success: true, message: "Prompt deleted successfully" };
} catch (err: any) {
console.error("[LlmPrompts] Delete error:", err);
set.status = 500;
return { success: false, error: err.message || "Failed to delete prompt" };
}
})
// POST reset default presets
.post("/reset-presets", async ({ set }) => {
try {
// Upsert default presets
for (const item of DEFAULT_LLM_PROMPTS) {
const existing = await prisma.llmPrompt.findFirst({
where: { title: item.title, isSystem: true },
});
if (!existing) {
await prisma.llmPrompt.create({
data: {
title: item.title,
category: item.category,
description: item.description,
tags: item.tags,
content: item.content,
isSystem: true,
},
});
}
}
const allPrompts = await prisma.llmPrompt.findMany({
orderBy: [{ isSystem: "desc" }, { id: "desc" }],
});
return { success: true, data: allPrompts };
} catch (err: any) {
console.error("[LlmPrompts] Reset presets error:", err);
set.status = 500;
return { success: false, error: err.message || "Failed to reset presets" };
}
})
// POST bulk import
.post(
"/bulk-import",
async ({ body, set }) => {
try {
const items = body as Array<{
title: string;
category?: string;
description?: string;
tags?: string[];
content: string;
}>;
if (!Array.isArray(items) || items.length === 0) {
set.status = 400;
return { success: false, error: "Empty or invalid items array" };
}
const createdItems = [];
for (const item of items) {
if (!item.title || !item.content) continue;
const created = await prisma.llmPrompt.create({
data: {
title: item.title,
category: item.category || "custom",
description: item.description || null,
tags: Array.isArray(item.tags) ? item.tags : [],
content: item.content,
isSystem: false,
},
});
createdItems.push(created);
}
return { success: true, count: createdItems.length, data: createdItems };
} catch (err: any) {
console.error("[LlmPrompts] Bulk import error:", err);
set.status = 500;
return { success: false, error: err.message || "Failed to import prompts" };
}
},
{
body: t.Array(
t.Object({
title: t.String(),
category: t.Optional(t.String()),
description: t.Optional(t.Nullable(t.String())),
tags: t.Optional(t.Array(t.String())),
content: t.String(),
})
),
}
);
+1 -1
View File
@@ -1,6 +1,6 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
// Trigger reload: resilient position and contentWidth support
// Trigger reload: support landscape and wide aspect ratios for FEATURED_TILES and EDITORIAL_TILE
import {
LocalizedStringSchema,
HeroSectionPayloadSchema,
+2 -2
View File
@@ -180,7 +180,7 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
renderedFields: ["title", "description", "imageUrl", "imagePosition", "imageAspectRatio", "buttonText", "buttonLink", "backgroundColor", "textColor"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["imagePosition must be left or right.", "imageAspectRatio can be square, portrait, tall, or auto."],
warnings: ["imagePosition must be left or right.", "imageAspectRatio can be landscape, wide, square, portrait, tall, or auto."],
minimalPayload: {
title: localizedObjectExample,
description: localizedObjectExample,
@@ -1055,7 +1055,7 @@ const SECTION_TOOL_CONFIGS = [
sectionId: { type: "number", description: "The numeric ID of the section to update" },
imageUrl: { type: "string", description: "Plain string main image URL" },
imagePosition: { type: "string", description: "Plain string image position ('left' or 'right')" },
imageAspectRatio: { type: "string", description: "Plain string image aspect ratio ('square', 'portrait', 'tall', or 'auto')" },
imageAspectRatio: { type: "string", description: "Plain string image aspect ratio ('landscape', 'wide', 'square', 'portrait', 'tall', or 'auto')" },
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
description: { ...localizedStringSchema, description: `Localized description, for example ${localizedStringExample}` },
buttonText: { ...localizedStringSchema, description: `Localized button text, for example ${localizedStringExample}` },
@@ -13,6 +13,12 @@ export default function DynamicFeaturedTiles({ payload }: DynamicFeaturedTilesPr
const tiles = Array.isArray(payload.tiles) ? payload.tiles : [];
const columns = payload.columns || 2;
const layoutPreset = payload.layoutPreset;
const textAlign = payload.textAlign || (layoutPreset === 'editorial' ? 'left' : 'center');
const ctaStyle = payload.ctaStyle || (layoutPreset === 'editorial' ? 'arrow_link' : 'pill_button');
const sectionAspect = payload.imageAspectRatio || (layoutPreset === 'editorial' ? 'landscape' : undefined);
const imageRounded = payload.imageRounded || (layoutPreset === 'editorial' ? 'md' : '3xl');
const sectionTitle = getLocalizedField(payload.title, lang);
const sectionSubtitle = getLocalizedField(payload.subtitle, lang);
@@ -21,7 +27,7 @@ export default function DynamicFeaturedTiles({ payload }: DynamicFeaturedTilesPr
case 3:
return 'grid-cols-1 md:grid-cols-3';
case 4:
return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4';
return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4';
case 2:
default:
return 'grid-cols-1 md:grid-cols-2';
@@ -29,7 +35,12 @@ export default function DynamicFeaturedTiles({ payload }: DynamicFeaturedTilesPr
};
const getAspectClass = (ratio?: string) => {
switch (ratio) {
const r = ratio || sectionAspect || 'square';
switch (r) {
case 'landscape':
return 'aspect-[16/9]';
case 'wide':
return 'aspect-[4/3]';
case 'portrait':
return 'aspect-[3/4]';
case 'tall':
@@ -42,6 +53,32 @@ export default function DynamicFeaturedTiles({ payload }: DynamicFeaturedTilesPr
}
};
const getRoundedClass = (rounded?: string) => {
switch (rounded) {
case 'none':
return 'rounded-none';
case 'sm':
return 'rounded-md';
case 'md':
return 'rounded-xl';
case '2xl':
return 'rounded-2xl';
case 'full':
return 'rounded-full';
case '3xl':
default:
return 'rounded-3xl';
}
};
const isLeftAlign = textAlign === 'left';
const isRightAlign = textAlign === 'right';
const alignContainerClass = isLeftAlign
? 'items-start text-left'
: isRightAlign
? 'items-end text-right'
: 'items-center text-center';
return (
<section
className="ui-section-band w-full overflow-hidden"
@@ -66,16 +103,17 @@ export default function DynamicFeaturedTiles({ payload }: DynamicFeaturedTilesPr
)}
{/* Campaign Tiles Grid */}
<div className={`grid gap-8 md:gap-12 ${getGridColsClass(columns)}`}>
<div className={`grid gap-6 md:gap-8 lg:gap-10 ${getGridColsClass(columns)}`}>
{tiles.map((tile, idx) => {
const tileTitle = getLocalizedField(tile.title, lang);
const tileDescription = getLocalizedField(tile.description, lang);
const buttonText = getLocalizedField(tile.buttonText, lang);
const imageAspectRatio = tile.imageAspectRatio || sectionAspect || 'square';
const cardContent = (
<>
<div className={`group flex flex-col ${alignContainerClass} space-y-4 w-full`}>
{/* Image Container */}
<div className={`relative ${getAspectClass(tile.imageAspectRatio)} w-full overflow-hidden rounded-2xl bg-neutral-100 shadow-md`}>
<div className={`relative ${getAspectClass(imageAspectRatio)} w-full overflow-hidden ${getRoundedClass(imageRounded)} bg-neutral-100 shadow-md`}>
{tile.imageUrl ? (
<img
src={tile.imageUrl}
@@ -93,10 +131,9 @@ export default function DynamicFeaturedTiles({ payload }: DynamicFeaturedTilesPr
</div>
{/* Content Details */}
<div className="space-y-2 max-w-md px-2 flex-1 flex flex-col justify-between w-full">
<div className="space-y-2">
<div className={`space-y-2.5 w-full flex flex-col ${alignContainerClass}`}>
{tileTitle && (
<h3 className="font-serif text-xl md:text-2xl font-light tracking-wide text-neutral-800 group-hover:text-pop-pink transition-colors" style={{ color: payload.textColor }}>
<h3 className="font-serif text-lg md:text-xl font-normal tracking-wide text-neutral-800 group-hover:text-pop-pink transition-colors" style={{ color: payload.textColor }}>
{tileTitle}
</h3>
)}
@@ -105,32 +142,38 @@ export default function DynamicFeaturedTiles({ payload }: DynamicFeaturedTilesPr
{tileDescription}
</p>
)}
</div>
{/* CTA Button */}
{buttonText && tile.buttonLink && (
<div className="pt-3">
<span className="inline-block px-7 py-3 text-xs font-medium tracking-wider uppercase transition-all duration-300 bg-neutral-900 text-white group-hover:bg-neutral-800 rounded-sm shadow-sm">
{buttonText && ctaStyle !== 'none' && (
<div className={isLeftAlign ? 'pt-0.5' : 'pt-2'}>
{ctaStyle === 'arrow_link' ? (
<span className="inline-flex items-center gap-1.5 text-xs md:text-sm font-medium tracking-wide text-neutral-800 group-hover:text-pop-pink transition-colors" style={{ color: payload.textColor }}>
<span>{buttonText}</span>
<span className="transition-transform duration-200 group-hover:translate-x-1 font-sans">→</span>
</span>
) : ctaStyle === 'solid_button' ? (
<span className="inline-flex rounded-xl bg-neutral-900 text-white px-7 py-2.5 text-xs font-bold uppercase tracking-[0.2em] transition hover:bg-neutral-800 shadow-sm">
{buttonText}
</span>
) : (
<span className="inline-flex rounded-full border border-current px-7 py-2.5 text-xs font-bold uppercase tracking-[0.2em] transition hover:bg-neutral-900 hover:text-white">
{buttonText}
</span>
)}
</div>
)}
</div>
</>
</div>
);
return (
<div key={idx} className="flex flex-col items-center text-center group w-full">
{tile.buttonLink ? (
<Link to={tile.buttonLink} className="w-full flex flex-col items-center text-center space-y-5">
return tile.buttonLink ? (
<Link key={idx} to={tile.buttonLink} className="block w-full focus:outline-none">
{cardContent}
</Link>
) : (
<div className="w-full flex flex-col items-center text-center space-y-5">
<div key={idx} className="w-full">
{cardContent}
</div>
)}
</div>
);
})}
</div>
@@ -1705,12 +1705,18 @@ function FeaturedTilesSection({ payload, lang }: { payload: Record<string, unkno
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
const tiles = Array.isArray(payload.tiles) ? payload.tiles.map(toRecord) : []
const layoutPreset = typeof payload.layoutPreset === 'string' ? payload.layoutPreset : undefined
const textAlign = typeof payload.textAlign === 'string' ? payload.textAlign : (layoutPreset === 'editorial' ? 'left' : 'center')
const ctaStyle = typeof payload.ctaStyle === 'string' ? payload.ctaStyle : (layoutPreset === 'editorial' ? 'arrow_link' : 'pill_button')
const sectionAspect = typeof payload.imageAspectRatio === 'string' ? payload.imageAspectRatio : (layoutPreset === 'editorial' ? 'landscape' : undefined)
const imageRounded = typeof payload.imageRounded === 'string' ? payload.imageRounded : (layoutPreset === 'editorial' ? 'md' : '3xl')
const getColsClass = (cols: number) => {
switch (cols) {
case 3:
return 'grid-cols-1 md:grid-cols-3'
case 4:
return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'
return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4'
case 2:
default:
return 'grid-cols-1 md:grid-cols-2'
@@ -1718,7 +1724,12 @@ function FeaturedTilesSection({ payload, lang }: { payload: Record<string, unkno
}
const getAspectClass = (ratio?: string) => {
switch (ratio) {
const r = ratio || sectionAspect || 'square'
switch (r) {
case 'landscape':
return 'aspect-[16/9]'
case 'wide':
return 'aspect-[4/3]'
case 'portrait':
return 'aspect-[3/4]'
case 'tall':
@@ -1731,6 +1742,32 @@ function FeaturedTilesSection({ payload, lang }: { payload: Record<string, unkno
}
}
const getRoundedClass = (rounded?: string) => {
switch (rounded) {
case 'none':
return 'rounded-none'
case 'sm':
return 'rounded-md'
case 'md':
return 'rounded-xl'
case '2xl':
return 'rounded-2xl'
case 'full':
return 'rounded-full'
case '3xl':
default:
return 'rounded-3xl'
}
}
const isLeftAlign = textAlign === 'left'
const isRightAlign = textAlign === 'right'
const alignContainerClass = isLeftAlign
? 'items-start text-left'
: isRightAlign
? 'items-end text-right'
: 'items-center text-center'
return (
<section className="overflow-hidden bg-surface-page ui-section-band" style={{ backgroundColor, color: textColor }}>
<div className="mx-auto max-w-7xl space-y-12">
@@ -1746,52 +1783,70 @@ function FeaturedTilesSection({ payload, lang }: { payload: Record<string, unkno
</div>
)}
<div className={`grid gap-8 md:gap-12 ${getColsClass(columns)}`}>
<div className={`grid gap-6 md:gap-8 lg:gap-10 ${getColsClass(columns)}`}>
{tiles.map((tile, idx) => {
const tileTitle = localizedFromRecord(tile, 'title', resolvedLang)
const tileDesc = localizedFromRecord(tile, 'description', resolvedLang)
const buttonText = localizedFromRecord(tile, 'buttonText', resolvedLang)
const buttonLink = typeof tile.buttonLink === 'string' ? tile.buttonLink : ''
const imageUrl = typeof tile.imageUrl === 'string' ? tile.imageUrl : ''
const imageAspectRatio = typeof tile.imageAspectRatio === 'string' ? tile.imageAspectRatio : 'square'
const imageAspectRatio = typeof tile.imageAspectRatio === 'string' ? tile.imageAspectRatio : (sectionAspect || 'square')
return (
<div key={idx} className="group flex flex-col items-center text-center space-y-5">
const cardContent = (
<div className={`group flex flex-col ${alignContainerClass} space-y-4 w-full`}>
{imageUrl && (
<div className={`relative ${getAspectClass(imageAspectRatio)} w-full overflow-hidden rounded-3xl shadow-card`}>
<div className={`relative ${getAspectClass(imageAspectRatio)} w-full overflow-hidden ${getRoundedClass(imageRounded)} shadow-card bg-surface-card`}>
<Image
src={imageUrl}
alt={tileTitle || 'Campaign Card'}
fill
unoptimized
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
className="h-full w-full object-cover transition duration-700 ease-out group-hover:scale-105"
/>
</div>
)}
<div className="space-y-3">
<div className={`space-y-2.5 w-full flex flex-col ${alignContainerClass}`}>
{tileTitle && (
<h3 className="text-xl font-semibold leading-snug text-text-main md:text-2xl" style={{ color: textColor }}>
<h3 className="font-serif text-lg md:text-xl lg:text-2xl font-normal leading-snug text-text-main group-hover:text-brand-primary transition-colors" style={{ color: textColor }}>
{tileTitle}
</h3>
)}
{tileDesc && (
<p className="text-sm leading-relaxed text-muted-text md:text-base" style={{ color: textColor }}>
<p className="text-sm leading-relaxed text-muted-text" style={{ color: textColor }}>
{tileDesc}
</p>
)}
{buttonText && buttonLink && (
<div className="pt-2">
<Link
href={buttonLink}
className="inline-flex rounded-full border border-current px-8 py-3 text-xs font-bold uppercase tracking-[0.22em] transition hover:bg-brand-primary hover:text-surface-card"
>
{buttonText && ctaStyle !== 'none' && (
<div className={isLeftAlign ? 'pt-0.5' : 'pt-2'}>
{ctaStyle === 'arrow_link' ? (
<span className="inline-flex items-center gap-1.5 text-xs md:text-sm font-medium tracking-wide text-text-main group-hover:text-brand-primary transition-colors" style={{ color: textColor }}>
<span>{buttonText}</span>
<span className="transition-transform duration-200 group-hover:translate-x-1">→</span>
</span>
) : ctaStyle === 'solid_button' ? (
<span className="inline-flex rounded-xl bg-brand-primary text-surface-card px-7 py-2.5 text-xs font-bold uppercase tracking-[0.2em] transition hover:opacity-90 shadow-sm">
{buttonText}
</Link>
</span>
) : (
<span className="inline-flex rounded-full border border-current px-7 py-2.5 text-xs font-bold uppercase tracking-[0.2em] transition hover:bg-brand-primary hover:text-surface-card">
{buttonText}
</span>
)}
</div>
)}
</div>
</div>
)
return buttonLink ? (
<Link key={idx} href={buttonLink} className="block w-full focus:outline-none">
{cardContent}
</Link>
) : (
<div key={idx} className="w-full">
{cardContent}
</div>
)
})}
</div>
</div>
+20 -2
View File
@@ -716,7 +716,7 @@ export const EditorialTileSectionPayloadSchema = z.object({
description: LocalizedStringSchema,
imageUrl: z.string().min(1),
imagePosition: z.enum(['left', 'right']),
imageAspectRatio: z.enum(['square', 'portrait', 'tall', 'auto']).optional(),
imageAspectRatio: z.enum(['landscape', 'wide', 'square', 'portrait', 'tall', 'auto']).optional(),
buttonText: LocalizedStringSchema.optional(),
buttonLink: z.string().optional(),
backgroundColor: z.string().optional(),
@@ -728,13 +728,18 @@ export const FeaturedTilesSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
subtitle: LocalizedStringSchema.optional(),
columns: z.number().optional().default(2),
layoutPreset: z.enum(['editorial', 'spotlight', 'custom']).optional(),
textAlign: z.enum(['left', 'center', 'right']).optional(),
ctaStyle: z.enum(['arrow_link', 'pill_button', 'solid_button', 'none']).optional(),
imageAspectRatio: z.enum(['landscape', 'wide', 'square', 'portrait', 'tall', 'auto']).optional(),
imageRounded: z.enum(['none', 'sm', 'md', 'xl', '2xl', '3xl', 'full']).optional(),
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
tiles: z.array(z.object({
title: LocalizedStringSchema,
description: LocalizedStringSchema.optional(),
imageUrl: z.string().min(1),
imageAspectRatio: z.enum(['square', 'portrait', 'tall', 'auto']).optional(),
imageAspectRatio: z.enum(['landscape', 'wide', 'square', 'portrait', 'tall', 'auto']).optional(),
buttonText: LocalizedStringSchema.optional(),
buttonLink: z.string().optional(),
})).min(1)
@@ -1366,3 +1371,16 @@ export const CATEGORY_SECTION_TYPES = [
'CUSTOM',
] as const;
export type CategorySectionType = typeof CATEGORY_SECTION_TYPES[number];
export interface LlmPrompt {
id: number;
title: string;
category: 'navigation' | 'catalog' | 'theme' | 'copywriting' | 'cms' | 'translation' | 'custom';
description: string | null;
tags: string[];
content: string;
isSystem: boolean;
createdAt: string;
updatedAt: string;
}