This commit is contained in:
Your Name committed 2026-09-25 05:03:46 +07:00
1 parent 44f731839f
commit 138078a398
1 file changed
+523 -109
+523 -109
View File
@@ -1,19 +1,52 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { API_BASE, getCategoryImage, type Category } from '@homaroma/types' import { API_BASE, getCategoryImage, type Category } from '@homaroma/types'
import { Edit3, Package, Trash2, Plus, X, Image as ImageIcon, UploadCloud, Loader2, ClipboardPaste, LayoutDashboard } from 'lucide-react' import {
Edit3,
Package,
Trash2,
Plus,
X,
Image as ImageIcon,
UploadCloud,
Loader2,
ClipboardPaste,
LayoutDashboard,
LayoutGrid,
List,
Search,
Globe,
ChevronDown,
ChevronUp,
Layers,
Copy
} from 'lucide-react'
import MediaLibraryModal from '../components/MediaLibraryModal' import MediaLibraryModal from '../components/MediaLibraryModal'
import ContentLanguageTabs from '../components/ContentLanguageTabs'
const ADDITIONAL_LANGS = [
{ code: 'ja', label: 'Japanese (日本語)', placeholder: '例: アロマキャンドル' },
{ code: 'zh', label: 'Chinese (中文)', placeholder: '例如: 香薰蜡烛' },
{ code: 'vi', label: 'Vietnamese (Tiếng Việt)', placeholder: 'Ví dụ: Nến thơm' },
{ code: 'ko', label: 'Korean (한국어)', placeholder: '예: 아로마 캔들' },
{ code: 'lo', label: 'Lao (ພາສາລາວ)', placeholder: 'ຕົວຢ່າງ: ທຽນຫອມ' },
{ code: 'it', label: 'Italian (Italiano)', placeholder: 'Es: Candela profumata' },
]
export default function CategoriesPage() { export default function CategoriesPage() {
const { t, i18n } = useTranslation() const { t } = useTranslation()
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list')
const [searchQuery, setSearchQuery] = useState('')
// Modal State
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [showMoreLangs, setShowMoreLangs] = useState(false)
const [editingCategory, setEditingCategory] = useState<Partial<Category> | null>(null) const [editingCategory, setEditingCategory] = useState<Partial<Category> | null>(null)
const [activeLang, setActiveLang] = useState('th')
// Media Modal State
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null) const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
@@ -22,9 +55,11 @@ export default function CategoriesPage() {
try { try {
const r = await fetch(`${API_BASE}/categories?lang=all`) const r = await fetch(`${API_BASE}/categories?lang=all`)
const data = await r.json() const data = await r.json()
if (Array.isArray(data)) {
setCategories(data) setCategories(data)
}
} catch (err) { } catch (err) {
console.error(err) console.error('Failed to fetch categories:', err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -34,38 +69,66 @@ export default function CategoriesPage() {
fetchData() fetchData()
}, []) }, [])
// Filtered categories
const filteredCategories = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return categories
return categories.filter((cat) => {
const idMatch = String(cat.id).includes(q)
if (typeof cat.name === 'string') {
return idMatch || cat.name.toLowerCase().includes(q)
}
if (cat.name && typeof cat.name === 'object') {
const anyLangMatch = Object.values(cat.name).some(
(val) => typeof val === 'string' && val.toLowerCase().includes(q)
)
return idMatch || anyLangMatch
}
return idMatch
})
}, [categories, searchQuery])
// Summary stats
const totalProducts = useMemo(() => {
return categories.reduce((sum, c) => sum + (c._count?.products || 0), 0)
}, [categories])
const handleSave = async (e: React.FormEvent) => { const handleSave = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!editingCategory) return if (!editingCategory) return
setIsSaving(true)
try { try {
const url = editingCategory.id const url = editingCategory.id
? `${API_BASE}/categories/${editingCategory.id}` ? `${API_BASE}/categories/${editingCategory.id}`
: `${API_BASE}/categories` : `${API_BASE}/categories`
const method = editingCategory.id ? 'PUT' : 'POST' const method = editingCategory.id ? 'PUT' : 'POST'
const masterName = typeof editingCategory.name === 'string'
? editingCategory.name
: (editingCategory.name?.th || editingCategory.name?.en || 'Untitled')
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: typeof editingCategory.name === 'string' name: masterName,
? editingCategory.name
: (editingCategory.name?.th || editingCategory.name?.en || 'Untitled'),
imageUrl: editingCategory.imageUrl || null imageUrl: editingCategory.imageUrl || null
}) })
}) })
if (res.ok) { if (res.ok) {
const savedCat = await res.json(); const savedCat = await res.json()
const catId = editingCategory.id || savedCat.id; const catId = editingCategory.id || savedCat.id
// Save translations // Save translations
if (typeof editingCategory.name === 'object') { if (typeof editingCategory.name === 'object' && catId) {
await fetch(`${API_BASE}/categories/${catId}/translations`, { await fetch(`${API_BASE}/categories/${catId}/translations`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editingCategory.name }) body: JSON.stringify({ name: editingCategory.name })
}); })
} }
setIsModalOpen(false) setIsModalOpen(false)
@@ -76,12 +139,14 @@ export default function CategoriesPage() {
} }
} catch (err) { } catch (err) {
console.error('Save error:', err) console.error('Save error:', err)
alert('An error occurred while saving.') alert('An error occurred while saving category.')
} finally {
setIsSaving(false)
} }
} }
const handleDelete = async (id: number) => { const handleDelete = async (id: number, catName: string) => {
if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this category?'))) return if (!confirm(t('categories.confirmDelete', `Are you sure you want to delete category "${catName}"?`))) return
try { try {
const res = await fetch(`${API_BASE}/categories/${id}`, { const res = await fetch(`${API_BASE}/categories/${id}`, {
method: 'DELETE' method: 'DELETE'
@@ -139,84 +204,313 @@ export default function CategoriesPage() {
const openAddModal = () => { const openAddModal = () => {
setEditingCategory({ setEditingCategory({
name: { th: '', en: '', ja: '', zh: '' } as any, name: { th: '', en: '', ja: '', zh: '', vi: '', ko: '', lo: '', it: '' } as any,
imageUrl: '' imageUrl: ''
}) })
setActiveLang('th') setShowMoreLangs(false)
setIsModalOpen(true) setIsModalOpen(true)
} }
const openEditModal = (cat: Category) => { const openEditModal = (cat: Category) => {
const nameObj = typeof cat.name === 'string'
? { th: cat.name, en: '', ja: '', zh: '', vi: '', ko: '', lo: '', it: '' }
: { th: '', en: '', ja: '', zh: '', vi: '', ko: '', lo: '', it: '', ...cat.name }
setEditingCategory({ setEditingCategory({
...cat, ...cat,
name: typeof cat.name === 'string' ? { th: cat.name } as any : { ...cat.name } name: nameObj as any
}) })
setActiveLang('th')
// Auto-open more langs if any secondary lang has content
const hasExtraLangs = ADDITIONAL_LANGS.some(l => Boolean((nameObj as any)[l.code]))
setShowMoreLangs(hasExtraLangs)
setIsModalOpen(true) setIsModalOpen(true)
} }
const handleCopyName = (fromLang: string) => { const updateCategoryNameField = (langCode: string, value: string) => {
if (!editingCategory?.name || typeof editingCategory.name !== 'object') return setEditingCategory(prev => {
const sourceText = (editingCategory.name as any)[fromLang] || '' if (!prev) return prev
if (!sourceText) return const currentName = typeof prev.name === 'object' ? { ...prev.name } : { th: prev.name || '' }
setEditingCategory({ return {
...editingCategory, ...prev,
name: { name: {
...(editingCategory.name as any), ...currentName,
[activeLang]: sourceText, [langCode]: value
} as any, } as any
}
}) })
} }
const getLocalizedCategoryName = (cat: Category) => { const copyNameFrom = (fromLang: string, targetLang: string) => {
if (!editingCategory?.name || typeof editingCategory.name !== 'object') return
const source = (editingCategory.name as any)[fromLang] || ''
if (!source) return
updateCategoryNameField(targetLang, source)
}
const getThaiName = (cat: Category) => {
if (typeof cat.name === 'string') return cat.name if (typeof cat.name === 'string') return cat.name
const lang = i18n.language return cat.name?.th || '-'
return cat.name?.[lang] || cat.name?.th || cat.name?.en || 'Unnamed' }
const getEnglishName = (cat: Category) => {
if (typeof cat.name === 'string') return '-'
return cat.name?.en || '-'
}
const getAvailableLangsCount = (cat: Category) => {
if (typeof cat.name === 'string') return 1
if (!cat.name || typeof cat.name !== 'object') return 0
return Object.values(cat.name).filter(v => Boolean(v && typeof v === 'string' && v.trim())).length
} }
return ( return (
<div className="space-y-10 animate-fade-in"> <div className="space-y-8 animate-fade-in relative pb-16">
{!isModalOpen ? ( {/* Header Bar */}
<> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="flex justify-between items-end">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">{t('categories.title', 'Category Management')}</h1> <h1 className="text-3xl font-bold">{t('categories.title', 'Category Management')}</h1>
<p className="text-slate-500 text-sm">{t('categories.subtitle', 'Organize products into multilingual categories')}</p> <span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20">
{categories.length} {t('categories.categoriesUnit', 'Categories')}
</span>
</div> </div>
<button onClick={openAddModal} className="btn-primary flex items-center gap-2"> <p className="text-slate-500 text-sm">
{t('categories.subtitle', 'Organize products into multilingual categories')} • {totalProducts} {t('categories.totalProducts', 'Total Products')}
</p>
</div>
<div className="flex items-center gap-3">
{/* View Switcher Toggle */}
<div className="flex items-center p-1 bg-night-900 border border-white/10 rounded-xl shadow-inner">
<button
onClick={() => setViewMode('list')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
viewMode === 'list'
? 'bg-cyan-glow text-night-950 font-bold shadow-sm'
: 'text-slate-400 hover:text-white'
}`}
title="List / Table View"
>
<List className="w-4 h-4" />
<span>List</span>
</button>
<button
onClick={() => setViewMode('grid')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
viewMode === 'grid'
? 'bg-cyan-glow text-night-950 font-bold shadow-sm'
: 'text-slate-400 hover:text-white'
}`}
title="Grid / Card View"
>
<LayoutGrid className="w-4 h-4" />
<span>Grid</span>
</button>
</div>
{/* Add Category Button */}
<button
onClick={openAddModal}
className="btn-primary flex items-center gap-2 cursor-pointer shadow-lg shadow-cyan-glow/10"
>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
{t('categories.addNew', 'Create Category')} {t('categories.addNew', 'Create Category')}
</button> </button>
</div> </div>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> {/* Search Bar */}
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="flex-1 w-full relative group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 group-focus-within:text-cyan-glow transition-colors" />
<input
type="text"
placeholder={t('categories.searchPlaceholder', 'Search categories by name (TH/EN) or ID...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-night-900/50 border border-white/5 focus:border-cyan-glow/30 rounded-xl outline-none text-sm placeholder:text-slate-600 transition-all shadow-inner text-white"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-500 hover:text-white cursor-pointer"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Content Rendering: List View vs Grid View */}
{viewMode === 'list' ? (
/* ─── LIST / TABLE VIEW ─── */
<div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl">
<div className="overflow-x-auto min-h-[300px]">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-white/[0.02] border-b border-white/5">
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 w-20">Image</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 w-16">ID</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Thai Name (ชื่อหมวดหมู่)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">English Name</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-center">Languages</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-center">Products</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.03]">
{loading ? ( {loading ? (
Array.from({ length: 5 }).map((_, i) => ( Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="admin-card h-48 bg-white/5 animate-pulse" /> <tr key={i}>
<td colSpan={7} className="px-6 py-6">
<div className="h-8 w-full bg-white/5 animate-pulse rounded-lg" />
</td>
</tr>
)) ))
) : filteredCategories.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-16 text-center space-y-3">
<Layers className="w-12 h-12 text-slate-700 mx-auto" />
<p className="text-slate-400 font-semibold">{t('categories.noFound', 'No categories found')}</p>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="text-xs text-cyan-glow hover:underline cursor-pointer"
>
Clear search filter
</button>
)}
</td>
</tr>
) : ( ) : (
categories.map((cat) => ( filteredCategories.map((cat) => {
<div key={cat.id} className="admin-card admin-card-hover group relative overflow-hidden"> const fallbackName = typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || '')
<div className="flex justify-between items-start mb-6"> const imageUrl = cat.imageUrl || getCategoryImage(fallbackName)
<div className="w-14 h-14 rounded-2xl overflow-hidden glass border border-white/10 p-1"> const langsCount = getAvailableLangsCount(cat)
const thaiName = getThaiName(cat)
return (
<tr
key={cat.id}
className="hover:bg-white/[0.02] transition-colors group"
>
{/* Thumbnail */}
<td className="px-6 py-3.5">
<div className="w-12 h-12 rounded-xl overflow-hidden glass border border-white/10 p-0.5 bg-night-950 flex-shrink-0">
<img <img
src={cat.imageUrl || getCategoryImage(typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || ''))} src={imageUrl}
alt={typeof cat.name === 'string' ? cat.name : (cat.name?.th || '')} alt={thaiName}
className="w-full h-full object-cover grayscale opacity-40 group-hover:grayscale-0 group-hover:opacity-100 transition-all duration-700" className="w-full h-full object-cover rounded-lg group-hover:scale-105 transition-transform duration-300"
/> />
</div> </div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity relative z-10"> </td>
{/* ID */}
<td className="px-6 py-3.5 text-xs font-mono text-slate-500">
#{cat.id}
</td>
{/* Thai Name */}
<td className="px-6 py-3.5">
<div className="font-bold text-white text-sm">
{thaiName}
</div>
</td>
{/* English Name */}
<td className="px-6 py-3.5">
<div className="text-sm text-slate-300">
{getEnglishName(cat)}
</div>
</td>
{/* Languages Badge */}
<td className="px-6 py-3.5 text-center">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-white/5 border border-white/10 text-slate-400">
<Globe className="w-3 h-3 text-cyan-glow" />
{langsCount} {langsCount === 1 ? 'lang' : 'langs'}
</span>
</td>
{/* Product Count */}
<td className="px-6 py-3.5 text-center">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-cyan-glow/5 text-cyan-glow border border-cyan-glow/20">
<Package className="w-3.5 h-3.5" />
{cat._count?.products || 0}
</span>
</td>
{/* Actions */}
<td className="px-6 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button <button
onClick={() => openEditModal(cat)} onClick={() => openEditModal(cat)}
title={t('common.edit', 'Edit')} title={t('common.edit', 'Edit Category')}
className="p-2 rounded-lg bg-night-800 border border-white/5 text-slate-500 hover:text-cyan-glow hover:border-cyan-glow/30 transition-all cursor-pointer" className="p-2 rounded-xl bg-night-800 border border-white/10 text-slate-400 hover:text-cyan-glow hover:border-cyan-glow/30 hover:bg-cyan-glow/10 transition-all cursor-pointer shadow-sm"
> >
<Edit3 className="w-4 h-4" /> <Edit3 className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => handleDelete(cat.id)} onClick={() => handleDelete(cat.id, thaiName)}
title={t('common.delete', 'Delete Category')}
className="p-2 rounded-xl bg-night-800 border border-white/10 text-slate-400 hover:text-rose-500 hover:border-rose-500/30 hover:bg-rose-500/10 transition-all cursor-pointer shadow-sm"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
) : (
/* ─── GRID / CARDS VIEW ─── */
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="admin-card h-48 bg-white/5 animate-pulse" />
))
) : filteredCategories.length === 0 ? (
<div className="col-span-full py-16 text-center space-y-3 admin-card">
<Layers className="w-12 h-12 text-slate-700 mx-auto" />
<p className="text-slate-400 font-semibold">{t('categories.noFound', 'No categories found')}</p>
</div>
) : (
filteredCategories.map((cat) => {
const fallbackName = typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || '')
const imageUrl = cat.imageUrl || getCategoryImage(fallbackName)
const thaiName = getThaiName(cat)
const englishName = getEnglishName(cat)
return (
<div key={cat.id} className="admin-card admin-card-hover group relative overflow-hidden flex flex-col justify-between p-6">
<div className="flex justify-between items-start gap-4 mb-4">
<div className="w-16 h-16 rounded-2xl overflow-hidden glass border border-white/10 p-1 bg-night-950 flex-shrink-0">
<img
src={imageUrl}
alt={thaiName}
className="w-full h-full object-cover rounded-xl group-hover:scale-105 transition-transform duration-500"
/>
</div>
{/* Actions on Card (Always visible with subtle hover highlights) */}
<div className="flex gap-1.5 relative z-10">
<button
onClick={() => openEditModal(cat)}
title={t('common.edit', 'Edit')}
className="p-2 rounded-xl bg-night-800 border border-white/10 text-slate-400 hover:text-cyan-glow hover:border-cyan-glow/30 hover:bg-cyan-glow/10 transition-all cursor-pointer"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(cat.id, thaiName)}
title={t('common.delete', 'Delete')} title={t('common.delete', 'Delete')}
className="p-2 rounded-lg bg-night-800 border border-white/5 text-slate-500 hover:text-rose-500 hover:border-rose-500/30 transition-all cursor-pointer" className="p-2 rounded-xl bg-night-800 border border-white/10 text-slate-400 hover:text-rose-500 hover:border-rose-500/30 hover:bg-rose-500/10 transition-all cursor-pointer"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
@@ -224,55 +518,82 @@ export default function CategoriesPage() {
</div> </div>
<div className="space-y-1 relative z-10"> <div className="space-y-1 relative z-10">
<h3 className="text-xl font-bold text-white tracking-tight"> <div className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">ID #{cat.id}</div>
{getLocalizedCategoryName(cat)} <h3 className="text-lg font-bold text-white tracking-tight line-clamp-1">
{thaiName}
</h3> </h3>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-500"> {englishName !== '-' && (
<Package className="w-3 h-3 text-cyan-glow/50" /> <p className="text-xs text-slate-400 line-clamp-1">{englishName}</p>
)}
<div className="flex items-center justify-between pt-3 mt-3 border-t border-white/5 text-xs text-slate-400">
<div className="flex items-center gap-1.5 font-semibold text-cyan-glow">
<Package className="w-3.5 h-3.5" />
<span>{cat._count?.products || 0} {t('categories.itemCount', 'Products')}</span> <span>{cat._count?.products || 0} {t('categories.itemCount', 'Products')}</span>
</div> </div>
<span className="text-[11px] text-slate-500">
{getAvailableLangsCount(cat)} langs
</span>
</div>
</div> </div>
<div className="absolute top-0 right-0 w-32 h-32 bg-cyan-glow/5 blur-3xl rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" /> <div className="absolute top-0 right-0 w-32 h-32 bg-cyan-glow/5 blur-3xl rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
</div> </div>
)) )
})
)} )}
</div> </div>
</> )}
) : (
editingCategory && ( {/* ─── SIMPLIFIED EDIT / CREATE MODAL ─── */}
<div className="w-full max-w-3xl mx-auto bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-fade-in"> {isModalOpen && editingCategory && (
<div className="p-6 border-b border-white/5 flex flex-shrink-0 items-center justify-between bg-white/[0.02]"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-night-950/80 backdrop-blur-md animate-fade-in overflow-y-auto">
<h3 className="text-xl font-bold flex items-center gap-2"> <div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden my-8">
<LayoutDashboard className="w-5 h-5 text-cyan-glow" /> {/* Modal Header */}
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-white/[0.02]">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow">
<LayoutDashboard className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-bold text-white">
{editingCategory.id ? t('categories.editCategory', 'Edit Category') : t('categories.addNew', 'Create Category')} {editingCategory.id ? t('categories.editCategory', 'Edit Category') : t('categories.addNew', 'Create Category')}
</h3> </h3>
<p className="text-xs text-slate-400">
{editingCategory.id ? `Editing category ID #${editingCategory.id}` : 'Fill in the details below to add a new category'}
</p>
</div>
</div>
<button <button
onClick={() => setIsModalOpen(false)} onClick={() => setIsModalOpen(false)}
className="p-2 rounded-xl hover:bg-white/5 text-slate-500 hover:text-white transition-all cursor-pointer" className="p-2 rounded-xl hover:bg-white/5 text-slate-400 hover:text-white transition-all cursor-pointer"
> >
<X className="w-6 h-6" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
<form onSubmit={handleSave} className="p-8 space-y-6"> {/* Modal Body */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <form onSubmit={handleSave} className="p-6 md:p-8 space-y-6">
{/* Image Upload Area */} {/* Category Image Section */}
<div className="md:col-span-2 space-y-3"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('categories.image', 'Category Image')}</label> <label className="text-[11px] uppercase font-bold tracking-widest text-slate-400">
{t('categories.image', 'Category Image')}
</label>
<button <button
type="button" type="button"
onClick={handlePasteFromClipboard} onClick={handlePasteFromClipboard}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-wider transition-all border border-cyan-glow/20 cursor-pointer" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow text-[11px] font-bold uppercase tracking-wider transition-all border border-cyan-glow/20 cursor-pointer"
> >
<ClipboardPaste className="w-3.5 h-3.5" /> <ClipboardPaste className="w-3.5 h-3.5" />
Paste Image Paste from Clipboard
</button> </button>
</div> </div>
<div className="flex items-center gap-6 p-4 rounded-2xl bg-white/[0.02] border border-white/5">
<div <div
className="relative w-40 h-40 mx-auto rounded-3xl overflow-hidden border-2 border-dashed border-white/5 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all" className="relative w-28 h-28 rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/60 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/40 transition-all flex-shrink-0"
onClick={() => { onClick={() => {
setOnSelectMedia(() => (url: string) => setEditingCategory(prev => ({ ...prev, imageUrl: url }))) setOnSelectMedia(() => (url: string) => setEditingCategory(prev => ({ ...prev, imageUrl: url })))
setIsMediaModalOpen(true) setIsMediaModalOpen(true)
@@ -281,73 +602,166 @@ export default function CategoriesPage() {
{editingCategory.imageUrl ? ( {editingCategory.imageUrl ? (
<> <>
<img src={editingCategory.imageUrl} className="w-full h-full object-cover" alt="Category" /> <img src={editingCategory.imageUrl} className="w-full h-full object-cover" alt="Category" />
<div className="absolute inset-0 bg-night-950/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm"> <div className="absolute inset-0 bg-night-950/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
<UploadCloud className="w-8 h-8 text-white" /> <UploadCloud className="w-6 h-6 text-white" />
</div> </div>
</> </>
) : ( ) : (
<div className="text-center space-y-2 p-4"> <div className="text-center space-y-1 p-2">
<ImageIcon className="w-8 h-8 text-slate-700 mx-auto" /> <ImageIcon className="w-6 h-6 text-slate-600 mx-auto" />
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Select Image</p> <p className="text-[9px] text-slate-400 font-bold uppercase tracking-wider">Select Image</p>
</div> </div>
)} )}
{isUploading && ( {isUploading && (
<div className="absolute inset-0 bg-night-950/60 flex items-center justify-center"> <div className="absolute inset-0 bg-night-950/70 flex items-center justify-center">
<Loader2 className="w-6 h-6 text-cyan-glow animate-spin" /> <Loader2 className="w-5 h-5 text-cyan-glow animate-spin" />
</div> </div>
)} )}
</div> </div>
<div className="space-y-2 flex-1">
<p className="text-xs text-slate-300 font-medium">Click thumbnail to select from Media Library</p>
<p className="text-[11px] text-slate-500">Recommended ratio: Square 1:1 (e.g. 600x600 px) in JPG, PNG, or WebP format.</p>
{editingCategory.imageUrl && (
<button
type="button"
onClick={() => setEditingCategory(prev => ({ ...prev, imageUrl: '' }))}
className="text-xs text-rose-400 hover:underline cursor-pointer"
>
Remove Image
</button>
)}
</div>
</div>
</div> </div>
{/* Multilingual Name Field */} {/* Primary Names (Thai & English) */}
<div className="md:col-span-2 space-y-3"> <div className="space-y-4">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500"> <div className="flex items-center gap-2">
{t('categories.name', 'Category Name')} (4 Languages: TH, EN, JA, ZH) <span className="text-xs font-bold uppercase tracking-wider text-slate-400">Primary Names</span>
<div className="h-px bg-white/10 flex-1" />
</div>
{/* Thai Name */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-300 flex items-center gap-2">
<span>🇹🇭 ชื่อภาษาไทย (Thai Name)</span>
<span className="text-rose-400 text-xs">*จำเป็น</span>
</label> </label>
<ContentLanguageTabs
activeLang={activeLang}
onSelectLang={setActiveLang}
translationsMap={typeof editingCategory.name === 'object' ? editingCategory.name : { th: editingCategory.name }}
onCopyFrom={handleCopyName}
/>
<input <input
type="text" type="text"
required={activeLang === 'th'} required
value={(editingCategory.name as any)?.[activeLang] || ''} value={(editingCategory.name as any)?.th || ''}
onChange={(e) => setEditingCategory({ onChange={(e) => updateCategoryNameField('th', e.target.value)}
...editingCategory, className="admin-input w-full"
name: { ...(editingCategory.name as any), [activeLang]: e.target.value } as any placeholder="เช่น เทียนหอม, น้ำมันหอมระเหย, ก้านไม้หอม"
})} />
className="admin-input w-full mt-2" </div>
placeholder={`Category name in ${activeLang.toUpperCase()} (${activeLang === 'th' ? 'เช่น เทียนหอม' : activeLang === 'en' ? 'e.g. Scented Candle' : activeLang === 'ja' ? '例: アロマキャンドル' : '例如: 香薰蜡烛'})`}
{/* English Name */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-slate-300 flex items-center gap-2">
<span>🇬🇧 ชื่อภาษาอังกฤษ (English Name)</span>
</label>
<button
type="button"
onClick={() => copyNameFrom('th', 'en')}
className="text-[11px] text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
title="Copy Thai name to English"
>
<Copy className="w-3 h-3" />
Copy from TH
</button>
</div>
<input
type="text"
value={(editingCategory.name as any)?.en || ''}
onChange={(e) => updateCategoryNameField('en', e.target.value)}
className="admin-input w-full"
placeholder="e.g. Scented Candles, Essential Oils, Reed Diffusers"
/> />
</div> </div>
</div> </div>
<div className="pt-6 border-t border-white/5 flex flex-col md:flex-row gap-4"> {/* Additional Languages Accordion (Optional) */}
<div className="border border-white/5 rounded-2xl bg-white/[0.01] overflow-hidden">
<button <button
type="submit" type="button"
disabled={isUploading} onClick={() => setShowMoreLangs(!showMoreLangs)}
className="flex-1 btn-primary py-3 flex items-center justify-center gap-2 cursor-pointer" className="w-full px-4 py-3 flex items-center justify-between hover:bg-white/[0.02] transition-colors cursor-pointer text-left"
> >
{editingCategory.id ? t('common.saveChanges', 'Save Changes') : t('categories.addNew', 'Create Category')} <div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-cyan-glow" />
<span className="text-xs font-semibold text-slate-300">
Additional Translations (JA, ZH, VI, KO, LO, IT)
</span>
</div>
<div className="flex items-center gap-2 text-slate-500">
<span className="text-[11px]">{showMoreLangs ? 'Hide' : 'Expand'}</span>
{showMoreLangs ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</div>
</button> </button>
{showMoreLangs && (
<div className="p-4 pt-2 border-t border-white/5 grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in">
{ADDITIONAL_LANGS.map(({ code, label, placeholder }) => (
<div key={code} className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-[11px] font-medium text-slate-400">{label}</label>
<button
type="button"
onClick={() => copyNameFrom('en', code)}
className="text-[10px] text-slate-500 hover:text-cyan-glow cursor-pointer"
title="Copy from EN"
>
Copy EN
</button>
</div>
<input
type="text"
value={(editingCategory.name as any)?.[code] || ''}
onChange={(e) => updateCategoryNameField(code, e.target.value)}
className="admin-input w-full text-xs py-2"
placeholder={placeholder}
/>
</div>
))}
</div>
)}
</div>
{/* Modal Actions */}
<div className="pt-4 border-t border-white/5 flex flex-col-reverse md:flex-row gap-3">
<button <button
type="button" type="button"
onClick={() => setIsModalOpen(false)} onClick={() => setIsModalOpen(false)}
className="px-8 py-3 bg-white/5 hover:bg-white/10 text-white font-bold rounded-xl transition-all cursor-pointer" className="px-6 py-3 bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white font-semibold rounded-xl text-sm transition-all cursor-pointer text-center"
> >
{t('common.cancel', 'Cancel')} {t('common.cancel', 'Cancel')}
</button> </button>
<button
type="submit"
disabled={isSaving || isUploading}
className="flex-1 btn-primary py-3 flex items-center justify-center gap-2 cursor-pointer text-sm font-bold shadow-lg shadow-cyan-glow/10"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Saving...</span>
</>
) : (
<span>{editingCategory.id ? t('common.saveChanges', 'Save Changes') : t('categories.addNew', 'Create Category')}</span>
)}
</button>
</div> </div>
</form> </form>
</div> </div>
) </div>
)} )}
{/* Media Library Modal */}
<MediaLibraryModal <MediaLibraryModal
isOpen={isMediaModalOpen} isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)} onClose={() => setIsMediaModalOpen(false)}