x
This commit is contained in:
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>
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user