x
This commit is contained in:
1 parent
230716933e
commit
3bbe2bb35e
16 files changed
+1815
-109
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. Summer Campaign"
|
||||
/>
|
||||
{/* ── 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="space-y-2">
|
||||
<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"
|
||||
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<option value={2}>2 Columns (Side-by-side)</option>
|
||||
<option value={3}>3 Columns</option>
|
||||
<option value={4}>4 Columns</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={payload.backgroundColor || '#FDFDF9'}
|
||||
onChange={(e) => updatePayloadValue('backgroundColor', e.target.value)}
|
||||
className="admin-input w-full h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Tiles List */}
|
||||
{/* ── 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 text-xs"
|
||||
placeholder="e.g. Featured Campaigns"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 text-xs bg-slate-900 border-white/10"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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 || '#F7F4EE'}
|
||||
onChange={(e) => updatePayloadValue('backgroundColor', 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.backgroundColor || '#F7F4EE'}
|
||||
onChange={(e) => updatePayloadValue('backgroundColor', e.target.value)}
|
||||
className="admin-input flex-1 text-xs"
|
||||
placeholder="#F7F4EE หรือ transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -110,7 +110,9 @@
|
||||
"paymentSettings": "ตั้งค่าช่องทางชำระเงิน",
|
||||
"themeSettings": "ตั้งค่าธีม & สี",
|
||||
"contentSettings": "ตั้งค่าเนื้อหาทั่วไป",
|
||||
"systemMaintenance": "บำรุงรักษาและจัดการระบบ"
|
||||
"systemMaintenance": "บำรุงรักษาและจัดการระบบ",
|
||||
"user": "ผู้ใช้งาน (User)",
|
||||
"llmPrompts": "LLM Prompts (คลังพรอมต์)"
|
||||
},
|
||||
"contentTabs": {
|
||||
"th": "ภาษาไทย",
|
||||
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,43 +131,48 @@ 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">
|
||||
{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 }}>
|
||||
{tileTitle}
|
||||
</h3>
|
||||
)}
|
||||
{tileDescription && (
|
||||
<p className="text-xs md:text-sm font-light leading-relaxed opacity-80" style={{ color: payload.textColor }}>
|
||||
{tileDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`space-y-2.5 w-full flex flex-col ${alignContainerClass}`}>
|
||||
{tileTitle && (
|
||||
<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>
|
||||
)}
|
||||
{tileDescription && (
|
||||
<p className="text-xs md:text-sm font-light leading-relaxed opacity-80" style={{ color: payload.textColor }}>
|
||||
{tileDescription}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
</span>
|
||||
{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">
|
||||
{cardContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="w-full flex flex-col items-center text-center space-y-5">
|
||||
{cardContent}
|
||||
</div>
|
||||
)}
|
||||
return tile.buttonLink ? (
|
||||
<Link key={idx} to={tile.buttonLink} className="block w-full focus:outline-none">
|
||||
{cardContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={idx} className="w-full">
|
||||
{cardContent}
|
||||
</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}
|
||||
</Link>
|
||||
{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}
|
||||
</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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user