This commit is contained in:
Your Name committed 2026-09-29 16:13:02 +07:00
1 parent 80e715da76
commit 57022d46af
7 files changed
+892 -5

No files matched your search

@@ -0,0 +1,585 @@
import React, { useState, useMemo, useRef } from 'react';
import {
X,
Copy,
Check,
Download,
Upload,
FileJson,
AlertCircle,
CheckCircle2,
FileText,
ArrowRight,
Bot,
Loader2,
Package
} from 'lucide-react';
import { API_BASE, type Category } from '@homaroma/types';
interface CategoryBulkJsonModalProps {
isOpen: boolean;
onClose: () => void;
categories: Category[];
onSuccess: () => void;
}
const SUPPORTED_LOCALES = [
{ code: 'th', label: 'TH', full: 'ภาษาไทย' },
{ code: 'en', label: 'EN', full: 'English' },
{ code: 'ja', label: 'JA', full: '日本語' },
{ code: 'zh', label: 'ZH', full: '中文' },
{ code: 'vi', label: 'VI', full: 'Tiếng Việt' },
{ code: 'ko', label: 'KO', full: '한국어' },
{ code: 'lo', label: 'LO', full: 'ພາສາລາວ' },
{ code: 'it', label: 'IT', full: 'Italiano' },
];
export default function CategoryBulkJsonModal({
isOpen,
onClose,
categories,
onSuccess,
}: CategoryBulkJsonModalProps) {
const [activeTab, setActiveTab] = useState<'import' | 'export'>('import');
const [copiedJson, setCopiedJson] = useState(false);
const [copiedPrompt, setCopiedPrompt] = useState(false);
const [importJsonText, setImportJsonText] = useState('');
const [parseError, setParseError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [resultMessage, setResultMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// 1. Export Data Generation
const exportData = useMemo(() => {
return categories.map((cat) => {
const transMap: Record<string, string> = {};
const catNameObj = typeof cat.name === 'object' && cat.name !== null ? (cat.name as any) : null;
const catBaseName = typeof cat.name === 'string' ? cat.name : (catNameObj?.th || '');
for (const loc of SUPPORTED_LOCALES) {
if (catNameObj && catNameObj[loc.code]) {
transMap[loc.code] = catNameObj[loc.code];
} else if (Array.isArray(cat.translations)) {
const t = cat.translations.find((tr: any) => tr.langCode === loc.code);
if (t?.name) {
transMap[loc.code] = t.name;
}
}
}
if (!transMap['th'] && catBaseName) {
transMap['th'] = catBaseName;
}
return {
id: cat.id,
name: catBaseName || transMap['th'] || 'Unnamed Category',
imageUrl: cat.imageUrl || null,
translations: transMap,
};
});
}, [categories]);
const exportJsonString = useMemo(() => {
return JSON.stringify(exportData, null, 2);
}, [exportData]);
// AI Translation Prompt
const aiTranslatePrompt = useMemo(() => {
const sampleItems = exportData.slice(0, 5);
return `Please translate and optimize our E-commerce Category Names into all 8 supported languages (th, en, ja, zh, vi, ko, lo, it).
Ensure the tone is elegant, accurate for luxury aromatherapy & lifestyle products.
Return ONLY a valid JSON Array conforming to this structure:
\`\`\`json
${JSON.stringify(
sampleItems.length > 0
? sampleItems
: [
{
name: 'น้ำมันหอมระเหย',
imageUrl: '/images/categories/essential-oils.jpg',
translations: {
th: 'น้ำมันหอมระเหย',
en: 'Essential Oils',
ja: 'エッセンシャルオイル',
zh: '精油',
vi: 'Tinh dầu',
ko: '에센셜 오일',
lo: 'ນ້ຳມັນຫອມລະເຫີຍ',
it: 'Oli Essenziali',
},
},
{
name: 'ก้านไม้หอมปรับอากาศ',
imageUrl: '/images/categories/reed-diffusers.jpg',
translations: {
th: 'ก้านไม้หอมปรับอากาศ',
en: 'Reed Diffusers',
ja: 'リードディフューザー',
zh: '无火藤条香薰',
vi: 'Que khuếch tán tinh dầu',
ko: '리드 디퓨저',
lo: 'ກ້ານໄມ້ຫອມປັບອາກາດ',
it: 'Diffusori a Bastoncini',
},
},
],
null,
2
)}
\`\`\``;
}, [exportData]);
// 2. Parsed Import Items
const parsedImportData = useMemo(() => {
if (!importJsonText.trim()) return null;
try {
const raw = JSON.parse(importJsonText);
const list = Array.isArray(raw) ? raw : (Array.isArray(raw.categories) ? raw.categories : null);
if (!list || !Array.isArray(list)) {
setParseError('JSON must be an Array of category objects e.g. [ { "name": "...", "translations": { ... } } ]');
return null;
}
const normalized = list.map((item: any) => {
const trans: Record<string, string> = {};
if (item.translations && typeof item.translations === 'object') {
for (const [k, v] of Object.entries(item.translations)) {
if (typeof v === 'string') {
trans[k.toLowerCase()] = v;
} else if (v && typeof v === 'object' && typeof (v as any).name === 'string') {
trans[k.toLowerCase()] = (v as any).name;
}
}
}
const baseName = item.name || trans['th'] || trans['en'] || 'Unnamed Category';
if (!trans['th'] && baseName) {
trans['th'] = baseName;
}
return {
id: typeof item.id === 'number' ? item.id : undefined,
name: baseName,
imageUrl: item.imageUrl || null,
translations: trans,
};
});
if (normalized.length === 0) {
setParseError('No category items found in the array.');
return null;
}
setParseError(null);
return normalized;
} catch (err: any) {
setParseError(`Invalid JSON Syntax: ${err.message}`);
return null;
}
}, [importJsonText]);
if (!isOpen) return null;
const handleCopyJson = async () => {
try {
await navigator.clipboard.writeText(exportJsonString);
setCopiedJson(true);
setTimeout(() => setCopiedJson(false), 2000);
} catch {}
};
const handleCopyPrompt = async () => {
try {
await navigator.clipboard.writeText(aiTranslatePrompt);
setCopiedPrompt(true);
setTimeout(() => setCopiedPrompt(false), 2000);
} catch {}
};
const handleDownloadJson = () => {
const blob = new Blob([exportJsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `categories_multilang_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
if (content) {
setImportJsonText(content);
}
};
reader.readAsText(file);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleLoadSample = () => {
setImportJsonText(exportJsonString);
};
const handleImportSubmit = async () => {
if (!parsedImportData || parsedImportData.length === 0) return;
setSubmitting(true);
setResultMessage(null);
try {
const res = await fetch(`${API_BASE}/categories/bulk-import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsedImportData),
});
const data = await res.json();
if (res.ok && data.success) {
setResultMessage(`นำเข้าสำเร็จเรียบร้อย! (สร้างใหม่ ${data.createdCount || 0} หมวดหมู่, อัปเดต ${data.updatedCount || 0} หมวดหมู่)`);
onSuccess();
setTimeout(() => {
onClose();
}, 1500);
} else {
setParseError(data.message || 'Failed to bulk import categories');
}
} catch (err: any) {
console.error('Import categories error:', err);
setParseError(err.message || 'Network error occurred');
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-night-950/80 backdrop-blur-md animate-fade-in">
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
{/* Modal Header */}
<div className="p-5 sm:p-6 border-b border-white/10 flex items-center justify-between bg-night-950/60">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl text-cyan-glow">
<FileJson className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-white flex items-center gap-2">
<span>Multi-Language Categories JSON</span>
<span className="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 rounded-full bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20">
Bulk JSON Array
</span>
</h2>
<p className="text-xs text-slate-400">
Import & Export Category details with translations for all 8 languages at once.
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/10 transition-all cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-white/10 bg-night-950/40 px-6 pt-3 gap-2">
<button
onClick={() => setActiveTab('import')}
className={`pb-3 px-4 text-xs font-bold uppercase tracking-wider flex items-center gap-2 border-b-2 transition-all cursor-pointer ${
activeTab === 'import'
? 'border-cyan-glow text-cyan-glow'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Upload className="w-4 h-4" />
<span>📥 Import JSON (นำเข้าหลายหมวดหมู่)</span>
</button>
<button
onClick={() => setActiveTab('export')}
className={`pb-3 px-4 text-xs font-bold uppercase tracking-wider flex items-center gap-2 border-b-2 transition-all cursor-pointer ${
activeTab === 'export'
? 'border-cyan-glow text-cyan-glow'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Download className="w-4 h-4" />
<span>📤 Export JSON ({categories.length} หมวดหมู่)</span>
</button>
</div>
{/* Tab Content Body */}
<div className="p-6 overflow-y-auto flex-1 space-y-5">
{/* IMPORT TAB */}
{activeTab === 'import' && (
<div className="space-y-5">
{/* Action Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
accept=".json,application/json"
onChange={handleFileUpload}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
>
<Upload className="w-3.5 h-3.5 text-cyan-glow" />
<span>Upload .json File</span>
</button>
<button
type="button"
onClick={handleLoadSample}
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
>
<FileText className="w-3.5 h-3.5 text-purple-400" />
<span>Load Current Categories ({categories.length})</span>
</button>
</div>
<div className="text-xs text-slate-400 flex items-center gap-1">
<span>รูปแบบ:</span>
<code className="px-1.5 py-0.5 rounded bg-black/40 text-cyan-glow font-mono text-[11px]">
[{`{"name":"...", "translations":{...}}`}]
</code>
</div>
</div>
{/* JSON Editor Textarea */}
<div className="relative">
<textarea
value={importJsonText}
onChange={(e) => setImportJsonText(e.target.value)}
rows={9}
className="admin-input w-full font-mono text-xs leading-relaxed p-4 bg-night-950 border-white/10 focus:border-cyan-glow/50 rounded-xl resize-y text-slate-200 placeholder:text-slate-600 outline-none transition-all shadow-inner"
placeholder={`Paste JSON Array here, e.g.:
[
{
"name": "น้ำมันหอมระเหย",
"imageUrl": "/images/categories/essential-oils.jpg",
"translations": {
"th": "น้ำมันหอมระเหย",
"en": "Essential Oils",
"ja": "エッセンシャルオイル",
"zh": "精油",
"vi": "Tinh dầu",
"ko": "에센셜 오일",
"lo": "ນ້ຳມັນຫອມລະເຫີຍ",
"it": "Oli Essenziali"
}
}
]`}
spellCheck={false}
/>
</div>
{/* Error Box */}
{parseError && (
<div className="p-3.5 bg-rose-500/10 border border-rose-500/30 rounded-xl flex items-center gap-3 text-xs text-rose-300 animate-fade-in">
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
<span>{parseError}</span>
</div>
)}
{/* Success Box */}
{resultMessage && (
<div className="p-3.5 bg-emerald-500/10 border border-emerald-500/30 rounded-xl flex items-center gap-3 text-xs text-emerald-300 animate-fade-in">
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
<span>{resultMessage}</span>
</div>
)}
{/* Live Preview List */}
{parsedImportData && (
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span className="text-xs font-bold uppercase tracking-wider text-emerald-400">
Valid JSON Array: Found {parsedImportData.length} Category Item(s)
</span>
</div>
</div>
{/* Preview Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-56 overflow-y-auto pr-1">
{parsedImportData.map((item, idx) => {
return (
<div
key={idx}
className="p-3.5 rounded-xl bg-night-950/80 border border-white/5 space-y-2 text-xs flex items-start gap-3"
>
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={item.name}
className="w-12 h-12 rounded-lg object-cover bg-white/5 border border-white/10 shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center shrink-0 text-slate-500">
<Package className="w-5 h-5" />
</div>
)}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between">
<span className="font-bold text-white truncate text-[13px]">
{item.name}
</span>
{item.id && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-slate-400">
ID: {item.id}
</span>
)}
</div>
<p className="text-[11px] text-slate-400 truncate">
EN: <span className="text-slate-200">{item.translations?.en || '-'}</span>
</p>
<div className="flex flex-wrap gap-1 pt-1">
{SUPPORTED_LOCALES.map((loc) => {
const hasTrans = Boolean(item.translations?.[loc.code]);
return (
<span
key={loc.code}
className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
hasTrans
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
: 'bg-white/5 text-slate-600 border border-white/5'
}`}
>
{loc.label}
</span>
);
})}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
{/* EXPORT TAB */}
{activeTab === 'export' && (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-slate-400">
Current {categories.length} Categories exported with full 8-language translations:
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopyJson}
className="px-3.5 py-1.5 bg-cyan-glow text-night-900 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 hover:shadow-lg hover:shadow-cyan-glow/20 cursor-pointer"
>
{copiedJson ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedJson ? 'Copied JSON!' : 'Copy JSON'}</span>
</button>
<button
type="button"
onClick={handleDownloadJson}
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
>
<Download className="w-3.5 h-3.5 text-cyan-glow" />
<span>Download .json</span>
</button>
</div>
</div>
{/* JSON Display */}
<div className="relative">
<textarea
readOnly
value={exportJsonString}
rows={12}
className="admin-input w-full font-mono text-xs leading-relaxed p-4 bg-night-950 border-white/10 rounded-xl resize-y text-slate-200 outline-none select-all"
spellCheck={false}
/>
</div>
{/* AI Translation Prompt Box */}
<div className="p-4 rounded-xl bg-purple-500/5 border border-purple-500/20 space-y-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-purple-300">
<Bot className="w-4 h-4 text-purple-400" />
<span>One-Click AI Translation Prompt (for ChatGPT / Claude)</span>
</div>
<button
type="button"
onClick={handleCopyPrompt}
className="px-3 py-1 bg-purple-500/20 hover:bg-purple-500/30 text-purple-300 border border-purple-500/30 rounded-lg text-xs font-semibold transition-all flex items-center gap-1 cursor-pointer"
>
{copiedPrompt ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedPrompt ? 'Copied Prompt!' : 'Copy Prompt for AI'}</span>
</button>
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
คลิกปุ่มนี้เพื่อคัดลอก Prompt สำเร็จรูป นำไปวางใน ChatGPT หรือ Claude เพื่อให้ AI แปลงชื่อหมวดหมู่ให้ครบทุกภาษา (ไทย, อังกฤษ, ญี่ปุ่น, จีน, เวียดนาม, เกาหลี, ลาว, อิตาลี) แล้วนำ JSON กลับมาวางในแท็บ Import ได้ทันที
</p>
</div>
</div>
)}
</div>
{/* Modal Footer */}
<div className="p-5 sm:p-6 border-t border-white/10 bg-night-950/80 flex items-center justify-between">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-slate-300 rounded-xl text-xs font-semibold transition-all cursor-pointer"
>
Cancel
</button>
{activeTab === 'import' && (
<button
type="button"
disabled={!parsedImportData || Boolean(parseError) || submitting}
onClick={handleImportSubmit}
className="px-5 py-2 bg-cyan-glow text-night-900 rounded-xl text-xs font-bold transition-all flex items-center gap-2 hover:shadow-lg hover:shadow-cyan-glow/20 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Importing...</span>
</>
) : (
<>
<span>Import {parsedImportData?.length || 0} Categories</span>
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
)}
</div>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { UploadCloud, Image as ImageIcon, Monitor, Smartphone, RotateCcw } from 'lucide-react';
import { UploadCloud, Image as ImageIcon, Monitor, Smartphone, RotateCcw, Layers, Sliders } from 'lucide-react';
import { type PageSection } from '@homaroma/types';
import FocalPointPicker from './FocalPointPicker';
@@ -24,6 +24,14 @@ const GRID_POSITIONS = [
{ id: 'bottom-right', label: 'Bottom Right (ล่างขวา)', short: 'BR', icon: '↘' },
];
const OVERLAY_PRESETS = [
{ label: 'None (0%)', value: 0 },
{ label: 'Light (25%)', value: 25 },
{ label: 'Default (45%)', value: 45 },
{ label: 'Dark (65%)', value: 65 },
{ label: 'Heavy (85%)', value: 85 },
];
export default function HeroForm({
editingSection,
getLocalizedValue,
@@ -32,6 +40,9 @@ export default function HeroForm({
setUploadTarget,
setIsMediaModalOpen,
}: HeroFormProps) {
const currentOverlayStyle = editingSection.payload?.overlayStyle || 'gradient';
const currentOverlayOpacity = editingSection.payload?.overlayOpacity !== undefined ? editingSection.payload.overlayOpacity : 45;
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Hero image uploading */}
@@ -70,6 +81,105 @@ export default function HeroForm({
</div>
)}
{/* Dark Overlay & Gradient Controls */}
<div className="md:col-span-2 bg-night-950/50 border border-white/5 p-4 rounded-2xl space-y-4">
<div className="flex items-center justify-between border-b border-white/5 pb-2">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-cyan-glow" />
<label className="text-[11px] uppercase font-bold tracking-widest text-slate-300">
Dark Overlay & เงามืดบังภาพ
</label>
</div>
<span className="text-[10px] font-mono px-2 py-0.5 rounded bg-white/5 text-slate-400 border border-white/5">
{currentOverlayStyle === 'none' ? 'Disabled' : `${currentOverlayStyle.toUpperCase()} • ${currentOverlayOpacity}%`}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center">
{/* Style Selector */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
รูปแบบเงา (Overlay Style)
</label>
<div className="grid grid-cols-3 gap-1.5 p-1 bg-night-900 border border-white/10 rounded-xl">
<button
type="button"
onClick={() => updatePayloadValue('overlayStyle', 'gradient')}
className={`py-2 text-xs font-bold rounded-lg transition-all cursor-pointer ${
currentOverlayStyle === 'gradient'
? 'bg-cyan-glow text-night-950 shadow-md font-black'
: 'text-slate-400 hover:text-white'
}`}
>
Gradient
</button>
<button
type="button"
onClick={() => updatePayloadValue('overlayStyle', 'solid')}
className={`py-2 text-xs font-bold rounded-lg transition-all cursor-pointer ${
currentOverlayStyle === 'solid'
? 'bg-cyan-glow text-night-950 shadow-md font-black'
: 'text-slate-400 hover:text-white'
}`}
>
Solid
</button>
<button
type="button"
onClick={() => updatePayloadValue('overlayStyle', 'none')}
className={`py-2 text-xs font-bold rounded-lg transition-all cursor-pointer ${
currentOverlayStyle === 'none'
? 'bg-rose-500 text-white shadow-md font-black'
: 'text-slate-400 hover:text-white'
}`}
>
None
</button>
</div>
</div>
{/* Opacity Slider & Presets */}
{currentOverlayStyle !== 'none' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400 flex items-center gap-1.5">
<Sliders className="w-3 h-3 text-cyan-glow" />
ความเข้มเงา ({currentOverlayOpacity}%)
</label>
<div className="flex gap-1">
{OVERLAY_PRESETS.map((preset) => (
<button
key={preset.value}
type="button"
onClick={() => updatePayloadValue('overlayOpacity', preset.value)}
className={`px-1.5 py-0.5 text-[9px] font-mono rounded transition-all cursor-pointer ${
currentOverlayOpacity === preset.value
? 'bg-cyan-glow text-night-950 font-bold'
: 'bg-white/5 text-slate-400 hover:text-white hover:bg-white/10'
}`}
>
{preset.value}%
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={100}
step={5}
value={currentOverlayOpacity}
onChange={(e) => updatePayloadValue('overlayOpacity', Number(e.target.value))}
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-cyan-glow"
/>
</div>
</div>
)}
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Tagline (เว้นว่างได้)</label>
<input
+22 -1
View File
@@ -12,8 +12,10 @@ import {
List,
Search,
Globe,
Layers
Layers,
FileJson
} from 'lucide-react'
import CategoryBulkJsonModal from '../components/CategoryBulkJsonModal'
export default function CategoriesPage() {
const { t } = useTranslation()
@@ -22,6 +24,7 @@ export default function CategoriesPage() {
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list')
const [searchQuery, setSearchQuery] = useState('')
const [isBulkJsonModalOpen, setIsBulkJsonModalOpen] = useState(false)
const fetchData = async () => {
setLoading(true)
@@ -146,6 +149,17 @@ export default function CategoriesPage() {
</button>
</div>
{/* Import / Export JSON Button */}
<button
type="button"
onClick={() => setIsBulkJsonModalOpen(true)}
className="px-3.5 py-2.5 bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/30 rounded-xl text-xs font-bold transition-all flex items-center gap-2 hover:shadow-lg hover:shadow-cyan-glow/10 cursor-pointer"
title="Bulk Import & Export Categories via JSON"
>
<FileJson className="w-4 h-4" />
<span>Import / Export JSON</span>
</button>
{/* Add Category Button */}
<Link
to="/categories/new"
@@ -390,6 +404,13 @@ export default function CategoriesPage() {
)}
</div>
)}
<CategoryBulkJsonModal
isOpen={isBulkJsonModalOpen}
onClose={() => setIsBulkJsonModalOpen(false)}
categories={categories}
onSuccess={fetchData}
/>
</div>
)
}