This commit is contained in:
Your Name committed 2026-10-05 06:43:10 +07:00
1 parent 142d991816
commit ebc8f2ffeb
7 files changed
+713 -32

No files matched your search

@@ -0,0 +1,534 @@
import React, { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import {
X,
Copy,
Check,
Download,
Upload,
FileJson,
AlertCircle,
CheckCircle2,
Sparkles,
Bot,
ArrowRight,
FolderTree,
} from 'lucide-react';
export const SUPPORTED_LOCALES = [
{ code: 'th', label: 'TH', full: 'ภาษาไทย', flag: '🇹🇭' },
{ code: 'en', label: 'EN', full: 'English', flag: '🇬🇧' },
{ code: 'ja', label: 'JA', full: '日本語', flag: '🇯🇵' },
{ code: 'zh', label: 'ZH', full: '中文', flag: '🇨🇳' },
{ code: 'vi', label: 'VI', full: 'Tiếng Việt', flag: '🇻🇳' },
{ code: 'ko', label: 'KO', full: '한국어', flag: '🇰🇷' },
{ code: 'lo', label: 'LO', full: 'ພາສາລາວ', flag: '🇱🇦' },
{ code: 'it', label: 'IT', full: 'Italiano', flag: '🇮🇹' },
] as const;
export interface CategoryTranslationData {
name: Record<string, string>;
}
interface CategoryTranslationModalProps {
isOpen: boolean;
onClose: () => void;
categoryName?: string;
categoryId?: string | number;
data: CategoryTranslationData;
initialTab?: 'export' | 'import';
onApplyTranslations: (updated: { name: Record<string, string> }) => void;
}
function getLocalesObject(val: unknown): Record<string, string> {
const result: Record<string, string> = {};
SUPPORTED_LOCALES.forEach(({ code }) => {
if (typeof val === 'string') {
result[code] = code === 'th' ? val : '';
} else if (typeof val === 'object' && val !== null) {
const record = val as Record<string, unknown>;
result[code] = typeof record[code] === 'string' ? record[code] : '';
} else {
result[code] = '';
}
});
return result;
}
function extractFieldTranslations(obj: unknown, fieldKey: string): Record<string, string> {
const res: Record<string, string> = {};
if (!obj || typeof obj !== 'object') return res;
const record = obj as Record<string, any>;
// Format A: { [fieldKey]: { th: "...", en: "..." } }
if (typeof record[fieldKey] === 'object' && record[fieldKey] !== null) {
SUPPORTED_LOCALES.forEach(({ code }) => {
res[code] = typeof record[fieldKey][code] === 'string' ? record[fieldKey][code].trim() : '';
});
return res;
}
// Format B: { th: { [fieldKey]: "..." }, en: { [fieldKey]: "..." } }
SUPPORTED_LOCALES.forEach(({ code }) => {
if (record[code] && typeof record[code] === 'object' && record[code][fieldKey]) {
res[code] = typeof record[code][fieldKey] === 'string' ? record[code][fieldKey].trim() : '';
}
});
// Flat Format: { th: "...", en: "..." }
if (Object.keys(res).length === 0 || !Object.values(res).some(Boolean)) {
SUPPORTED_LOCALES.forEach(({ code }) => {
if (typeof record[code] === 'string') {
res[code] = record[code].trim();
}
});
}
return res;
}
export default function CategoryTranslationModal({
isOpen,
onClose,
categoryName,
categoryId,
data,
initialTab = 'export',
onApplyTranslations,
}: CategoryTranslationModalProps) {
const [activeTab, setActiveTab] = useState<'export' | 'import'>(initialTab);
const [copiedJson, setCopiedJson] = useState(false);
const [copiedPrompt, setCopiedPrompt] = useState(false);
const [importText, setImportText] = useState('');
const [parseError, setParseError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
React.useEffect(() => {
if (isOpen) {
setActiveTab(initialTab);
setParseError(null);
setSuccessMsg(null);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prevOverflow;
};
}
}, [isOpen, initialTab]);
// Structured export object
const exportData = useMemo(() => {
return {
name: getLocalesObject(data.name),
};
}, [data]);
const exportJsonString = useMemo(() => {
return JSON.stringify(exportData, null, 2);
}, [exportData]);
// AI Prompt tailored for category naming & sensory fragrance branding
const aiPromptString = useMemo(() => {
const title = categoryName || data.name?.th || data.name?.en || 'หมวดหมู่สินค้า';
return `คุณคือ AI ผู้เชี่ยวชาญด้าน E-Commerce Localization, Luxury Fragrance & Home Aroma Brand Strategist (HomAroma Platform)
ภารกิจ: แปลชื่อหมวดหมู่สินค้า (Category / Collection Name): "${title}" เป็น 8 ภาษาให้ครบถ้วน สละสลวย ไพเราะ และกระตุ้นความรู้สึกสัมผัสกลิ่นหอมและบรรยากาศ (Sensory & Ambience Appeal):
- th: ภาษาไทย
- en: ภาษาอังกฤษ (English)
- ja: ภาษาญี่ปุ่น (日本語)
- zh: ภาษาจีน (中文)
- vi: ภาษาเวียดนาม (Tiếng Việt)
- ko: ภาษาเกาหลี (한국어)
- lo: ภาษาลาว (ພາສາລາວ)
- it: ภาษาอิตาลี (Italiano)
ข้อกำหนดสำคัญ:
1. รักษาโครงสร้าง JSON และคีย์ "name" ให้ถูกต้องสมบูรณ์
2. "name": ชื่อหมวดหมู่สินค้า แปลให้สละสลวย เป็นทางการ และกระตุ้นความรู้สึกผ่อนคลาย สง่างาม น่าค้นหา
3. สไตล์การแปล: หรูหรา สง่างาม อบอุ่น (Quiet Luxury, High-End Spa & Artisanal Perfumery)
4. ตอบกลับมาเฉพาะโค้ด JSON ที่ถูกต้องเท่านั้น (Valid JSON only) ห้ามใส่คำอธิบายเพิ่มเติม
\`\`\`json
${exportJsonString}
\`\`\``;
}, [categoryName, data.name, exportJsonString]);
const handleCopyJson = async () => {
try {
await navigator.clipboard.writeText(exportJsonString);
setCopiedJson(true);
setTimeout(() => setCopiedJson(false), 2000);
} catch {
// fallback
}
};
const handleCopyPrompt = async () => {
try {
await navigator.clipboard.writeText(aiPromptString);
setCopiedPrompt(true);
setTimeout(() => setCopiedPrompt(false), 2000);
} catch {
// fallback
}
};
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 = `category-${categoryId || 'item'}-translations.json`;
a.click();
URL.revokeObjectURL(url);
};
// Parser & validator for pasted import JSON
const parsedImport = useMemo(() => {
if (!importText.trim()) return null;
let clean = importText.trim();
if (clean.startsWith('```json')) clean = clean.slice(7);
else if (clean.startsWith('```')) clean = clean.slice(3);
if (clean.endsWith('```')) clean = clean.slice(0, -3);
clean = clean.trim();
try {
const parsed = JSON.parse(clean);
if (!parsed || typeof parsed !== 'object') {
return { error: 'รูปแบบ JSON ไม่ถูกต้อง (ต้องเป็น JSON Object)' };
}
const nameMap = extractFieldTranslations(parsed, 'name');
const languageCounts: Record<string, boolean> = {};
SUPPORTED_LOCALES.forEach(({ code }) => {
languageCounts[code] = Boolean(nameMap[code]?.trim());
});
const filledCount = Object.values(languageCounts).filter(Boolean).length;
return {
name: nameMap,
languageCounts,
filledCount,
statsText: `พบชื่อหมวดหมู่ ${filledCount} / 8 ภาษา`,
};
} catch (err: any) {
return { error: `JSON Parse Error: ${err.message}` };
}
}, [importText]);
const handleApplyImport = () => {
if (!parsedImport || parsedImport.error || !parsedImport.name) return;
onApplyTranslations({
name: parsedImport.name,
});
setSuccessMsg('อัปเดตชื่อหมวดหมู่ทุกภาษาลงในฟอร์มสำเร็จแล้ว!');
setTimeout(() => {
onClose();
}, 1200);
};
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-night-950/80 backdrop-blur-md animate-fade-in">
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden text-slate-200 animate-pop-in">
{/* Header */}
<div className="px-6 py-5 border-b border-white/5 flex items-center justify-between bg-gradient-to-r from-white/[0.04] to-transparent shrink-0">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-2xl bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow">
<FolderTree className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-white">
จัดการชื่อหมวดหมู่หลายภาษา (Category Multi-Language JSON)
</h3>
{categoryId && (
<span className="px-2 py-0.5 text-[10px] font-mono font-bold bg-white/5 text-slate-400 rounded border border-white/10">
ID: {categoryId}
</span>
)}
</div>
<p className="text-xs text-slate-400 mt-0.5">
ส่งออกชื่อหมวดหมู่สินค้าให้ LLM แปล 8 ภาษา แล้วนำ JSON มาวางเพื่ออัปเดตทุกภาษาพร้อมกัน
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-xl hover:bg-white/5 text-slate-400 hover:text-white transition-all cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Navigation Tabs */}
<div className="px-6 pt-3 border-b border-white/5 bg-night-950/40 flex items-center gap-2">
<button
type="button"
onClick={() => setActiveTab('export')}
className={`flex items-center gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider border-b-2 transition-all cursor-pointer ${
activeTab === 'export'
? 'border-cyan-glow text-cyan-glow bg-cyan-glow/5 rounded-t-xl'
: 'border-transparent text-slate-400 hover:text-white hover:bg-white/5 rounded-t-xl'
}`}
>
<Download className="w-3.5 h-3.5" />
<span>1. ส่งออก & พร้อมท์ AI (Export)</span>
</button>
<button
type="button"
onClick={() => setActiveTab('import')}
className={`flex items-center gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider border-b-2 transition-all cursor-pointer ${
activeTab === 'import'
? 'border-pop-pink text-pop-pink bg-pop-pink/5 rounded-t-xl'
: 'border-transparent text-slate-400 hover:text-white hover:bg-white/5 rounded-t-xl'
}`}
>
<Upload className="w-3.5 h-3.5" />
<span>2. วางผลแปล & อัปเดต (Import)</span>
</button>
</div>
{/* Modal Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Error Message */}
{parseError && (
<div className="p-4 rounded-2xl bg-rose-500/10 border border-rose-500/20 text-rose-400 flex items-center gap-3 animate-fade-in text-sm">
<AlertCircle className="w-5 h-5 shrink-0" />
<span>{parseError}</span>
</div>
)}
{/* Success Notification */}
{successMsg && (
<div className="p-4 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center gap-3 animate-fade-in">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
<span className="text-sm font-semibold">{successMsg}</span>
</div>
)}
{activeTab === 'export' ? (
<div className="space-y-4">
{/* Instructions Banner */}
<div className="bg-night-950/60 p-4 rounded-2xl border border-white/5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs font-bold text-slate-200">
<Sparkles className="w-4 h-4 text-cyan-glow" />
<span>ขั้นตอนการแปลชื่อหมวดหมู่ด้วย AI:</span>
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
1. กดปุ่ม <b className="text-cyan-glow">"คัดลอกพร้อมท์ AI"</b> &rarr; 2. นำไปวางใน ChatGPT / Claude / Gemini &rarr; 3. สลับไปแท็บ <b className="text-pop-pink">"วางผลแปล & อัปเดต"</b> แล้วนำผลลัพธ์มาวาง
</p>
</div>
<div className="flex items-center gap-2 shrink-0 w-full sm:w-auto">
<button
type="button"
onClick={handleCopyPrompt}
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-bold transition-all shadow-md cursor-pointer ${
copiedPrompt
? 'bg-emerald-500 text-white shadow-emerald-500/20'
: 'bg-cyan-glow hover:bg-cyan-glow/90 text-night-950 shadow-cyan-glow/20'
}`}
>
{copiedPrompt ? <Check className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
<span>{copiedPrompt ? 'คัดลอกพร้อมท์แล้ว!' : 'คัดลอกพร้อมท์ AI'}</span>
</button>
<button
type="button"
onClick={handleCopyJson}
className="flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-white text-xs font-medium border border-white/10 transition-all cursor-pointer"
>
{copiedJson ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
<span>{copiedJson ? 'คัดลอก JSON แล้ว' : 'คัดลอก JSON'}</span>
</button>
<button
type="button"
onClick={handleDownloadJson}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/10 transition-all cursor-pointer"
title="ดาวน์โหลดไฟล์ .json"
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
{/* Supported Languages Status Badges */}
<div className="space-y-1.5">
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
สถานะภาษาในหมวดหมู่นี้ (8 ภาษา):
</span>
<div className="flex flex-wrap gap-2">
{SUPPORTED_LOCALES.map(({ code, label, full, flag }) => {
const hasVal = Boolean(data.name?.[code]?.trim());
return (
<div
key={code}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-mono border transition-all ${
hasVal
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
: 'bg-white/5 text-slate-500 border-white/5'
}`}
title={full}
>
<span>{flag}</span>
<span className="font-bold">{label}</span>
<span className="text-[10px] opacity-70">({code})</span>
{hasVal ? <Check className="w-3 h-3 text-emerald-400" /> : <span className="text-[10px] text-slate-600">-</span>}
</div>
);
})}
</div>
</div>
{/* JSON Display Area */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 flex items-center gap-1.5">
<FileJson className="w-3.5 h-3.5 text-cyan-glow" />
<span>โครงสร้าง JSON ข้อมูลชื่อหมวดหมู่ปัจจุบัน:</span>
</span>
<span className="text-[10px] text-slate-500 font-mono">UTF-8 Encoded</span>
</div>
<pre className="p-4 rounded-2xl bg-night-950 border border-white/10 font-mono text-xs text-cyan-200 overflow-x-auto max-h-[360px] leading-relaxed shadow-inner">
<code>{exportJsonString}</code>
</pre>
</div>
</div>
) : (
<div className="space-y-5">
{/* Import Textarea */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-300 flex items-center justify-between">
<span className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-pop-pink" />
<span>วางข้อความ JSON ผลลัพธ์จาก AI (ChatGPT / Claude / Gemini):</span>
</span>
<button
type="button"
onClick={async () => {
try {
const text = await navigator.clipboard.readText();
setImportText(text);
} catch {
// ignore
}
}}
className="text-[11px] text-cyan-glow hover:underline cursor-pointer flex items-center gap-1 font-semibold"
>
<span>วางจากคลิปบอร์ด</span>
</button>
</label>
<textarea
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder={`วางโค้ด JSON ที่ได้จาก AI ที่นี่ (รองรับทั้งแบบครอบ \`\`\`json หรือ Object ปกติ)\nตัวอย่าง:\n{\n "name": {\n "th": "เทียนหอมอโรมา",\n "en": "Aromatherapy Scented Candles",\n "ja": "アロマキャンドル",\n "zh": "香薰蜡烛",\n "vi": "Nến thơm thư giãn",\n "ko": "아로마 캔들",\n "lo": "ທຽນຫອມອາໂຣມາ",\n "it": "Candele aromatiche profumate"\n }\n}`}
rows={8}
className="w-full p-4 rounded-2xl bg-night-950 border border-white/10 focus:border-pop-pink/50 text-xs font-mono text-white placeholder:text-slate-600 outline-none transition-all shadow-inner leading-relaxed resize-y"
autoFocus
/>
</div>
{/* Parsing Feedback & Validation */}
{parsedImport?.error ? (
<div className="p-4 rounded-2xl bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs flex items-center gap-3">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{parsedImport.error}</span>
</div>
) : parsedImport ? (
<div className="space-y-4">
<div className="flex items-center justify-between p-3.5 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-bold">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>{parsedImport.statsText}</span>
</div>
<span className="text-[11px] font-normal text-emerald-400/80">พร้อมอัปเดตลงฟอร์ม</span>
</div>
{/* 8-Language Live Preview Grid */}
<div className="space-y-2">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">
ตัวอย่างชื่อหมวดหมู่ที่จะนำเข้า:
</span>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{SUPPORTED_LOCALES.map(({ code, label, full, flag }) => {
const val = parsedImport.name?.[code] || '';
return (
<div
key={code}
className={`p-3 rounded-xl border transition-all ${
val
? 'bg-night-950 border-white/10'
: 'bg-white/[0.02] border-white/5 opacity-40'
}`}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<span>{flag}</span>
<span className="text-xs font-bold text-white">{label}</span>
</div>
<span className="text-[10px] text-slate-500">{full}</span>
</div>
<p className="text-xs text-cyan-glow font-medium truncate" title={val}>
{val || <span className="text-slate-600 italic">ไม่มีข้อมูล</span>}
</p>
</div>
);
})}
</div>
</div>
</div>
) : null}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/60 flex items-center justify-between shrink-0">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-white/5 transition-all cursor-pointer"
>
ยกเลิก
</button>
<div className="flex items-center gap-3">
{activeTab === 'export' ? (
<button
type="button"
onClick={() => setActiveTab('import')}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-pop-pink hover:bg-pop-pink/90 text-white font-bold text-xs transition-all shadow-lg shadow-pop-pink/20 cursor-pointer"
>
<span>ไปที่หน้าวางผลแปล (Import)</span>
<ArrowRight className="w-4 h-4" />
</button>
) : (
<button
type="button"
onClick={handleApplyImport}
disabled={!parsedImport || Boolean(parsedImport.error) || parsedImport.filledCount === 0}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-emerald-500 hover:bg-emerald-400 disabled:opacity-50 disabled:cursor-not-allowed text-night-950 font-black text-xs transition-all shadow-lg shadow-emerald-500/20 cursor-pointer"
>
<Check className="w-4 h-4" />
<span>อัปเดตลงฟอร์มทันที (Apply to Form)</span>
</button>
)}
</div>
</div>
</div>
</div>,
document.body
);
}
@@ -157,6 +157,10 @@ export const BLOCK_TRANSLATABLE_FIELDS: Record<string, TranslatableFieldDef[]> =
{ key: 'title', label: 'Section Title' },
{ key: 'subtitle', label: 'Section Subtitle' },
],
CATEGORY_HEADER: [
{ key: 'title', label: 'Category Title / Heading' },
{ key: 'subtitle', label: 'Category Subtitle / Description', multiline: true },
],
PRODUCT_CAROUSEL: [
{ key: 'title', label: 'Carousel Title' },
{ key: 'description', label: 'Carousel Subtitle / Description', multiline: true },
+57 -6
View File
@@ -6,6 +6,7 @@ import {
ArrowLeft,
ArrowUp,
CheckCircle2,
Download,
Edit3,
Eye,
EyeOff,
@@ -23,6 +24,7 @@ import {
Shield,
Sparkles,
Trash2,
Upload,
} from 'lucide-react'
import { API_BASE, type Category, type CategorySection } from '@homaroma/types'
import MediaLibraryModal from '../components/MediaLibraryModal'
@@ -30,6 +32,7 @@ import CategoryHeroCarouselBlockForm from '../components/category/CategoryHeroCa
import CategoryDividerBlockForm from '../components/category/CategoryDividerBlockForm'
import { validateCategoryHeroCarouselPayload } from '../components/category/categoryHeroCarouselValidation'
import { getAdminDisplayString } from '../utils/localization'
import BlockTranslationModal, { isTranslatableBlock } from '../components/page-builder/BlockTranslationModal'
interface ApiValidationIssue {
path?: string
@@ -167,6 +170,8 @@ export default function CategoryCmsPage() {
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
const [isAddBlockModalOpen, setIsAddBlockModalOpen] = useState(false)
const [isTranslationModalOpen, setIsTranslationModalOpen] = useState(false)
const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export')
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']
@@ -671,12 +676,42 @@ export default function CategoryCmsPage() {
{SECTION_HELP[sectionType] || 'Configure the section payload for this category detail page.'}
</p>
</div>
{selectedSection?.isLocked && (
<div className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-night-950 px-3 py-2 text-xs font-bold uppercase tracking-widest text-slate-400">
<Lock className="h-3.5 w-3.5" />
Locked
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{isTranslatableBlock(sectionType) && (
<>
<button
type="button"
onClick={() => {
setTranslationModalTab('export')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
title="ส่งออกข้อความทุกภาษาของบล็อกนี้เพื่อนำไปแปลด้วย LLM"
>
<Download className="w-3.5 h-3.5" />
<span>Export ภาษา</span>
</button>
<button
type="button"
onClick={() => {
setTranslationModalTab('import')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-pop-pink/10 hover:bg-pop-pink/20 text-pop-pink border border-pop-pink/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
title="วาง JSON ผลลัพธ์จาก LLM เพื่ออัปเดตทุกภาษาพร้อมกัน"
>
<Upload className="w-3.5 h-3.5" />
<span>Import ภาษา</span>
</button>
</>
)}
{selectedSection?.isLocked && (
<div className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-night-950 px-3 py-2 text-xs font-bold uppercase tracking-widest text-slate-400">
<Lock className="h-3.5 w-3.5" />
Locked
</div>
)}
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-[1fr_auto]">
@@ -990,6 +1025,22 @@ export default function CategoryCmsPage() {
</div>
</div>
)}
{/* AI Multi-Language Translation Modal */}
<BlockTranslationModal
isOpen={isTranslationModalOpen}
onClose={() => setIsTranslationModalOpen(false)}
section={{
id: typeof selectedSectionId === 'number' ? selectedSectionId : undefined,
type: sectionType,
payload: sectionPayload,
}}
initialTab={translationModalTab}
onApplyTranslations={(updatedPayload) => {
setSectionPayload(updatedPayload)
setSectionJson(JSON.stringify(updatedPayload, null, 2))
}}
/>
</div>
)
}
+93 -20
View File
@@ -18,17 +18,19 @@ import {
LayoutDashboard,
CheckCircle2,
AlertCircle,
FileJson,
Check,
Code2,
Download,
Sparkles,
LayoutTemplate,
Columns,
Grid3X3,
EyeOff
EyeOff,
Upload,
} from 'lucide-react'
import MediaLibraryModal from '../components/MediaLibraryModal'
import CategoryMediaEditor from '../components/CategoryMediaEditor'
import CategoryTranslationModal from '../components/category/CategoryTranslationModal'
const ADDITIONAL_LANGS = [
{ code: 'ja', label: 'Japanese (日本語)', placeholder: '例: アロマキャンドル' },
@@ -62,6 +64,9 @@ export default function CategoryEditPage() {
const [showMoreLangs, setShowMoreLangs] = useState(false)
const [successToast, setSuccessToast] = useState<string | null>(null)
const [isTranslationModalOpen, setIsTranslationModalOpen] = useState(false)
const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export')
// JSON Quick Import State
const [isJsonBoxOpen, setIsJsonBoxOpen] = useState(false)
const [jsonInput, setJsonInput] = useState('')
@@ -473,6 +478,32 @@ export default function CategoryEditPage() {
</Link>
)}
{/* AI Multi-Language Translation Buttons */}
<button
type="button"
onClick={() => {
setTranslationModalTab('export')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-3.5 py-2.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
title="ส่งออกชื่อหมวดหมู่ 8 ภาษาเพื่อนำไปแปลด้วย AI"
>
<Download className="w-4 h-4" />
<span>Export ภาษา</span>
</button>
<button
type="button"
onClick={() => {
setTranslationModalTab('import')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-3.5 py-2.5 rounded-xl bg-pop-pink/10 hover:bg-pop-pink/20 text-pop-pink border border-pop-pink/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
title="วาง JSON ผลลัพธ์จาก AI เพื่ออัปเดตทุกภาษาพร้อมกัน"
>
<Upload className="w-4 h-4" />
<span>Import ภาษา</span>
</button>
<Link
to="/categories"
className="px-5 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-sm font-semibold transition-all cursor-pointer"
@@ -528,24 +559,50 @@ export default function CategoryEditPage() {
</div>
</div>
{/* JSON Paste Action Button */}
<button
type="button"
onClick={() => {
setIsJsonBoxOpen(!isJsonBoxOpen)
setJsonError(null)
setJsonSuccess(null)
}}
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all border cursor-pointer ${
isJsonBoxOpen
? 'bg-cyan-glow text-night-950 border-cyan-glow font-bold shadow-md shadow-cyan-glow/20'
: 'bg-white/5 hover:bg-cyan-glow/10 text-slate-300 hover:text-cyan-glow border-white/10 hover:border-cyan-glow/30'
}`}
title="Paste multilingual category names as JSON"
>
<FileJson className="w-4 h-4" />
<span>{isJsonBoxOpen ? 'Hide JSON Box' : 'Paste Names JSON'}</span>
</button>
{/* Multilingual AI Translation Buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setTranslationModalTab('export')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/20 text-xs font-bold transition-all cursor-pointer"
title="ส่งออกชื่อหมวดหมู่ 8 ภาษาเพื่อนำไปแปลด้วย AI"
>
<Download className="w-3.5 h-3.5" />
<span>Export ภาษา</span>
</button>
<button
type="button"
onClick={() => {
setTranslationModalTab('import')
setIsTranslationModalOpen(true)
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-pop-pink/10 hover:bg-pop-pink/20 text-pop-pink border border-pop-pink/20 text-xs font-bold transition-all cursor-pointer"
title="วาง JSON ผลลัพธ์จาก AI เพื่ออัปเดตทุกภาษาพร้อมกัน"
>
<Upload className="w-3.5 h-3.5" />
<span>Import ภาษา</span>
</button>
<button
type="button"
onClick={() => {
setIsJsonBoxOpen(!isJsonBoxOpen)
setJsonError(null)
setJsonSuccess(null)
}}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all border cursor-pointer ${
isJsonBoxOpen
? 'bg-white/10 text-white border-white/20'
: 'bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border-white/10'
}`}
title="Quick JSON Code Editor"
>
<Code2 className="w-3.5 h-3.5" />
<span>{isJsonBoxOpen ? 'Hide Code' : 'JSON Code'}</span>
</button>
</div>
</div>
{/* Expandable JSON Input Box */}
@@ -1038,6 +1095,22 @@ export default function CategoryEditPage() {
setIsMediaModalOpen(false)
}}
/>
{/* AI Multi-Language Translation Modal */}
<CategoryTranslationModal
isOpen={isTranslationModalOpen}
onClose={() => setIsTranslationModalOpen(false)}
categoryName={name.th || name.en}
categoryId={id}
data={{ name }}
initialTab={translationModalTab}
onApplyTranslations={({ name: updatedName }) => {
setName((prev) => ({ ...prev, ...updatedName }))
setShowMoreLangs(true)
setSuccessToast('อัปเดตชื่อหมวดหมู่ทุกภาษาเรียบร้อยแล้ว')
setTimeout(() => setSuccessToast(null), 3000)
}}
/>
</div>
)
}
+2
View File
@@ -61,6 +61,8 @@
@layer base {
:root,
html {
--navbar-height: 72px;
--navbar-bottom: 72px;
--font-body-locale: var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
--font-serif-locale: ui-serif;
+22 -5
View File
@@ -101,16 +101,28 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
}, [])
useEffect(() => {
let accumulatedDelta = 0
const onScroll = () => {
const currentScrollY = window.scrollY
const delta = currentScrollY - previousScrollY.current
if (currentScrollY <= 20) {
setScrollingDown(false)
} else if (delta > 4) {
setScrollingDown(true)
} else if (delta < -4) {
setScrollingDown(false)
accumulatedDelta = 0
} else {
// Reset accumulator if user reverses scroll direction
if ((delta > 0 && accumulatedDelta < 0) || (delta < 0 && accumulatedDelta > 0)) {
accumulatedDelta = 0
}
accumulatedDelta += delta
// Require 10px of deliberate scroll movement to prevent micro-jitter bounce
if (accumulatedDelta > 10) {
setScrollingDown(true)
} else if (accumulatedDelta < -10) {
setScrollingDown(false)
}
}
previousScrollY.current = currentScrollY
@@ -127,8 +139,13 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
const updateNavBottom = () => {
if (navElement) {
const bottom = navElement.getBoundingClientRect().bottom
const rect = navElement.getBoundingClientRect()
const bottom = Math.max(0, rect.bottom)
const height = navElement.offsetHeight || rect.height || 72
document.documentElement.style.setProperty('--navbar-bottom', `${bottom}px`)
if (height > 0) {
document.documentElement.style.setProperty('--navbar-height', `${height}px`)
}
}
}
@@ -2525,7 +2525,7 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
if (needsTopSpacing && rendered) {
return (
<div key={section.id} style={{ paddingTop: 'var(--navbar-bottom, 72px)' }} className="w-full">
<div key={section.id} style={{ paddingTop: 'var(--navbar-height, 72px)' }} className="w-full">
{rendered}
</div>
)