mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
- Introduced a new `backup.json` file to track feature statuses, descriptions, and summaries for better project management. - Updated `.automaker/feature_list.json` to reflect verified statuses for several features, ensuring accurate representation of progress. - Enhanced `memory.md` with details on drag-and-drop functionality for features in `waiting_approval` status. - Improved auto mode service to allow running tasks to complete when auto mode is stopped, enhancing user experience.
290 lines
9.6 KiB
TypeScript
290 lines
9.6 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef, useCallback } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { ImageIcon, X, Upload } from "lucide-react";
|
|
import type { ImageAttachment } from "@/store/app-store";
|
|
|
|
interface ImageDropZoneProps {
|
|
onImagesSelected: (images: ImageAttachment[]) => void;
|
|
maxFiles?: number;
|
|
maxFileSize?: number; // in bytes, default 10MB
|
|
className?: string;
|
|
children?: React.ReactNode;
|
|
disabled?: boolean;
|
|
images?: ImageAttachment[]; // Optional controlled images prop
|
|
}
|
|
|
|
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
|
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
|
|
export function ImageDropZone({
|
|
onImagesSelected,
|
|
maxFiles = 5,
|
|
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
|
className,
|
|
children,
|
|
disabled = false,
|
|
images,
|
|
}: ImageDropZoneProps) {
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [internalImages, setInternalImages] = useState<ImageAttachment[]>([]);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Use controlled images if provided, otherwise use internal state
|
|
const selectedImages = images ?? internalImages;
|
|
|
|
// Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state
|
|
const updateImages = useCallback((newImages: ImageAttachment[]) => {
|
|
if (images === undefined) {
|
|
setInternalImages(newImages);
|
|
}
|
|
onImagesSelected(newImages);
|
|
}, [images, onImagesSelected]);
|
|
|
|
const processFiles = useCallback(async (files: FileList) => {
|
|
if (disabled || isProcessing) return;
|
|
|
|
setIsProcessing(true);
|
|
const newImages: ImageAttachment[] = [];
|
|
const errors: string[] = [];
|
|
|
|
for (const file of Array.from(files)) {
|
|
// Validate file type
|
|
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
|
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
|
continue;
|
|
}
|
|
|
|
// Validate file size
|
|
if (file.size > maxFileSize) {
|
|
const maxSizeMB = maxFileSize / (1024 * 1024);
|
|
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
|
continue;
|
|
}
|
|
|
|
// Check if we've reached max files
|
|
if (newImages.length + selectedImages.length >= maxFiles) {
|
|
errors.push(`Maximum ${maxFiles} images allowed.`);
|
|
break;
|
|
}
|
|
|
|
try {
|
|
const base64 = await fileToBase64(file);
|
|
const imageAttachment: ImageAttachment = {
|
|
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
data: base64,
|
|
mimeType: file.type,
|
|
filename: file.name,
|
|
size: file.size,
|
|
};
|
|
newImages.push(imageAttachment);
|
|
} catch (error) {
|
|
errors.push(`${file.name}: Failed to process image.`);
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
console.warn('Image upload errors:', errors);
|
|
// You could show these errors to the user via a toast or notification
|
|
}
|
|
|
|
if (newImages.length > 0) {
|
|
const allImages = [...selectedImages, ...newImages];
|
|
updateImages(allImages);
|
|
}
|
|
|
|
setIsProcessing(false);
|
|
}, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]);
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOver(false);
|
|
|
|
if (disabled) return;
|
|
|
|
const files = e.dataTransfer.files;
|
|
if (files.length > 0) {
|
|
processFiles(files);
|
|
}
|
|
}, [disabled, processFiles]);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!disabled) {
|
|
setIsDragOver(true);
|
|
}
|
|
}, [disabled]);
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOver(false);
|
|
}, []);
|
|
|
|
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (files && files.length > 0) {
|
|
processFiles(files);
|
|
}
|
|
// Reset the input so the same file can be selected again
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}, [processFiles]);
|
|
|
|
const handleBrowseClick = useCallback(() => {
|
|
if (!disabled && fileInputRef.current) {
|
|
fileInputRef.current.click();
|
|
}
|
|
}, [disabled]);
|
|
|
|
const removeImage = useCallback((imageId: string) => {
|
|
const updated = selectedImages.filter(img => img.id !== imageId);
|
|
updateImages(updated);
|
|
}, [selectedImages, updateImages]);
|
|
|
|
const clearAllImages = useCallback(() => {
|
|
updateImages([]);
|
|
}, [updateImages]);
|
|
|
|
return (
|
|
<div className={cn("relative", className)}>
|
|
{/* Hidden file input */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept={ACCEPTED_IMAGE_TYPES.join(',')}
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
disabled={disabled}
|
|
/>
|
|
|
|
{/* Drop zone */}
|
|
<div
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
className={cn(
|
|
"relative rounded-lg border-2 border-dashed transition-all duration-200",
|
|
{
|
|
"border-blue-400 bg-blue-50 dark:bg-blue-950/20": isDragOver && !disabled,
|
|
"border-muted-foreground/25": !isDragOver && !disabled,
|
|
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
|
|
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10": !disabled && !isDragOver,
|
|
}
|
|
)}
|
|
>
|
|
{children || (
|
|
<div className="flex flex-col items-center justify-center p-6 text-center">
|
|
<div className={cn(
|
|
"rounded-full p-3 mb-4",
|
|
isDragOver && !disabled ? "bg-blue-100 dark:bg-blue-900/30" : "bg-muted"
|
|
)}>
|
|
{isProcessing ? (
|
|
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<p className="text-sm font-medium text-foreground mb-1">
|
|
{isDragOver && !disabled ? "Drop your images here" : "Drag images here or click to browse"}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{maxFiles > 1 ? `Up to ${maxFiles} images` : "1 image"}, max {Math.round(maxFileSize / (1024 * 1024))}MB each
|
|
</p>
|
|
{!disabled && (
|
|
<button
|
|
onClick={handleBrowseClick}
|
|
className="mt-2 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
|
disabled={isProcessing}
|
|
>
|
|
Browse files
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Image previews */}
|
|
{selectedImages.length > 0 && (
|
|
<div className="mt-3 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs font-medium text-foreground">
|
|
{selectedImages.length} image{selectedImages.length > 1 ? 's' : ''} selected
|
|
</p>
|
|
<button
|
|
onClick={clearAllImages}
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
disabled={disabled}
|
|
>
|
|
Clear all
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{selectedImages.map((image) => (
|
|
<div
|
|
key={image.id}
|
|
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
|
|
>
|
|
{/* Image thumbnail */}
|
|
<div className="w-8 h-8 rounded overflow-hidden bg-muted flex-shrink-0">
|
|
<img
|
|
src={image.data}
|
|
alt={image.filename}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
{/* Image info */}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs font-medium text-foreground truncate">
|
|
{image.filename}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatFileSize(image.size)}
|
|
</p>
|
|
</div>
|
|
{/* Remove button */}
|
|
{!disabled && (
|
|
<button
|
|
onClick={() => removeImage(image.id)}
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive hover:text-destructive-foreground text-muted-foreground"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function fileToBase64(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
if (typeof reader.result === 'string') {
|
|
resolve(reader.result);
|
|
} else {
|
|
reject(new Error('Failed to read file as base64'));
|
|
}
|
|
};
|
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
} |