x
This commit is contained in:
1 parent
138078a398
commit
3e742ac468
5 files changed
+871
-417
No files matched your search
@@ -6,6 +6,7 @@ import ProductEditPage from './pages/ProductEditPage'
|
|||||||
import ProductBomPage from './pages/ProductBomPage'
|
import ProductBomPage from './pages/ProductBomPage'
|
||||||
import ProductCmsPage from './pages/ProductCmsPage'
|
import ProductCmsPage from './pages/ProductCmsPage'
|
||||||
import CategoriesPage from './pages/CategoriesPage'
|
import CategoriesPage from './pages/CategoriesPage'
|
||||||
|
import CategoryEditPage from './pages/CategoryEditPage'
|
||||||
import CustomersPage from './pages/CustomersPage'
|
import CustomersPage from './pages/CustomersPage'
|
||||||
import PageBuilderPage from './pages/PageBuilderPage'
|
import PageBuilderPage from './pages/PageBuilderPage'
|
||||||
import CmsMockupPage from './pages/CmsMockupPage'
|
import CmsMockupPage from './pages/CmsMockupPage'
|
||||||
@@ -92,6 +93,9 @@ function App() {
|
|||||||
<Route path="/products/:id/cms" element={<ProductCmsPage />} />
|
<Route path="/products/:id/cms" element={<ProductCmsPage />} />
|
||||||
<Route path="/products/:id/bom" element={<ProductBomPage />} />
|
<Route path="/products/:id/bom" element={<ProductBomPage />} />
|
||||||
<Route path="/categories" element={<CategoriesPage />} />
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
|
<Route path="/categories/new" element={<CategoryEditPage />} />
|
||||||
|
<Route path="/categories/:id" element={<CategoryEditPage />} />
|
||||||
|
<Route path="/categories/:id/edit" element={<CategoryEditPage />} />
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
<Route path="/page-builder" element={<PageBuilderPage />} />
|
<Route path="/page-builder" element={<PageBuilderPage />} />
|
||||||
<Route path="/cms-mockup" element={<CmsMockupPage />} />
|
<Route path="/cms-mockup" element={<CmsMockupPage />} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
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 {
|
import {
|
||||||
@@ -7,48 +8,20 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Plus,
|
Plus,
|
||||||
X,
|
X,
|
||||||
Image as ImageIcon,
|
|
||||||
UploadCloud,
|
|
||||||
Loader2,
|
|
||||||
ClipboardPaste,
|
|
||||||
LayoutDashboard,
|
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
Search,
|
Search,
|
||||||
Globe,
|
Globe,
|
||||||
ChevronDown,
|
Layers
|
||||||
ChevronUp,
|
|
||||||
Layers,
|
|
||||||
Copy
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import MediaLibraryModal from '../components/MediaLibraryModal'
|
|
||||||
|
|
||||||
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 } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const navigate = useNavigate()
|
||||||
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 [viewMode, setViewMode] = useState<'list' | 'grid'>('list')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
// Modal State
|
|
||||||
const [isModalOpen, setIsModalOpen] = 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)
|
|
||||||
|
|
||||||
// Media Modal State
|
|
||||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
|
||||||
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -94,57 +67,6 @@ export default function CategoriesPage() {
|
|||||||
return categories.reduce((sum, c) => sum + (c._count?.products || 0), 0)
|
return categories.reduce((sum, c) => sum + (c._count?.products || 0), 0)
|
||||||
}, [categories])
|
}, [categories])
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!editingCategory) return
|
|
||||||
|
|
||||||
setIsSaving(true)
|
|
||||||
try {
|
|
||||||
const url = editingCategory.id
|
|
||||||
? `${API_BASE}/categories/${editingCategory.id}`
|
|
||||||
: `${API_BASE}/categories`
|
|
||||||
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, {
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: masterName,
|
|
||||||
imageUrl: editingCategory.imageUrl || null
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const savedCat = await res.json()
|
|
||||||
const catId = editingCategory.id || savedCat.id
|
|
||||||
|
|
||||||
// Save translations
|
|
||||||
if (typeof editingCategory.name === 'object' && catId) {
|
|
||||||
await fetch(`${API_BASE}/categories/${catId}/translations`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: editingCategory.name })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsModalOpen(false)
|
|
||||||
fetchData()
|
|
||||||
} else {
|
|
||||||
const errorData = await res.json()
|
|
||||||
alert(errorData.message || 'Failed to save category')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Save error:', err)
|
|
||||||
alert('An error occurred while saving category.')
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: number, catName: string) => {
|
const handleDelete = async (id: number, catName: string) => {
|
||||||
if (!confirm(t('categories.confirmDelete', `Are you sure you want to delete category "${catName}"?`))) return
|
if (!confirm(t('categories.confirmDelete', `Are you sure you want to delete category "${catName}"?`))) return
|
||||||
try {
|
try {
|
||||||
@@ -159,95 +81,10 @@ export default function CategoriesPage() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete error:', err)
|
console.error('Delete error:', err)
|
||||||
|
alert('Failed to delete category')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
|
||||||
setIsUploading(true)
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/upload`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.url) {
|
|
||||||
setEditingCategory(prev => ({ ...prev, imageUrl: data.url }))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Upload error:', err)
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePasteFromClipboard = async () => {
|
|
||||||
try {
|
|
||||||
const items = await navigator.clipboard.read()
|
|
||||||
for (const item of items) {
|
|
||||||
const imageTypes = item.types.filter(t => t.startsWith('image/'))
|
|
||||||
for (const type of imageTypes) {
|
|
||||||
const blob = await item.getType(type)
|
|
||||||
const file = new File([blob], 'pasted-category-image.png', { type })
|
|
||||||
handleUpload(file)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
alert('No image found in clipboard')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Clipboard error:', err)
|
|
||||||
alert('Please allow clipboard access or copy an image first.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openAddModal = () => {
|
|
||||||
setEditingCategory({
|
|
||||||
name: { th: '', en: '', ja: '', zh: '', vi: '', ko: '', lo: '', it: '' } as any,
|
|
||||||
imageUrl: ''
|
|
||||||
})
|
|
||||||
setShowMoreLangs(false)
|
|
||||||
setIsModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
|
||||||
...cat,
|
|
||||||
name: nameObj as any
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateCategoryNameField = (langCode: string, value: string) => {
|
|
||||||
setEditingCategory(prev => {
|
|
||||||
if (!prev) return prev
|
|
||||||
const currentName = typeof prev.name === 'object' ? { ...prev.name } : { th: prev.name || '' }
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
name: {
|
|
||||||
...currentName,
|
|
||||||
[langCode]: value
|
|
||||||
} as any
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
const getThaiName = (cat: Category) => {
|
||||||
if (typeof cat.name === 'string') return cat.name
|
if (typeof cat.name === 'string') return cat.name
|
||||||
return cat.name?.th || '-'
|
return cat.name?.th || '-'
|
||||||
@@ -270,7 +107,7 @@ export default function CategoriesPage() {
|
|||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-3">
|
<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 text-white">{t('categories.title', 'Category Management')}</h1>
|
||||||
<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">
|
<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')}
|
{categories.length} {t('categories.categoriesUnit', 'Categories')}
|
||||||
</span>
|
</span>
|
||||||
@@ -310,13 +147,13 @@ export default function CategoriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Category Button */}
|
{/* Add Category Button */}
|
||||||
<button
|
<Link
|
||||||
onClick={openAddModal}
|
to="/categories/new"
|
||||||
className="btn-primary flex items-center gap-2 cursor-pointer shadow-lg shadow-cyan-glow/10"
|
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')}
|
<span>{t('categories.addNew', 'Create Category')}</span>
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -393,7 +230,8 @@ export default function CategoriesPage() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
className="hover:bg-white/[0.02] transition-colors group"
|
className="hover:bg-white/[0.02] transition-colors group cursor-pointer"
|
||||||
|
onClick={() => navigate(`/categories/${cat.id}`)}
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<td className="px-6 py-3.5">
|
<td className="px-6 py-3.5">
|
||||||
@@ -413,9 +251,13 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
{/* Thai Name */}
|
{/* Thai Name */}
|
||||||
<td className="px-6 py-3.5">
|
<td className="px-6 py-3.5">
|
||||||
<div className="font-bold text-white text-sm">
|
<Link
|
||||||
|
to={`/categories/${cat.id}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="font-bold text-white text-sm hover:text-cyan-glow transition-colors"
|
||||||
|
>
|
||||||
{thaiName}
|
{thaiName}
|
||||||
</div>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* English Name */}
|
{/* English Name */}
|
||||||
@@ -443,14 +285,14 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<td className="px-6 py-3.5 text-right">
|
<td className="px-6 py-3.5 text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<Link
|
||||||
onClick={() => openEditModal(cat)}
|
to={`/categories/${cat.id}`}
|
||||||
title={t('common.edit', 'Edit Category')}
|
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"
|
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 inline-flex"
|
||||||
>
|
>
|
||||||
<Edit3 className="w-4 h-4" />
|
<Edit3 className="w-4 h-4" />
|
||||||
</button>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(cat.id, thaiName)}
|
onClick={() => handleDelete(cat.id, thaiName)}
|
||||||
title={t('common.delete', 'Delete Category')}
|
title={t('common.delete', 'Delete Category')}
|
||||||
@@ -488,7 +330,11 @@ export default function CategoriesPage() {
|
|||||||
const englishName = getEnglishName(cat)
|
const englishName = getEnglishName(cat)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cat.id} className="admin-card admin-card-hover group relative overflow-hidden flex flex-col justify-between p-6">
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
className="admin-card admin-card-hover group relative overflow-hidden flex flex-col justify-between p-6 cursor-pointer"
|
||||||
|
onClick={() => navigate(`/categories/${cat.id}`)}
|
||||||
|
>
|
||||||
<div className="flex justify-between items-start gap-4 mb-4">
|
<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">
|
<div className="w-16 h-16 rounded-2xl overflow-hidden glass border border-white/10 p-1 bg-night-950 flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
@@ -498,15 +344,15 @@ export default function CategoriesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions on Card (Always visible with subtle hover highlights) */}
|
{/* Actions on Card */}
|
||||||
<div className="flex gap-1.5 relative z-10">
|
<div className="flex gap-1.5 relative z-10" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<Link
|
||||||
onClick={() => openEditModal(cat)}
|
to={`/categories/${cat.id}`}
|
||||||
title={t('common.edit', 'Edit')}
|
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"
|
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 inline-flex"
|
||||||
>
|
>
|
||||||
<Edit3 className="w-4 h-4" />
|
<Edit3 className="w-4 h-4" />
|
||||||
</button>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(cat.id, thaiName)}
|
onClick={() => handleDelete(cat.id, thaiName)}
|
||||||
title={t('common.delete', 'Delete')}
|
title={t('common.delete', 'Delete')}
|
||||||
@@ -519,7 +365,7 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
<div className="space-y-1 relative z-10">
|
<div className="space-y-1 relative z-10">
|
||||||
<div className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">ID #{cat.id}</div>
|
<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">
|
<h3 className="text-lg font-bold text-white tracking-tight line-clamp-1 group-hover:text-cyan-glow transition-colors">
|
||||||
{thaiName}
|
{thaiName}
|
||||||
</h3>
|
</h3>
|
||||||
{englishName !== '-' && (
|
{englishName !== '-' && (
|
||||||
@@ -544,231 +390,6 @@ export default function CategoriesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── SIMPLIFIED EDIT / CREATE MODAL ─── */}
|
|
||||||
{isModalOpen && editingCategory && (
|
|
||||||
<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">
|
|
||||||
<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">
|
|
||||||
{/* 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')}
|
|
||||||
</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
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Modal Body */}
|
|
||||||
<form onSubmit={handleSave} className="p-6 md:p-8 space-y-6">
|
|
||||||
|
|
||||||
{/* Category Image Section */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-[11px] uppercase font-bold tracking-widest text-slate-400">
|
|
||||||
{t('categories.image', 'Category Image')}
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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-[11px] font-bold uppercase tracking-wider transition-all border border-cyan-glow/20 cursor-pointer"
|
|
||||||
>
|
|
||||||
<ClipboardPaste className="w-3.5 h-3.5" />
|
|
||||||
Paste from Clipboard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6 p-4 rounded-2xl bg-white/[0.02] border border-white/5">
|
|
||||||
<div
|
|
||||||
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={() => {
|
|
||||||
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>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={(editingCategory.name as any)?.th || ''}
|
|
||||||
onChange={(e) => updateCategoryNameField('th', e.target.value)}
|
|
||||||
className="admin-input w-full"
|
|
||||||
placeholder="เช่น เทียนหอม, น้ำมันหอมระเหย, ก้านไม้หอม"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* Additional Languages Accordion (Optional) */}
|
|
||||||
<div className="border border-white/5 rounded-2xl bg-white/[0.01] overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowMoreLangs(!showMoreLangs)}
|
|
||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-white/[0.02] transition-colors cursor-pointer text-left"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{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
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
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')}
|
|
||||||
</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>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Media Library Modal */}
|
|
||||||
<MediaLibraryModal
|
|
||||||
isOpen={isMediaModalOpen}
|
|
||||||
onClose={() => setIsMediaModalOpen(false)}
|
|
||||||
onSelect={(url) => {
|
|
||||||
if (onSelectMedia) onSelectMedia(url)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,807 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { API_BASE, type Category } from '@homaroma/types'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Image as ImageIcon,
|
||||||
|
UploadCloud,
|
||||||
|
Loader2,
|
||||||
|
ClipboardPaste,
|
||||||
|
Globe,
|
||||||
|
Package,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
LayoutDashboard,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
FileJson,
|
||||||
|
Check,
|
||||||
|
Code2,
|
||||||
|
Sparkles
|
||||||
|
} from 'lucide-react'
|
||||||
|
import MediaLibraryModal from '../components/MediaLibraryModal'
|
||||||
|
|
||||||
|
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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SAMPLE_JSON_TEMPLATE = JSON.stringify({
|
||||||
|
th: "เทียนหอมอโรมา",
|
||||||
|
en: "Aromatherapy Scented Candles",
|
||||||
|
ja: "アロマキャンドル",
|
||||||
|
zh: "芳香香薰蜡烛",
|
||||||
|
vi: "Nến thơm thư giãn",
|
||||||
|
ko: "아로마 캔들",
|
||||||
|
lo: "ທຽນຫອມອາໂຣມາ",
|
||||||
|
it: "Candele aromatiche profumate"
|
||||||
|
}, null, 2)
|
||||||
|
|
||||||
|
export default function CategoryEditPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const isNew = !id || id === 'new'
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(!isNew)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [showMoreLangs, setShowMoreLangs] = useState(false)
|
||||||
|
const [successToast, setSuccessToast] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// JSON Quick Import State
|
||||||
|
const [isJsonBoxOpen, setIsJsonBoxOpen] = useState(false)
|
||||||
|
const [jsonInput, setJsonInput] = useState('')
|
||||||
|
const [jsonError, setJsonError] = useState<string | null>(null)
|
||||||
|
const [jsonSuccess, setJsonSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Category State
|
||||||
|
const [name, setName] = useState<Record<string, string>>({
|
||||||
|
th: '',
|
||||||
|
en: '',
|
||||||
|
ja: '',
|
||||||
|
zh: '',
|
||||||
|
vi: '',
|
||||||
|
ko: '',
|
||||||
|
lo: '',
|
||||||
|
it: '',
|
||||||
|
})
|
||||||
|
const [imageUrl, setImageUrl] = useState<string>('')
|
||||||
|
const [categoryMeta, setCategoryMeta] = useState<Category | null>(null)
|
||||||
|
|
||||||
|
// Media Library Modal
|
||||||
|
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
fetch(`${API_BASE}/categories/${id}?lang=all`)
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error('Category not found')
|
||||||
|
const data = await res.json()
|
||||||
|
setCategoryMeta(data)
|
||||||
|
setImageUrl(data.imageUrl || '')
|
||||||
|
|
||||||
|
if (data.name && typeof data.name === 'object') {
|
||||||
|
setName(prev => ({ ...prev, ...data.name }))
|
||||||
|
// If any additional lang exists, expand the accordion
|
||||||
|
const hasExtra = ADDITIONAL_LANGS.some(l => Boolean(data.name[l.code]))
|
||||||
|
if (hasExtra) setShowMoreLangs(true)
|
||||||
|
} else if (typeof data.name === 'string') {
|
||||||
|
setName(prev => ({ ...prev, th: data.name }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to load category:', err)
|
||||||
|
alert('Could not find category. Returning to categories list.')
|
||||||
|
navigate('/categories')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [id, isNew, navigate])
|
||||||
|
|
||||||
|
const updateLangName = (langCode: string, value: string) => {
|
||||||
|
setName(prev => ({
|
||||||
|
...prev,
|
||||||
|
[langCode]: value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyNameFrom = (fromLang: string, targetLang: string) => {
|
||||||
|
const source = name[fromLang] || ''
|
||||||
|
if (!source) return
|
||||||
|
updateLangName(targetLang, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON Import Handlers ──────────────────────────────────────────────────
|
||||||
|
const handleApplyJson = () => {
|
||||||
|
setJsonError(null)
|
||||||
|
setJsonSuccess(null)
|
||||||
|
|
||||||
|
if (!jsonInput.trim()) {
|
||||||
|
setJsonError('Please paste valid JSON text')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let parsed = JSON.parse(jsonInput)
|
||||||
|
|
||||||
|
// Support nested shapes e.g. { name: { th, en } } or { translations: { th: { name: "..." } } }
|
||||||
|
if (parsed.name && typeof parsed.name === 'object') {
|
||||||
|
parsed = parsed.name
|
||||||
|
} else if (parsed.translations && typeof parsed.translations === 'object') {
|
||||||
|
const extracted: Record<string, string> = {}
|
||||||
|
for (const [key, val] of Object.entries(parsed.translations)) {
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
extracted[key] = val
|
||||||
|
} else if (val && typeof val === 'object' && typeof (val as any).name === 'string') {
|
||||||
|
extracted[key] = (val as any).name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed = extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
|
||||||
|
throw new Error('JSON must be an object with language keys (e.g. { "th": "...", "en": "..." })')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedName = { ...name }
|
||||||
|
let appliedCount = 0
|
||||||
|
|
||||||
|
// Match all possible language keys
|
||||||
|
const allKnownCodes = ['th', 'en', ...ADDITIONAL_LANGS.map(l => l.code)]
|
||||||
|
for (const code of allKnownCodes) {
|
||||||
|
if (typeof parsed[code] === 'string' && parsed[code].trim()) {
|
||||||
|
updatedName[code] = parsed[code].trim()
|
||||||
|
appliedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appliedCount === 0) {
|
||||||
|
throw new Error('No recognized language keys (th, en, ja, zh, vi, ko, lo, it) found in the JSON.')
|
||||||
|
}
|
||||||
|
|
||||||
|
setName(updatedName)
|
||||||
|
|
||||||
|
// Auto-expand accordion if additional languages were filled
|
||||||
|
const hasExtra = ADDITIONAL_LANGS.some(l => Boolean(updatedName[l.code]))
|
||||||
|
if (hasExtra) setShowMoreLangs(true)
|
||||||
|
|
||||||
|
setJsonSuccess(`Successfully applied ${appliedCount} language names!`)
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsJsonBoxOpen(false)
|
||||||
|
setJsonSuccess(null)
|
||||||
|
}, 1200)
|
||||||
|
} catch (err: any) {
|
||||||
|
setJsonError(err.message || 'Invalid JSON format')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePasteJsonFromClipboard = async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText()
|
||||||
|
if (text) {
|
||||||
|
setJsonInput(text)
|
||||||
|
setJsonError(null)
|
||||||
|
} else {
|
||||||
|
alert('Clipboard is empty or does not contain text.')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Clipboard error:', err)
|
||||||
|
alert('Could not access clipboard. Please paste manually.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFillSampleJson = () => {
|
||||||
|
setJsonInput(SAMPLE_JSON_TEMPLATE)
|
||||||
|
setJsonError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportCurrentJson = () => {
|
||||||
|
const cleanObject: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries(name)) {
|
||||||
|
if (v && v.trim()) cleanObject[k] = v.trim()
|
||||||
|
}
|
||||||
|
setJsonInput(JSON.stringify(cleanObject, null, 2))
|
||||||
|
setJsonError(null)
|
||||||
|
setJsonSuccess('Exported current names to JSON!')
|
||||||
|
setTimeout(() => setJsonSuccess(null), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image Upload Handlers ─────────────────────────────────────────────────
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
setIsUploading(true)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.url) {
|
||||||
|
setImageUrl(data.url)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Upload error:', err)
|
||||||
|
alert('Failed to upload image')
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePasteFromClipboard = async () => {
|
||||||
|
try {
|
||||||
|
const items = await navigator.clipboard.read()
|
||||||
|
for (const item of items) {
|
||||||
|
const imageTypes = item.types.filter(t => t.startsWith('image/'))
|
||||||
|
for (const type of imageTypes) {
|
||||||
|
const blob = await item.getType(type)
|
||||||
|
const file = new File([blob], 'pasted-category-image.png', { type })
|
||||||
|
handleUpload(file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert('No image found in clipboard')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Clipboard error:', err)
|
||||||
|
alert('Please allow clipboard access or copy an image first.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.th?.trim()) {
|
||||||
|
alert('Please enter Thai category name')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const masterName = name.th.trim() || name.en?.trim() || 'Untitled'
|
||||||
|
const url = isNew ? `${API_BASE}/categories` : `${API_BASE}/categories/${id}`
|
||||||
|
const method = isNew ? 'POST' : 'PUT'
|
||||||
|
|
||||||
|
// Step 1: Save base category
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: masterName,
|
||||||
|
imageUrl: imageUrl || null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json()
|
||||||
|
throw new Error(errorData.message || 'Failed to save category')
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedCat = await res.json()
|
||||||
|
const targetId = isNew ? savedCat.id : id
|
||||||
|
|
||||||
|
// Step 2: Save translations
|
||||||
|
if (targetId) {
|
||||||
|
await fetch(`${API_BASE}/categories/${targetId}/translations`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccessToast(isNew ? 'Category created successfully!' : 'Category updated successfully!')
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/categories')
|
||||||
|
}, 800)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Save error:', err)
|
||||||
|
alert(err.message || 'An error occurred while saving.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (isNew) return
|
||||||
|
const categoryTitle = name.th || name.en || `ID #${id}`
|
||||||
|
if (!confirm(t('categories.confirmDelete', `Are you sure you want to delete category "${categoryTitle}"?`))) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/categories/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
navigate('/categories')
|
||||||
|
} else {
|
||||||
|
const errorData = await res.json()
|
||||||
|
alert(errorData.message || 'Failed to delete category')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete error:', err)
|
||||||
|
alert('Failed to delete category')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4 animate-fade-in">
|
||||||
|
<Loader2 className="w-10 h-10 text-cyan-glow animate-spin" />
|
||||||
|
<p className="text-slate-400 text-sm font-medium">Loading category details...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = name.th || name.en || (isNew ? 'New Category' : `Category #${id}`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-fade-in pb-20 max-w-6xl mx-auto">
|
||||||
|
{/* Top Header & Sticky Action Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-white/5 pb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/categories"
|
||||||
|
className="p-2.5 rounded-xl bg-night-900 border border-white/10 text-slate-400 hover:text-white hover:border-white/20 transition-all cursor-pointer shadow-sm"
|
||||||
|
title="Back to Categories"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-white tracking-tight">
|
||||||
|
{isNew ? 'Create New Category' : `Edit Category`}
|
||||||
|
</h1>
|
||||||
|
{!isNew && (
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-mono font-semibold bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20">
|
||||||
|
ID #{id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-xs">
|
||||||
|
{isNew ? 'Define category names in multiple languages and set a cover image' : displayName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 self-end sm:self-auto">
|
||||||
|
{!isNew && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 text-sm font-semibold transition-all cursor-pointer flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Delete</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"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="category-form"
|
||||||
|
disabled={saving || isUploading}
|
||||||
|
className="btn-primary px-6 py-2.5 flex items-center gap-2 text-sm font-bold shadow-lg shadow-cyan-glow/10 cursor-pointer"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Saving...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
<span>{isNew ? 'Create Category' : 'Save Changes'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Notification */}
|
||||||
|
{successToast && (
|
||||||
|
<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 shadow-lg">
|
||||||
|
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span className="text-sm font-semibold">{successToast}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Edit Layout */}
|
||||||
|
<form id="category-form" onSubmit={handleSave} className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
|
||||||
|
{/* Left Column (2 Cols): Names & Translations */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
|
||||||
|
{/* Card 1: Primary Names + JSON Quick Paste Button */}
|
||||||
|
<div className="admin-card p-6 md:p-8 space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-white/5 pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-xl bg-cyan-glow/10 text-cyan-glow">
|
||||||
|
<LayoutDashboard className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Category Names</h2>
|
||||||
|
<p className="text-xs text-slate-400">Primary names for store navigation and product classification</p>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable JSON Input Box */}
|
||||||
|
{isJsonBoxOpen && (
|
||||||
|
<div className="p-5 rounded-2xl bg-night-950 border border-cyan-glow/30 shadow-2xl space-y-4 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-cyan-glow">
|
||||||
|
<Code2 className="w-4 h-4" />
|
||||||
|
<span>Paste Multilingual Names JSON</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePasteJsonFromClipboard}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-[11px] text-slate-300 hover:text-white flex items-center gap-1 cursor-pointer transition-colors"
|
||||||
|
title="Paste text from clipboard"
|
||||||
|
>
|
||||||
|
<ClipboardPaste className="w-3 h-3 text-cyan-glow" />
|
||||||
|
<span>Paste Clipboard</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFillSampleJson}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-[11px] text-slate-300 hover:text-white flex items-center gap-1 cursor-pointer transition-colors"
|
||||||
|
title="Insert sample JSON template"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3 h-3 text-amber-400" />
|
||||||
|
<span>Sample JSON</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportCurrentJson}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-[11px] text-slate-300 hover:text-white flex items-center gap-1 cursor-pointer transition-colors"
|
||||||
|
title="Export current values as JSON"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3 text-purple-400" />
|
||||||
|
<span>Export Current</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
value={jsonInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setJsonInput(e.target.value)
|
||||||
|
setJsonError(null)
|
||||||
|
}}
|
||||||
|
className="w-full font-mono text-xs p-3.5 bg-night-900 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-cyan-200 placeholder:text-slate-600 resize-y shadow-inner"
|
||||||
|
placeholder='{\n "th": "เทียนหอมอโรมา",\n "en": "Aromatherapy Candles",\n "ja": "アロマキャンドル",\n "zh": "香薰蜡烛"\n}'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Error / Success Feedback */}
|
||||||
|
{jsonError && (
|
||||||
|
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{jsonError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{jsonSuccess && (
|
||||||
|
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{jsonSuccess}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Supports keys: <code className="text-slate-400">th, en, ja, zh, vi, ko, lo, it</code> or nested <code className="text-slate-400">{ name: ... }</code>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsJsonBoxOpen(false)}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 text-xs font-semibold cursor-pointer"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyJson}
|
||||||
|
className="px-4 py-1.5 rounded-xl bg-cyan-glow hover:bg-cyan-400 text-night-950 text-xs font-bold flex items-center gap-1.5 cursor-pointer shadow-md shadow-cyan-glow/20 transition-all"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
<span>Apply to Form</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Thai Name */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-wider text-slate-300 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>🇹🇭 ชื่อหมวดหมู่ภาษาไทย (Thai Name)</span>
|
||||||
|
<span className="text-rose-400 text-xs">*จำเป็น</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">{name.th?.length || 0} chars</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name.th}
|
||||||
|
onChange={(e) => updateLangName('th', e.target.value)}
|
||||||
|
className="admin-input w-full text-base font-medium py-3"
|
||||||
|
placeholder="เช่น เทียนหอม, น้ำมันหอมระเหย, ก้านไม้หอมปรับอากาศ"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-slate-500">ชื่อหมวดหมู่ภาษาไทยจะถูกใช้เป็นชื่อหลัก (Master Name) ของระบบ</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* English Name */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-wider 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 font-medium"
|
||||||
|
title="Copy Thai name"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
Copy from TH
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name.en}
|
||||||
|
onChange={(e) => updateLangName('en', e.target.value)}
|
||||||
|
className="admin-input w-full text-base font-medium py-3"
|
||||||
|
placeholder="e.g. Scented Candles, Pure Essential Oils, Reed Diffusers"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-slate-500">ใช้สำหรับหน้าเว็บภาษาอังกฤษ และสร้าง URL Slug อัตโนมัติ</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 2: Multilingual Translations Accordion */}
|
||||||
|
<div className="admin-card p-6 md:p-8 space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/5 pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||||
|
<Globe className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Multilingual Translations</h2>
|
||||||
|
<p className="text-xs text-slate-400">Additional languages for international visitors</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMoreLangs(!showMoreLangs)}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-semibold flex items-center gap-2 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<span>{showMoreLangs ? 'Collapse' : 'Expand All'}</span>
|
||||||
|
{showMoreLangs ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMoreLangs ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 animate-fade-in">
|
||||||
|
{ADDITIONAL_LANGS.map(({ code, label, placeholder }) => (
|
||||||
|
<div key={code} className="space-y-1.5 p-4 rounded-2xl bg-white/[0.01] border border-white/5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">{label}</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyNameFrom('en', code)}
|
||||||
|
className="text-[10px] text-slate-500 hover:text-cyan-glow cursor-pointer font-medium"
|
||||||
|
title="Copy from English"
|
||||||
|
>
|
||||||
|
Copy EN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name[code] || ''}
|
||||||
|
onChange={(e) => updateLangName(code, e.target.value)}
|
||||||
|
className="admin-input w-full text-sm py-2.5"
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => setShowMoreLangs(true)}
|
||||||
|
className="p-4 rounded-2xl bg-white/[0.01] border border-dashed border-white/10 text-center space-y-1 cursor-pointer hover:border-cyan-glow/30 transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-slate-400 font-medium">Click to configure Japanese, Chinese, Vietnamese, Korean, Lao, and Italian translations</p>
|
||||||
|
<p className="text-[11px] text-slate-600">6 additional languages available</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 3: Associated Products & Quick Link */}
|
||||||
|
{!isNew && categoryMeta && (
|
||||||
|
<div className="admin-card p-6 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-white">Associated Products</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
This category currently contains <strong className="text-cyan-glow">{categoryMeta._count?.products || 0}</strong> active products.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/products?category=${id}`}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-xs font-semibold flex items-center gap-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<span>View Products</span>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column (1 Col): Image & Metadata */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Category Image Card */}
|
||||||
|
<div className="admin-card p-6 space-y-5">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/5 pb-4">
|
||||||
|
<h2 className="text-base font-bold text-white">Category Image</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePasteFromClipboard}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1 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"
|
||||||
|
title="Paste image directly from clipboard"
|
||||||
|
>
|
||||||
|
<ClipboardPaste className="w-3 h-3" />
|
||||||
|
Paste Image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail Preview Area */}
|
||||||
|
<div
|
||||||
|
className="relative w-full aspect-square 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 shadow-inner"
|
||||||
|
onClick={() => setIsMediaModalOpen(true)}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
alt={displayName}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2 backdrop-blur-xs text-white">
|
||||||
|
<UploadCloud className="w-8 h-8 text-cyan-glow" />
|
||||||
|
<span className="text-xs font-bold">Change Image</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center space-y-2 p-6">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-white/5 flex items-center justify-center mx-auto text-slate-500 group-hover:text-cyan-glow transition-colors">
|
||||||
|
<ImageIcon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 font-bold uppercase tracking-wider">Select Image</p>
|
||||||
|
<p className="text-[10px] text-slate-600">Click to browse Media Library</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUploading && (
|
||||||
|
<div className="absolute inset-0 bg-night-950/80 flex flex-col items-center justify-center gap-2">
|
||||||
|
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
|
||||||
|
<span className="text-xs text-slate-300 font-medium">Uploading image...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Actions & Helpers */}
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsMediaModalOpen(true)}
|
||||||
|
className="text-cyan-glow hover:underline cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
Browse Media Library
|
||||||
|
</button>
|
||||||
|
{imageUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setImageUrl('')}
|
||||||
|
className="text-rose-400 hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
Remove Image
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!imageUrl && (
|
||||||
|
<div className="p-3 rounded-xl bg-amber-500/5 border border-amber-500/10 text-amber-400 text-xs flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>If no image is uploaded, a default placeholder graphic will be used.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Info & Tips Card */}
|
||||||
|
<div className="admin-card p-6 space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-white uppercase tracking-wider text-slate-400">Information</h3>
|
||||||
|
<div className="space-y-2.5 text-xs text-slate-400">
|
||||||
|
<div className="flex justify-between py-1 border-b border-white/5">
|
||||||
|
<span>Status</span>
|
||||||
|
<span className="text-emerald-400 font-semibold">Active</span>
|
||||||
|
</div>
|
||||||
|
{!isNew && (
|
||||||
|
<div className="flex justify-between py-1 border-b border-white/5">
|
||||||
|
<span>Category ID</span>
|
||||||
|
<span className="font-mono text-white">#{id}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between py-1">
|
||||||
|
<span>Total Translations</span>
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
{Object.values(name).filter(v => Boolean(v && v.trim())).length} / 8
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Media Library Modal */}
|
||||||
|
<MediaLibraryModal
|
||||||
|
isOpen={isMediaModalOpen}
|
||||||
|
onClose={() => setIsMediaModalOpen(false)}
|
||||||
|
onSelect={(url) => {
|
||||||
|
setImageUrl(url)
|
||||||
|
setIsMediaModalOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,28 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
|||||||
name: resolveCatName(cat, lang),
|
name: resolveCatName(cat, lang),
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
|
.get("/:id", async ({ params, query, status }) => {
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const lang = (query as any).lang || 'all';
|
||||||
|
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
||||||
|
|
||||||
|
const category = await prisma.category.findUnique({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
_count: {
|
||||||
|
select: { products: { where: { isDeleted: false } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) return status(404, { message: "Category not found" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
name: resolveCatName(category, lang),
|
||||||
|
};
|
||||||
|
})
|
||||||
.get("/:id/products", async ({ params, query, status }) => {
|
.get("/:id/products", async ({ params, query, status }) => {
|
||||||
const id = parseInt(params.id);
|
const id = parseInt(params.id);
|
||||||
const lang = (query as any).lang || 'th';
|
const lang = (query as any).lang || 'th';
|
||||||
@@ -157,7 +179,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
|||||||
|
|
||||||
const updated = await prisma.category.findUnique({
|
const updated = await prisma.category.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { translations: true, _count: { select: { products: true } } },
|
include: { translations: true, _count: { select: { products: { where: { isDeleted: false } } } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, translations: results, category: updated };
|
return { success: true, translations: results, category: updated };
|
||||||
@@ -249,7 +271,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
|||||||
try {
|
try {
|
||||||
const category = await prisma.category.findUnique({
|
const category = await prisma.category.findUnique({
|
||||||
where: { id, deletedAt: null },
|
where: { id, deletedAt: null },
|
||||||
include: { _count: { select: { products: true } } }
|
include: { _count: { select: { products: { where: { isDeleted: false } } } } }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!category) return status(404, { message: "Category not found" });
|
if (!category) return status(404, { message: "Category not found" });
|
||||||
|
|||||||
@@ -1319,7 +1319,7 @@ export async function hydrateSectionPayload(section: any, lang: string) {
|
|||||||
include: {
|
include: {
|
||||||
translations: true,
|
translations: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { products: true },
|
select: { products: { where: { isDeleted: false } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1330,7 +1330,7 @@ export async function hydrateSectionPayload(section: any, lang: string) {
|
|||||||
include: {
|
include: {
|
||||||
translations: true,
|
translations: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { products: true },
|
select: { products: { where: { isDeleted: false } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user