- Fix Next.js build failure: remove duplicate route at (dashboard)/listings/[id] that conflicted with (public)/listings/[id] (same URL path in two route groups) - Fix 772 ESLint errors: auto-fix import ordering (import-x/order), remove unused imports/variables, convert empty interfaces to type aliases, replace require() with ESM imports, fix consistent-type-imports violations - Add CLAUDE.md for developer onboarding documentation - All checks pass: 0 lint errors, typecheck clean, 230 tests passing, build success Co-Authored-By: Paperclip <noreply@paperclip.ing>
167 lines
4.9 KiB
TypeScript
167 lines
4.9 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
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));
|
|
};
|
|
}, []); // intentionally empty: runs only on unmount to revoke object URLs
|
|
|
|
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 };
|