x
This commit is contained in:
1 parent
44f731839f
commit
138078a398
1 file changed
+583
-169
@@ -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()
|
||||||
setCategories(data)
|
if (Array.isArray(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,215 +204,564 @@ 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">
|
||||||
</div>
|
{categories.length} {t('categories.categoriesUnit', 'Categories')}
|
||||||
<button onClick={openAddModal} className="btn-primary flex items-center gap-2">
|
</span>
|
||||||
<Plus className="w-4 h-4" />
|
</div>
|
||||||
{t('categories.addNew', 'Create Category')}
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{/* Add Category Button */}
|
||||||
{loading ? (
|
<button
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
onClick={openAddModal}
|
||||||
<div key={i} className="admin-card h-48 bg-white/5 animate-pulse" />
|
className="btn-primary flex items-center gap-2 cursor-pointer shadow-lg shadow-cyan-glow/10"
|
||||||
))
|
>
|
||||||
) : (
|
<Plus className="w-4 h-4" />
|
||||||
categories.map((cat) => (
|
{t('categories.addNew', 'Create Category')}
|
||||||
<div key={cat.id} className="admin-card admin-card-hover group relative overflow-hidden">
|
</button>
|
||||||
<div className="flex justify-between items-start mb-6">
|
</div>
|
||||||
<div className="w-14 h-14 rounded-2xl overflow-hidden glass border border-white/10 p-1">
|
</div>
|
||||||
<img
|
|
||||||
src={cat.imageUrl || getCategoryImage(typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || ''))}
|
|
||||||
alt={typeof cat.name === 'string' ? cat.name : (cat.name?.th || '')}
|
|
||||||
className="w-full h-full object-cover grayscale opacity-40 group-hover:grayscale-0 group-hover:opacity-100 transition-all duration-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity relative z-10">
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(cat)}
|
|
||||||
title={t('common.edit', 'Edit')}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(cat.id)}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 relative z-10">
|
{/* Search Bar */}
|
||||||
<h3 className="text-xl font-bold text-white tracking-tight">
|
<div className="flex flex-col md:flex-row gap-4 items-center">
|
||||||
{getLocalizedCategoryName(cat)}
|
<div className="flex-1 w-full relative group">
|
||||||
</h3>
|
<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" />
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
<input
|
||||||
<Package className="w-3 h-3 text-cyan-glow/50" />
|
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 ? (
|
||||||
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
filteredCategories.map((cat) => {
|
||||||
|
const fallbackName = typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || '')
|
||||||
|
const imageUrl = cat.imageUrl || getCategoryImage(fallbackName)
|
||||||
|
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
|
||||||
|
src={imageUrl}
|
||||||
|
alt={thaiName}
|
||||||
|
className="w-full h-full object-cover rounded-lg group-hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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
|
||||||
|
onClick={() => openEditModal(cat)}
|
||||||
|
title={t('common.edit', 'Edit Category')}
|
||||||
|
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" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
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')}
|
||||||
|
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" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 relative z-10">
|
||||||
|
<div className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">ID #{cat.id}</div>
|
||||||
|
<h3 className="text-lg font-bold text-white tracking-tight line-clamp-1">
|
||||||
|
{thaiName}
|
||||||
|
</h3>
|
||||||
|
{englishName !== '-' && (
|
||||||
|
<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>
|
||||||
</div>
|
<span className="text-[11px] text-slate-500">
|
||||||
|
{getAvailableLangsCount(cat)} langs
|
||||||
|
</span>
|
||||||
|
</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 && (
|
|
||||||
<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">
|
{/* ─── SIMPLIFIED EDIT / CREATE MODAL ─── */}
|
||||||
<div className="p-6 border-b border-white/5 flex flex-shrink-0 items-center justify-between bg-white/[0.02]">
|
{isModalOpen && editingCategory && (
|
||||||
<h3 className="text-xl font-bold flex items-center gap-2">
|
<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">
|
||||||
<LayoutDashboard className="w-5 h-5 text-cyan-glow" />
|
<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">
|
||||||
{editingCategory.id ? t('categories.editCategory', 'Edit Category') : t('categories.addNew', 'Create Category')}
|
{/* Modal Header */}
|
||||||
</h3>
|
<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')}
|
||||||
|
</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">
|
||||||
<button
|
{t('categories.image', 'Category Image')}
|
||||||
type="button"
|
</label>
|
||||||
onClick={handlePasteFromClipboard}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={handlePasteFromClipboard}
|
||||||
<ClipboardPaste className="w-3.5 h-3.5" />
|
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"
|
||||||
Paste Image
|
>
|
||||||
</button>
|
<ClipboardPaste className="w-3.5 h-3.5" />
|
||||||
</div>
|
Paste from Clipboard
|
||||||
<div
|
</button>
|
||||||
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"
|
|
||||||
onClick={() => {
|
|
||||||
setOnSelectMedia(() => (url: string) => setEditingCategory(prev => ({ ...prev, imageUrl: url })))
|
|
||||||
setIsMediaModalOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{editingCategory.imageUrl ? (
|
|
||||||
<>
|
|
||||||
<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">
|
|
||||||
<UploadCloud className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center space-y-2 p-4">
|
|
||||||
<ImageIcon className="w-8 h-8 text-slate-700 mx-auto" />
|
|
||||||
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Select Image</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isUploading && (
|
|
||||||
<div className="absolute inset-0 bg-night-950/60 flex items-center justify-center">
|
|
||||||
<Loader2 className="w-6 h-6 text-cyan-glow animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Multilingual Name Field */}
|
<div className="flex items-center gap-6 p-4 rounded-2xl bg-white/[0.02] border border-white/5">
|
||||||
<div className="md:col-span-2 space-y-3">
|
<div
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">
|
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"
|
||||||
{t('categories.name', 'Category Name')} (4 Languages: TH, EN, JA, ZH)
|
onClick={() => {
|
||||||
|
setOnSelectMedia(() => (url: string) => setEditingCategory(prev => ({ ...prev, imageUrl: url })))
|
||||||
|
setIsMediaModalOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editingCategory.imageUrl ? (
|
||||||
|
<>
|
||||||
|
<img src={editingCategory.imageUrl} className="w-full h-full object-cover" alt="Category" />
|
||||||
|
<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-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center space-y-1 p-2">
|
||||||
|
<ImageIcon className="w-6 h-6 text-slate-600 mx-auto" />
|
||||||
|
<p className="text-[9px] text-slate-400 font-bold uppercase tracking-wider">Select Image</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUploading && (
|
||||||
|
<div className="absolute inset-0 bg-night-950/70 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-5 h-5 text-cyan-glow animate-spin" />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Primary Names (Thai & English) */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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)}
|
||||||
|
|||||||
Reference in new issue
Block a user