This commit is contained in:
Your Name committed 2026-10-02 07:10:25 +07:00
1 parent 75770ca122
commit f258e3e176
16 files changed
+1207 -348

No files matched your search

@@ -7,6 +7,7 @@ interface MediaLibraryModalProps {
isOpen: boolean;
onClose: () => void;
onSelect: (url: string) => void;
mediaType?: 'all' | 'image' | 'video';
}
interface MediaItem {
@@ -16,7 +17,7 @@ interface MediaItem {
mimeType?: string;
}
export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLibraryModalProps) {
export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType = 'all' }: MediaLibraryModalProps) {
const { t } = useTranslation();
const [media, setMedia] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -46,6 +47,16 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (mediaType === 'image' && !file.type.startsWith('image/')) {
alert('Please select an image file.');
e.target.value = '';
return;
}
if (mediaType === 'video' && !file.type.startsWith('video/')) {
alert('Please select a video file.');
e.target.value = '';
return;
}
const formData = new FormData();
formData.append('file', file);
@@ -82,6 +93,16 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
return fn.endsWith('.mp4') || fn.endsWith('.webm') || fn.endsWith('.mov') || fn.endsWith('.ogg');
};
const visibleMedia = media.filter((item) => {
if (mediaType === 'all') return true;
return mediaType === 'video' ? isVideoFile(item) : !isVideoFile(item);
});
const acceptedFileTypes = mediaType === 'image'
? 'image/*'
: mediaType === 'video'
? 'video/*'
: 'image/*,video/*';
if (!isOpen) return null;
return (
@@ -96,7 +117,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
</h2>
<p className="text-sm text-slate-400 mt-1">{t('mediaLibrary.modalSubtitle')}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors text-slate-400 hover:text-white">
<button type="button" onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors text-slate-400 hover:text-white">
<X className="w-6 h-6" />
</button>
</div>
@@ -109,10 +130,10 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
) : (
<><Upload className="w-4 h-4" /> {t('mediaLibrary.btnUploadNewFile')}</>
)}
<input type="file" className="hidden" accept="image/*,video/*" onChange={handleUpload} disabled={uploading} />
<input type="file" className="hidden" accept={acceptedFileTypes} onChange={handleUpload} disabled={uploading} />
</label>
<div className="text-sm text-slate-500 font-mono">
{t('mediaLibrary.itemsFound', { count: media.length })}
{t('mediaLibrary.itemsFound', { count: visibleMedia.length })}
</div>
</div>
@@ -125,7 +146,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
{media.map((item, idx) => {
{visibleMedia.map((item, idx) => {
const isVid = isVideoFile(item);
return (
@@ -164,7 +185,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
</div>
);
})}
{media.length === 0 && !loading && (
{visibleMedia.length === 0 && !loading && (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-slate-500">
<ImageIcon className="w-16 h-16 mb-4 opacity-50" />
<p className="text-lg font-bold">{t('mediaLibrary.noMediaFound')}</p>
+295 -63
View File
@@ -14,7 +14,12 @@ import {
Image as ImageIcon,
ImagePlus,
ExternalLink,
X
X,
ArrowUp,
ArrowDown,
Monitor,
Smartphone,
RotateCcw
} from 'lucide-react'
import { API_BASE, type AttributeDefinition, type Category, type Product, type ProductAttribute, type ProductImage, type Variant } from '@homaroma/types'
import MDEditor from '@uiw/react-md-editor'
@@ -32,6 +37,75 @@ type EditableAttribute = {
translations: Record<string, string>
}
type EditableProductImage = Partial<ProductImage> & {
mobileUrl?: string | null
}
type ImageAspectPreviewProps = {
url?: string | null
label: string
expectedRatio: number
expectedLabel: string
frameClassName: string
fallbackUrl?: string | null
}
function ImageAspectPreview({
url,
label,
expectedRatio,
expectedLabel,
frameClassName,
fallbackUrl,
}: ImageAspectPreviewProps) {
const [loadedImage, setLoadedImage] = useState<{ url: string; width: number; height: number } | null>(null)
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const displayUrl = url || fallbackUrl || ''
const isFallback = !url && Boolean(fallbackUrl)
const dimensions = loadedImage?.url === displayUrl ? loadedImage : null
const failed = failedUrl === displayUrl
const actualRatio = dimensions ? dimensions.width / dimensions.height : null
const ratioMismatch = actualRatio !== null && Math.abs(actualRatio - expectedRatio) > 0.04
return (
<div className="space-y-2">
<div className={`relative overflow-hidden rounded-xl border border-white/10 bg-night-950 ${frameClassName}`}>
{displayUrl && !failed ? (
<img
src={displayUrl.replace('/public/uploads/', '/uploads/')}
alt={`${label} preview`}
className="h-full w-full object-contain"
onLoad={(event) => {
const image = event.currentTarget
setLoadedImage({ url: displayUrl, width: image.naturalWidth, height: image.naturalHeight })
}}
onError={() => setFailedUrl(displayUrl)}
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 text-center text-slate-600">
<ImageIcon className="h-6 w-6" />
<span className="text-[10px] font-bold uppercase tracking-widest">
{failed ? 'Preview unavailable' : `No ${label} image`}
</span>
</div>
)}
{isFallback && displayUrl && !failed && (
<div className="absolute inset-x-2 bottom-2 flex items-center justify-center gap-1 rounded-lg border border-white/10 bg-night-950/90 px-2 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-300">
<RotateCcw className="h-3 w-3" />
Desktop fallback
</div>
)}
</div>
{dimensions && (
<p className={`text-[10px] ${ratioMismatch ? 'text-amber-400' : 'text-slate-500'}`}>
{dimensions.width} × {dimensions.height}px
{ratioMismatch ? ` · Recommended aspect ratio is ${expectedLabel}` : ` · ${expectedLabel} ratio`}
</p>
)}
</div>
)
}
export default function ProductEditPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
@@ -52,7 +126,7 @@ export default function ProductEditPage() {
const [details, setDetails] = useState<Record<string, string>>({})
const [options, setOptions] = useState<{name: Record<string, string>, values: Record<string, string>[]}[]>([])
const [variants, setVariants] = useState<Partial<Variant>[]>([])
const [images, setImages] = useState<Partial<ProductImage>[]>([])
const [images, setImages] = useState<EditableProductImage[]>([])
const [availableAttributes, setAvailableAttributes] = useState<AttributeDefinition[]>([])
const [attributes, setAttributes] = useState<EditableAttribute[]>([])
const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0)
@@ -75,11 +149,6 @@ export default function ProductEditPage() {
// removed quillRef
const fixUrl = (url: string) => {
if (!url) return ''
return url.replace('/public/uploads/', '/uploads/')
}
const openMediaModal = (onSelect: (url: string) => void) => {
setOnSelectMedia(() => onSelect)
setIsMediaModalOpen(true)
@@ -211,13 +280,27 @@ export default function ProductEditPage() {
}
const handleRemoveImage = (index: number) => {
setImages(images.filter((_, i) => i !== index))
setImages((current) => current
.filter((_, imageIndex) => imageIndex !== index)
.map((image, order) => ({ ...image, order })))
}
const handleUpdateImage = (index: number, value: string) => {
const updated = [...images]
updated[index] = { ...updated[index], url: value }
setImages(updated)
const handleUpdateImage = (index: number, field: 'url' | 'mobileUrl', value: string) => {
setImages((current) => current.map((image, imageIndex) => (
imageIndex === index ? { ...image, [field]: value } : image
)))
}
const handleMoveImage = (index: number, direction: -1 | 1) => {
const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= images.length) return
setImages((current) => {
const reordered = [...current]
const [moved] = reordered.splice(index, 1)
reordered.splice(nextIndex, 0, moved)
return reordered.map((image, order) => ({ ...image, order }))
})
}
const attachedDefinitionIds = new Set(attributes.map((attribute) => attribute.attributeDefinitionId))
@@ -287,6 +370,10 @@ export default function ProductEditPage() {
setSuccess(false)
try {
if (images.some((image) => !image.url?.trim() && image.mobileUrl?.trim())) {
throw new Error('Each mobile image needs a Desktop / Tablet image in the same gallery item.')
}
const isNew = !id
const method = isNew ? 'POST' : 'PUT'
const url = isNew ? `${API_BASE}/products` : `${API_BASE}/products/${id}`
@@ -313,7 +400,8 @@ export default function ProductEditPage() {
images: images.filter(img => img.url).map((img, idx) => ({
...(img.id ? { id: img.id } : {}),
url: img.url,
order: img.order ?? (typeof img.order === 'number' ? img.order : idx)
mobileUrl: img.mobileUrl?.trim() || null,
order: idx
}))
}),
})
@@ -650,59 +738,202 @@ export default function ProductEditPage() {
{/* Image Management */}
<section className="admin-card space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-cyan-glow">
<ImageIcon className="w-5 h-5" />
<h2 className="font-bold text-lg">Images Gallery</h2>
</div>
<div className="flex gap-2">
<button
onClick={() => openMediaModal((url) => {
setImages(prev => [...prev, { url, order: prev.length }])
})}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all"
>
<ImagePlus className="w-3.5 h-3.5" />
Add / Browse Image
</button>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-cyan-glow">
<ImageIcon className="w-5 h-5" />
<h2 className="font-bold text-lg">Responsive Images Gallery</h2>
</div>
<p className="max-w-2xl text-sm text-slate-500">
Each gallery item keeps its Desktop / Tablet and Mobile assets together when reordered or removed.
</p>
</div>
<button
type="button"
onClick={() => openMediaModal((url) => {
setImages(prev => [...prev, { url, mobileUrl: null, order: prev.length }])
})}
className="flex shrink-0 items-center justify-center gap-2 rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-cyan-glow transition-all hover:bg-cyan-glow/20"
>
<ImagePlus className="w-3.5 h-3.5" />
Add Gallery Item
</button>
</div>
<div className="space-y-4">
<div className="space-y-5">
{images.map((img, idx) => (
<div key={idx} className="flex gap-4 p-4 bg-white/[0.02] border border-white/5 rounded-2xl group animate-fade-in items-center">
<div className="w-16 h-16 rounded-lg overflow-hidden bg-night-950 border border-white/5 flex-shrink-0">
{img.url ? (
<img src={fixUrl(img.url)} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-slate-700">
<ImageIcon className="w-6 h-6" />
</div>
)}
</div>
<div className="flex-1 space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Image {idx + 1} URL</label>
<div className="relative">
<input
type="text"
value={img.url}
onChange={(e) => handleUpdateImage(idx, e.target.value)}
placeholder="https://..."
className="w-full pl-4 pr-10 py-2.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
/>
{img.url && (
<a href={img.url} target="_blank" rel="noreferrer" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-cyan-glow transition-colors">
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
</div>
<button
onClick={() => handleRemoveImage(idx)}
className="p-2.5 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all"
>
<Trash2 className="w-5 h-5" />
</button>
<div key={img.id ?? `new-image-${idx}`} className="animate-fade-in space-y-5 rounded-2xl border border-white/5 bg-white/[0.02] p-4 sm:p-5">
<div className="flex items-center justify-between gap-4 border-b border-white/5 pb-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-white">Gallery item {idx + 1}</p>
<p className="mt-1 text-[11px] text-slate-500">Both responsive assets move together.</p>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleMoveImage(idx, -1)}
disabled={idx === 0}
aria-label={`Move gallery item ${idx + 1} up`}
title="Move up"
className="rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/5 hover:text-cyan-glow disabled:cursor-not-allowed disabled:opacity-30"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleMoveImage(idx, 1)}
disabled={idx === images.length - 1}
aria-label={`Move gallery item ${idx + 1} down`}
title="Move down"
className="rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/5 hover:text-cyan-glow disabled:cursor-not-allowed disabled:opacity-30"
>
<ArrowDown className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleRemoveImage(idx)}
aria-label={`Remove gallery item ${idx + 1}`}
title="Remove image pair"
className="rounded-lg p-2 text-slate-500 transition-all hover:bg-rose-500/10 hover:text-rose-500"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid gap-5 xl:grid-cols-2">
<div className="space-y-4 rounded-2xl border border-white/5 bg-night-950/40 p-4">
<div className="flex items-start gap-3">
<div className="rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 p-2 text-cyan-glow">
<Monitor className="h-4 w-4" />
</div>
<div>
<h3 className="text-sm font-bold text-white">Desktop / Tablet · 3:4</h3>
<p className="mt-1 text-[11px] text-slate-500">Recommended 1200 × 1600px</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-[9rem_minmax(0,1fr)]">
<ImageAspectPreview
url={img.url}
label="Desktop"
expectedRatio={3 / 4}
expectedLabel="3:4"
frameClassName="aspect-[3/4] w-full"
/>
<div className="space-y-3">
<div className="space-y-2">
<label className="ml-1 text-[10px] font-bold uppercase tracking-widest text-slate-500">Desktop image URL</label>
<div className="relative">
<input
type="text"
value={img.url || ''}
onChange={(e) => handleUpdateImage(idx, 'url', e.target.value)}
placeholder="https://... or /uploads/..."
className="w-full rounded-xl border border-white/10 bg-night-950 py-2.5 pl-4 pr-10 text-sm text-white shadow-inner outline-none transition-all focus:border-cyan-glow/30"
/>
{img.url && (
<a href={img.url} target="_blank" rel="noreferrer" aria-label="Open desktop image" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 transition-colors hover:text-cyan-glow">
<ExternalLink className="h-4 w-4" />
</a>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => openMediaModal((url) => handleUpdateImage(idx, 'url', url))}
className="inline-flex items-center gap-2 rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-cyan-glow transition-colors hover:bg-cyan-glow/20"
>
<ImagePlus className="h-3.5 w-3.5" />
{img.url ? 'Replace / Browse' : 'Add / Browse'}
</button>
{img.url && (
<button
type="button"
onClick={() => handleUpdateImage(idx, 'url', '')}
className="inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-rose-400 transition-colors hover:bg-rose-500/20"
>
<X className="h-3.5 w-3.5" />
Remove
</button>
)}
</div>
{!img.url && img.mobileUrl && (
<p className="text-[11px] text-amber-400">Desktop / Tablet image is required before this pair can be saved.</p>
)}
</div>
</div>
</div>
<div className="space-y-4 rounded-2xl border border-white/5 bg-night-950/40 p-4">
<div className="flex items-start gap-3">
<div className="rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 p-2 text-cyan-glow">
<Smartphone className="h-4 w-4" />
</div>
<div>
<h3 className="text-sm font-bold text-white">Mobile · 1:1</h3>
<p className="mt-1 text-[11px] text-slate-500">Recommended 1200 × 1200px · Optional</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-[9rem_minmax(0,1fr)]">
<ImageAspectPreview
url={img.mobileUrl}
fallbackUrl={img.url}
label="Mobile"
expectedRatio={1}
expectedLabel="1:1"
frameClassName="aspect-square w-full"
/>
<div className="space-y-3">
<div className="space-y-2">
<label className="ml-1 text-[10px] font-bold uppercase tracking-widest text-slate-500">Mobile image URL</label>
<div className="relative">
<input
type="text"
value={img.mobileUrl || ''}
onChange={(e) => handleUpdateImage(idx, 'mobileUrl', e.target.value)}
placeholder="Optional — uses Desktop image when empty"
className="w-full rounded-xl border border-white/10 bg-night-950 py-2.5 pl-4 pr-10 text-sm text-white shadow-inner outline-none transition-all focus:border-cyan-glow/30"
/>
{img.mobileUrl && (
<a href={img.mobileUrl} target="_blank" rel="noreferrer" aria-label="Open mobile image" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 transition-colors hover:text-cyan-glow">
<ExternalLink className="h-4 w-4" />
</a>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => openMediaModal((url) => handleUpdateImage(idx, 'mobileUrl', url))}
className="inline-flex items-center gap-2 rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-cyan-glow transition-colors hover:bg-cyan-glow/20"
>
<ImagePlus className="h-3.5 w-3.5" />
{img.mobileUrl ? 'Replace / Browse' : 'Add / Browse'}
</button>
{img.mobileUrl && (
<button
type="button"
onClick={() => handleUpdateImage(idx, 'mobileUrl', '')}
className="inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-rose-400 transition-colors hover:bg-rose-500/20"
>
<X className="h-3.5 w-3.5" />
Remove
</button>
)}
</div>
{!img.mobileUrl && img.url && (
<p className="flex items-center gap-1.5 text-[11px] text-slate-400">
<RotateCcw className="h-3 w-3" />
Mobile storefront will fall back to the Desktop image.
</p>
)}
</div>
</div>
</div>
</div>
</div>
))}
@@ -711,7 +942,7 @@ export default function ProductEditPage() {
<div className="w-12 h-12 bg-white/5 rounded-full flex items-center justify-center mx-auto text-slate-600">
<ImageIcon className="w-6 h-6" />
</div>
<p className="text-slate-600 italic text-sm">No product images added yet. Default category image will be used.</p>
<p className="text-slate-600 italic text-sm">No gallery items added yet. The storefront will use its existing fallback image.</p>
</div>
)}
</div>
@@ -926,6 +1157,7 @@ export default function ProductEditPage() {
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
mediaType="image"
onSelect={(url) => {
if (onSelectMedia) onSelectMedia(url)
}}