x
This commit is contained in:
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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,106 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
name: resolveCatName(cat, lang),
|
||||
}));
|
||||
})
|
||||
.post("/bulk-import", async ({ body, status }) => {
|
||||
try {
|
||||
const raw = body as any;
|
||||
const list = Array.isArray(raw) ? raw : (Array.isArray(raw.categories) ? raw.categories : []);
|
||||
|
||||
if (list.length === 0) {
|
||||
return status(400, { message: "No categories provided in array" });
|
||||
}
|
||||
|
||||
const results = [];
|
||||
let createdCount = 0;
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const item of list) {
|
||||
const baseName = item.name || item.translations?.th || item.translations?.en || 'Unnamed Category';
|
||||
const imageUrl = item.imageUrl || null;
|
||||
const translations = item.translations || {};
|
||||
|
||||
let category: any = null;
|
||||
|
||||
if (item.id && typeof item.id === 'number') {
|
||||
category = await prisma.category.findUnique({
|
||||
where: { id: item.id, deletedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
if (!category && baseName) {
|
||||
category = await prisma.category.findFirst({
|
||||
where: { name: baseName, deletedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
if (category) {
|
||||
// Update existing
|
||||
const updateData: any = {};
|
||||
if (imageUrl !== undefined) updateData.imageUrl = imageUrl;
|
||||
if (item.name) updateData.name = item.name;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
category = await prisma.category.update({
|
||||
where: { id: category.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
updatedCount++;
|
||||
} else {
|
||||
// Create new
|
||||
category = await prisma.category.create({
|
||||
data: {
|
||||
name: baseName,
|
||||
imageUrl,
|
||||
},
|
||||
});
|
||||
createdCount++;
|
||||
}
|
||||
|
||||
// Upsert translations
|
||||
for (const lang of LANGS) {
|
||||
let transName = typeof translations[lang] === 'string'
|
||||
? translations[lang]
|
||||
: (translations[lang]?.name || (lang === 'th' ? baseName : null));
|
||||
|
||||
if (transName && typeof transName === 'string' && transName.trim() !== '') {
|
||||
await prisma.categoryTranslation.upsert({
|
||||
where: {
|
||||
categoryId_langCode: {
|
||||
categoryId: category.id,
|
||||
langCode: lang,
|
||||
},
|
||||
},
|
||||
update: { name: transName.trim() },
|
||||
create: {
|
||||
categoryId: category.id,
|
||||
langCode: lang,
|
||||
name: transName.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fullCategory = await prisma.category.findUnique({
|
||||
where: { id: category.id },
|
||||
include: { translations: true, _count: { select: { products: { where: { isDeleted: false } } } } },
|
||||
});
|
||||
|
||||
results.push(fullCategory);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: results.length,
|
||||
createdCount,
|
||||
updatedCount,
|
||||
categories: results,
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error("Bulk import categories error:", err);
|
||||
return status(500, { message: err?.message || "Failed to bulk import categories" });
|
||||
}
|
||||
})
|
||||
.get("/:id", async ({ params, query, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
const lang = (query as any).lang || 'all';
|
||||
|
||||
@@ -182,6 +182,40 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||
const contentClasses = `${mClasses.content} ${dClasses.content}`;
|
||||
const actionsClasses = `${mClasses.actions} ${dClasses.actions}`;
|
||||
|
||||
const overlayStyle = (payload as any).overlayStyle || 'gradient';
|
||||
const overlayOpacity = (payload as any).overlayOpacity !== undefined ? (payload as any).overlayOpacity : 45;
|
||||
|
||||
const renderOverlay = () => {
|
||||
if (overlayStyle === 'none' || overlayOpacity === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opacityRatio = overlayOpacity / 100;
|
||||
|
||||
if (overlayStyle === 'solid') {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-300"
|
||||
style={{ backgroundColor: `rgba(0, 0, 0, ${opacityRatio})` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Gradient style (default): smooth vertical gradient scaled by opacity
|
||||
const bottomAlpha = Math.min(0.95, opacityRatio * 1.85);
|
||||
const midAlpha = opacityRatio;
|
||||
const topAlpha = Math.max(0, opacityRatio * 0.65);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-300"
|
||||
style={{
|
||||
background: `linear-gradient(to top, rgba(0, 0, 0, ${bottomAlpha}) 0%, rgba(0, 0, 0, ${midAlpha}) 50%, rgba(0, 0, 0, ${topAlpha}) 100%)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex bg-night-950 overflow-hidden">
|
||||
<div className="absolute inset-0 w-full h-full z-0">
|
||||
@@ -194,8 +228,8 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/* Dark Gradient Overlay for high readability on both mobile and desktop */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/45 to-black/30 md:bg-black/35" />
|
||||
{/* Dynamic Dark Gradient / Solid Overlay */}
|
||||
{renderOverlay()}
|
||||
</div>
|
||||
|
||||
<div className={`relative z-10 w-full min-h-[85vh] sm:min-h-[90vh] md:min-h-full flex flex-col ${wrapperClasses} animate-fade-in`}>
|
||||
|
||||
@@ -251,6 +251,40 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
|
||||
const contentClasses = `${mClasses.content} ${dClasses.content}`
|
||||
const actionsClasses = `${mClasses.actions} ${dClasses.actions}`
|
||||
|
||||
const overlayStyle = typeof payload.overlayStyle === 'string' ? payload.overlayStyle : 'gradient'
|
||||
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 45
|
||||
|
||||
const renderOverlay = () => {
|
||||
if (overlayStyle === 'none' || overlayOpacity === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const opacityRatio = overlayOpacity / 100
|
||||
|
||||
if (overlayStyle === 'solid') {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-300"
|
||||
style={{ backgroundColor: `rgba(0, 0, 0, ${opacityRatio})` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Gradient style (default): smooth vertical gradient scaled by opacity
|
||||
const bottomAlpha = Math.min(0.95, opacityRatio * 1.85)
|
||||
const midAlpha = opacityRatio
|
||||
const topAlpha = Math.max(0, opacityRatio * 0.65)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-300"
|
||||
style={{
|
||||
background: `linear-gradient(to top, rgba(0, 0, 0, ${bottomAlpha}) 0%, rgba(0, 0, 0, ${midAlpha}) 50%, rgba(0, 0, 0, ${topAlpha}) 100%)`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex bg-night-950 overflow-hidden">
|
||||
<div className="absolute inset-0 w-full h-full z-0">
|
||||
@@ -262,7 +296,8 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
|
||||
className="object-cover md:scale-[1.02]"
|
||||
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/45 to-black/30 md:bg-black/35" />
|
||||
{/* Dynamic Dark Gradient / Solid Overlay */}
|
||||
{renderOverlay()}
|
||||
</div>
|
||||
<div className={`relative z-10 w-full min-h-[85vh] sm:min-h-[90vh] md:min-h-full flex flex-col ${wrapperClasses}`}>
|
||||
<div className={`w-full max-w-3xl flex flex-col space-y-5 ${contentClasses}`}>
|
||||
|
||||
@@ -414,6 +414,8 @@ export const HeroSectionPayloadSchema = z.object({
|
||||
'center-left', 'center', 'center-right',
|
||||
'bottom-left', 'bottom-center', 'bottom-right'
|
||||
]).optional(),
|
||||
overlayStyle: z.enum(['gradient', 'solid', 'none']).optional().default('gradient'),
|
||||
overlayOpacity: z.number().min(0).max(100).optional(),
|
||||
});
|
||||
export type HeroSectionPayload = z.infer<typeof HeroSectionPayloadSchema>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user