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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user