Files
goodgo-platform/apps/web/components/listings/image-upload.tsx
Ho Ngoc Hai 2502aa69b7 fix: production readiness — resolve build, lint, and code quality issues
- 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>
2026-04-08 07:15:06 +07:00

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 };