Files
goodgo-platform/apps/web/components/listings/image-upload.tsx
Ho Ngoc Hai 207a2013f3 feat(listings-frontend): add create/edit form, detail page, and listing components
- Multi-step wizard for listing creation (basic info, location, details, pricing, images)
- Listing detail page with image gallery, property specs, seller/agent info, stats
- Listings index page with filters (transaction type, property type) and pagination
- Edit page with tab-based form (read-only until backend PATCH endpoint available)
- Drag & drop image upload component with preview and multi-file support
- Dashboard layout with navigation bar
- New UI primitives: textarea, select, badge, tabs
- Listings API client with typed endpoints matching backend contract
- Zod validation schemas for all form steps
- Status badges with Vietnamese labels for all listing states
- Responsive design across all pages

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 01:54:08 +07:00

168 lines
4.9 KiB
TypeScript

'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface ImageFile {
file: File;
preview: string;
}
interface ImageUploadProps {
images: ImageFile[];
onChange: (images: ImageFile[]) => void;
maxFiles?: number;
className?: string;
}
export function ImageUpload({ images, onChange, maxFiles = 20, className }: ImageUploadProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = React.useState(false);
const addFiles = React.useCallback(
(files: FileList | File[]) => {
const newImages: ImageFile[] = [];
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
const maxSize = 10 * 1024 * 1024; // 10MB
Array.from(files).forEach((file) => {
if (!allowedTypes.includes(file.type)) return;
if (file.size > maxSize) return;
if (images.length + newImages.length >= maxFiles) return;
newImages.push({
file,
preview: URL.createObjectURL(file),
});
});
if (newImages.length > 0) {
onChange([...images, ...newImages]);
}
},
[images, onChange, maxFiles],
);
const removeImage = React.useCallback(
(index: number) => {
const updated = [...images];
URL.revokeObjectURL(updated[index]!.preview);
updated.splice(index, 1);
onChange(updated);
},
[images, onChange],
);
const handleDragOver = React.useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = React.useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = React.useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files);
}
},
[addFiles],
);
React.useEffect(() => {
return () => {
images.forEach((img) => URL.revokeObjectURL(img.preview));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={cn('space-y-4', className)}>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={cn(
'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50',
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="mb-3 text-muted-foreground"
>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
<p className="text-sm font-medium">Kéo thả nh vào đây hoặc nhấp đ chọn</p>
<p className="mt-1 text-xs text-muted-foreground">
JPG, PNG, WebP - Tối đa {maxFiles} nh, mỗi nh 10MB
</p>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = '';
}}
/>
</div>
{images.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{images.map((img, index) => (
<div key={img.preview} className="group relative aspect-square overflow-hidden rounded-lg border">
<img
src={img.preview}
alt={`Ảnh ${index + 1}`}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
<Button
type="button"
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation();
removeImage(index);
}}
>
Xóa
</Button>
</div>
{index === 0 && (
<span className="absolute left-1.5 top-1.5 rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
nh bìa
</span>
)}
</div>
))}
</div>
)}
</div>
);
}
export type { ImageFile };