This commit is contained in:
Your Name committed 2026-10-01 06:59:38 +07:00
1 parent 0ceee3dbff
commit 2d39edbd57
18 files changed
+804 -153

No files matched your search

@@ -75,6 +75,7 @@ export default function CategoryBulkJsonModal({
id: cat.id,
name: catBaseName || transMap['th'] || 'Unnamed Category',
imageUrl: cat.imageUrl || null,
media: cat.media || [],
layoutType: cat.layoutType || 'split_showcase',
translations: transMap,
};
@@ -89,8 +90,9 @@ export default function CategoryBulkJsonModal({
const aiTranslatePrompt = useMemo(() => {
const sampleItems = exportData.slice(0, 5);
return `Please translate and optimize our E-commerce Category Names into all 8 supported languages (th, en, ja, zh, vi, ko, lo, it).
Ensure the tone is elegant, accurate for luxury aromatherapy & lifestyle products.
Preserve the tenant's catalog terminology and brand tone.
Allowed layoutType values: "split_showcase" (default), "editorial_hero", "dense_catalog".
Allowed media roles: "thumbnail", "hero_desktop", "hero_mobile", "card_portrait", "social_share".
Return ONLY a valid JSON Array conforming to this structure:
\`\`\`json
@@ -101,6 +103,11 @@ ${JSON.stringify(
{
name: 'น้ำมันหอมระเหย',
imageUrl: '/images/categories/essential-oils.jpg',
media: [
{ role: 'thumbnail', url: '/images/categories/essential-oils-square.jpg' },
{ role: 'hero_desktop', url: '/images/categories/essential-oils-wide.jpg' },
{ role: 'hero_mobile', url: '/images/categories/essential-oils-mobile.jpg' },
],
layoutType: 'split_showcase',
translations: {
th: 'น้ำมันหอมระเหย',
@@ -116,6 +123,11 @@ ${JSON.stringify(
{
name: 'ก้านไม้หอมปรับอากาศ',
imageUrl: '/images/categories/reed-diffusers.jpg',
media: [
{ role: 'thumbnail', url: '/images/categories/reed-diffusers-square.jpg' },
{ role: 'hero_desktop', url: '/images/categories/reed-diffusers-wide.jpg' },
{ role: 'hero_mobile', url: '/images/categories/reed-diffusers-mobile.jpg' },
],
layoutType: 'editorial_hero',
translations: {
th: 'ก้านไม้หอมปรับอากาศ',
@@ -169,6 +181,7 @@ ${JSON.stringify(
id: typeof item.id === 'number' ? item.id : undefined,
name: baseName,
imageUrl: item.imageUrl || null,
media: Array.isArray(item.media) ? item.media : [],
layoutType: typeof item.layoutType === 'string' ? item.layoutType : 'split_showcase',
translations: trans,
};
@@ -0,0 +1,168 @@
import type { Dispatch, SetStateAction } from 'react'
import { AlertCircle, ClipboardPaste, Image as ImageIcon, Loader2, Trash2 } from 'lucide-react'
import type { CategoryMedia, CategoryMediaRole } from '@homaroma/types'
import FocalPointPicker from './page-builder/FocalPointPicker'
type CategoryMediaEditorProps = {
media: CategoryMedia[]
layoutType: string
displayName: string
uploadingRole: CategoryMediaRole | null
onChange: Dispatch<SetStateAction<CategoryMedia[]>>
onBrowse: (role: CategoryMediaRole) => void
onPaste: (role: CategoryMediaRole) => void
}
const MEDIA_SLOTS: Array<{
role: CategoryMediaRole
label: string
ratio: string
dimensions: string
previewClass: string
expectedRatio: number
}> = [
{ role: 'thumbnail', label: 'Thumbnail', ratio: '1:1', dimensions: '1200 × 1200 px', previewClass: 'aspect-square', expectedRatio: 1 },
{ role: 'hero_desktop', label: 'Desktop Hero', ratio: '21:9', dimensions: '2400 × 1028 px', previewClass: 'aspect-[21/9]', expectedRatio: 21 / 9 },
{ role: 'hero_mobile', label: 'Mobile Hero', ratio: '4:5', dimensions: '1200 × 1500 px', previewClass: 'aspect-[4/5]', expectedRatio: 4 / 5 },
{ role: 'card_portrait', label: 'Portrait Card', ratio: '4:5', dimensions: '1200 × 1500 px', previewClass: 'aspect-[4/5]', expectedRatio: 4 / 5 },
{ role: 'social_share', label: 'Social Share', ratio: '1.91:1', dimensions: '1200 × 630 px', previewClass: 'aspect-[1.91/1]', expectedRatio: 1200 / 630 },
]
function isRecommendedForLayout(role: CategoryMediaRole, layoutType: string) {
return layoutType === 'editorial_hero' && (role === 'hero_desktop' || role === 'hero_mobile')
}
export default function CategoryMediaEditor({
media,
layoutType,
displayName,
uploadingRole,
onChange,
onBrowse,
onPaste,
}: CategoryMediaEditorProps) {
const findMedia = (role: CategoryMediaRole) => media.find((item) => item.role === role)
const updateMedia = (role: CategoryMediaRole, update: Partial<CategoryMedia>) => {
onChange((current) => {
const existing = current.find((item) => item.role === role)
if (existing) return current.map((item) => item.role === role ? { ...item, ...update } : item)
if (!update.url) return current
return [...current, { role, url: update.url, focalX: 0.5, focalY: 0.5, sortOrder: current.length, ...update }]
})
}
const removeMedia = (role: CategoryMediaRole) => {
onChange((current) => current.filter((item) => item.role !== role))
}
return (
<div className="admin-card space-y-5 p-6">
<div className="border-b border-white/5 pb-4">
<h2 className="text-base font-bold text-white">Category Media</h2>
<p className="mt-1 text-xs text-slate-400">Art-directed assets for cards, responsive heroes, and social sharing.</p>
</div>
<div className="space-y-5">
{MEDIA_SLOTS.map((slot) => {
const item = findMedia(slot.role)
const required = slot.role === 'thumbnail'
const recommended = isRecommendedForLayout(slot.role, layoutType)
const isUploading = uploadingRole === slot.role
const actualRatio = item?.width && item?.height ? item.width / item.height : null
const ratioMismatch = actualRatio !== null && Math.abs(actualRatio - slot.expectedRatio) / slot.expectedRatio > 0.08
return (
<section key={slot.role} className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-white">{slot.label}</h3>
{(required || recommended) && (
<span className="rounded-md border border-cyan-glow/20 bg-cyan-glow/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-cyan-glow">
{required ? 'Required' : 'Recommended'}
</span>
)}
</div>
<p className="mt-1 text-[11px] text-slate-500">{slot.ratio} · {slot.dimensions}</p>
</div>
<button
type="button"
onClick={() => onPaste(slot.role)}
className="inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-[10px] font-semibold text-slate-300 transition hover:bg-white/10 hover:text-white"
>
<ClipboardPaste className="h-3 w-3" />
Paste
</button>
</div>
<button
type="button"
onClick={() => onBrowse(slot.role)}
className={`relative flex w-full items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-white/10 bg-night-950/60 transition hover:border-cyan-glow/40 ${slot.previewClass}`}
>
{item?.url ? (
<img
src={item.url}
alt={`${displayName || 'Category'} ${slot.label}`}
className="absolute inset-0 h-full w-full object-cover"
style={{ objectPosition: `${(item.focalX ?? 0.5) * 100}% ${(item.focalY ?? 0.5) * 100}%` }}
onLoad={(event) => {
const { naturalWidth, naturalHeight } = event.currentTarget
if (naturalWidth !== item.width || naturalHeight !== item.height) {
updateMedia(slot.role, { width: naturalWidth, height: naturalHeight })
}
}}
/>
) : (
<div className="space-y-2 p-5 text-center">
<ImageIcon className="mx-auto h-6 w-6 text-slate-500" />
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400">Select {slot.label}</p>
</div>
)}
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-night-950/80">
<Loader2 className="h-6 w-6 animate-spin text-cyan-glow" />
</div>
)}
</button>
{item?.url ? (
<div className="space-y-3">
<p className={`text-[11px] ${ratioMismatch ? 'text-amber-400' : 'text-slate-500'}`}>
Actual: {item.width && item.height ? `${item.width} × ${item.height} px` : 'detecting dimensions…'}
{ratioMismatch ? ` · recommended ${slot.ratio}` : ''}
</p>
<FocalPointPicker
imageUrl={item.url}
focalPoint={{
x: `${(item.focalX ?? 0.5) * 100}%`,
y: `${(item.focalY ?? 0.5) * 100}%`,
}}
onChange={(point) => updateMedia(slot.role, {
focalX: Number.parseFloat(point.x) / 100,
focalY: Number.parseFloat(point.y) / 100,
})}
/>
<button
type="button"
onClick={() => removeMedia(slot.role)}
className="inline-flex items-center gap-1 text-xs font-medium text-rose-400 hover:underline"
>
<Trash2 className="h-3.5 w-3.5" />
Remove asset
</button>
</div>
) : (required || recommended) ? (
<div className="flex items-start gap-2 rounded-xl border border-amber-500/10 bg-amber-500/5 p-3 text-xs text-amber-400">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{required ? 'This asset is required before saving.' : 'This layout will use the documented fallback until this asset is supplied.'}</span>
</div>
) : null}
</section>
)
})}
</div>
</div>
)
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { API_BASE, getCategoryImage, type Category } from '@homaroma/types'
import { API_BASE, getCategoryImage, resolveCategoryMediaUrl, type Category } from '@homaroma/types'
import {
Edit3,
Package,
@@ -237,7 +237,7 @@ export default function CategoriesPage() {
) : (
filteredCategories.map((cat) => {
const fallbackName = typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || '')
const imageUrl = cat.imageUrl || getCategoryImage(fallbackName)
const imageUrl = resolveCategoryMediaUrl(cat, 'thumbnail', getCategoryImage(fallbackName))
const langsCount = getAvailableLangsCount(cat)
const thaiName = getThaiName(cat)
@@ -339,7 +339,7 @@ export default function CategoriesPage() {
) : (
filteredCategories.map((cat) => {
const fallbackName = typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || '')
const imageUrl = cat.imageUrl || getCategoryImage(fallbackName)
const imageUrl = resolveCategoryMediaUrl(cat, 'thumbnail', getCategoryImage(fallbackName))
const thaiName = getThaiName(cat)
const englishName = getEnglishName(cat)
+52 -91
View File
@@ -1,13 +1,11 @@
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 { API_BASE, type Category, type CategoryMedia, type CategoryMediaRole } from '@homaroma/types'
import {
ArrowLeft,
Save,
Trash2,
Image as ImageIcon,
UploadCloud,
Loader2,
ClipboardPaste,
Globe,
@@ -28,6 +26,7 @@ import {
Grid3X3
} from 'lucide-react'
import MediaLibraryModal from '../components/MediaLibraryModal'
import CategoryMediaEditor from '../components/CategoryMediaEditor'
const ADDITIONAL_LANGS = [
{ code: 'ja', label: 'Japanese (日本語)', placeholder: '例: アロマキャンドル' },
@@ -78,12 +77,14 @@ export default function CategoryEditPage() {
lo: '',
it: '',
})
const [imageUrl, setImageUrl] = useState<string>('')
const [media, setMedia] = useState<CategoryMedia[]>([])
const [layoutType, setLayoutType] = useState<string>('split_showcase')
const [categoryMeta, setCategoryMeta] = useState<Category | null>(null)
// Media Library Modal
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [activeMediaRole, setActiveMediaRole] = useState<CategoryMediaRole>('thumbnail')
const [uploadingRole, setUploadingRole] = useState<CategoryMediaRole | null>(null)
useEffect(() => {
if (isNew) return
@@ -94,7 +95,9 @@ export default function CategoryEditPage() {
if (!res.ok) throw new Error('Category not found')
const data = await res.json()
setCategoryMeta(data)
setImageUrl(data.imageUrl || '')
setMedia(Array.isArray(data.media) && data.media.length > 0
? data.media
: (data.imageUrl ? [{ role: 'thumbnail', url: data.imageUrl, focalX: 0.5, focalY: 0.5 }] : []))
setLayoutType(data.layoutType || 'split_showcase')
if (data.name && typeof data.name === 'object') {
@@ -223,8 +226,17 @@ export default function CategoryEditPage() {
}
// ── Image Upload Handlers ─────────────────────────────────────────────────
const handleUpload = async (file: File) => {
const setMediaUrl = (role: CategoryMediaRole, url: string) => {
setMedia((current) => {
const existing = current.find((item) => item.role === role)
if (existing) return current.map((item) => item.role === role ? { ...item, url } : item)
return [...current, { role, url, focalX: 0.5, focalY: 0.5, sortOrder: current.length }]
})
}
const handleUpload = async (file: File, role: CategoryMediaRole) => {
setIsUploading(true)
setUploadingRole(role)
const formData = new FormData()
formData.append('file', file)
@@ -235,17 +247,18 @@ export default function CategoryEditPage() {
})
const data = await res.json()
if (data.url) {
setImageUrl(data.url)
setMediaUrl(role, data.url)
}
} catch (err) {
console.error('Upload error:', err)
alert('Failed to upload image')
} finally {
setIsUploading(false)
setUploadingRole(null)
}
}
const handlePasteFromClipboard = async () => {
const handlePasteFromClipboard = async (role: CategoryMediaRole) => {
try {
const items = await navigator.clipboard.read()
for (const item of items) {
@@ -253,7 +266,7 @@ export default function CategoryEditPage() {
for (const type of imageTypes) {
const blob = await item.getType(type)
const file = new File([blob], 'pasted-category-image.png', { type })
handleUpload(file)
void handleUpload(file, role)
return
}
}
@@ -266,6 +279,12 @@ export default function CategoryEditPage() {
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
const thumbnail = media.find((item) => item.role === 'thumbnail' && item.url.trim())
if (!thumbnail) {
alert('A 1:1 category thumbnail is required before saving.')
return
}
if (!name.th?.trim()) {
alert('Please enter Thai category name')
return
@@ -283,8 +302,17 @@ export default function CategoryEditPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: masterName,
imageUrl: imageUrl || null,
layoutType: layoutType || 'split_showcase'
imageUrl: thumbnail.url,
layoutType: layoutType || 'split_showcase',
media: media.map(({ role, url, width, height, focalX, focalY, sortOrder }) => ({
role,
url,
width: width ?? null,
height: height ?? null,
focalX: focalX ?? 0.5,
focalY: focalY ?? 0.5,
sortOrder: sortOrder ?? 0,
})),
})
})
@@ -838,85 +866,18 @@ export default function CategoryEditPage() {
{/* 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>
<CategoryMediaEditor
media={media}
layoutType={layoutType}
displayName={displayName}
uploadingRole={uploadingRole}
onChange={setMedia}
onBrowse={(role) => {
setActiveMediaRole(role)
setIsMediaModalOpen(true)
}}
onPaste={(role) => void handlePasteFromClipboard(role)}
/>
{/* Quick Info & Tips Card */}
<div className="admin-card p-6 space-y-4">
@@ -950,7 +911,7 @@ export default function CategoryEditPage() {
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
onSelect={(url) => {
setImageUrl(url)
setMediaUrl(activeMediaRole, url)
setIsMediaModalOpen(false)
}}
/>