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>
This commit is contained in:
167
apps/web/components/listings/image-upload.tsx
Normal file
167
apps/web/components/listings/image-upload.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user