This commit is contained in:
Your Name committed 2026-09-25 05:16:28 +07:00
1 parent 138078a398
commit 3e742ac468
5 files changed
+871 -417

No files matched your search

+4
View File
@@ -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 />} />
+34 -413
View File
@@ -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,49 +8,21 @@ 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)
try { try {
@@ -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>
) )
} }
+807
View File
@@ -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">&#123; name: ... &#125;</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>
)
}
+24 -2
View File
@@ -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" });
+2 -2
View File
@@ -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 } } },
}, },
}, },
}); });