From 081f7c600793e60b7a4bbe5e5327fc444788120f Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Tue, 9 Dec 2025 12:47:24 -0500 Subject: [PATCH] feat: add image drag and drop to Kanban card description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ability to drag and drop images into the description section when creating new Kanban cards. Images are saved to a temp directory and their paths are stored with the feature for agent context. - Create DescriptionImageDropZone component with drag/drop support - Integrate with Add Feature dialog in board-view - Add FeatureImagePath interface to track temp file paths - Update saveFeatures to persist imagePaths - Add saveImageToTemp to mock electron API - Add test utilities for image upload testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ui/description-image-dropzone.tsx | 371 ++++++++++++++++++ app/src/components/views/board-view.tsx | 23 +- app/src/lib/electron.ts | 22 ++ app/src/store/app-store.ts | 8 + app/tests/utils.ts | 72 ++++ 5 files changed, 488 insertions(+), 8 deletions(-) create mode 100644 app/src/components/ui/description-image-dropzone.tsx diff --git a/app/src/components/ui/description-image-dropzone.tsx b/app/src/components/ui/description-image-dropzone.tsx new file mode 100644 index 00000000..53d92b31 --- /dev/null +++ b/app/src/components/ui/description-image-dropzone.tsx @@ -0,0 +1,371 @@ +"use client"; + +import React, { useState, useRef, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { ImageIcon, X, Loader2 } from "lucide-react"; +import { Textarea } from "@/components/ui/textarea"; +import { getElectronAPI } from "@/lib/electron"; + +export interface FeatureImagePath { + id: string; + path: string; // Path to the temp file + filename: string; + mimeType: string; +} + +interface DescriptionImageDropZoneProps { + value: string; + onChange: (value: string) => void; + images: FeatureImagePath[]; + onImagesChange: (images: FeatureImagePath[]) => void; + placeholder?: string; + className?: string; + disabled?: boolean; + maxFiles?: number; + maxFileSize?: number; // in bytes, default 10MB +} + +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 DescriptionImageDropZone({ + value, + onChange, + images, + onImagesChange, + placeholder = "Describe the feature...", + className, + disabled = false, + maxFiles = 5, + maxFileSize = DEFAULT_MAX_FILE_SIZE, +}: DescriptionImageDropZoneProps) { + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [previewImages, setPreviewImages] = useState>( + new Map() + ); + const fileInputRef = useRef(null); + + const fileToBase64 = (file: File): Promise => { + 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); + }); + }; + + const saveImageToTemp = async ( + base64Data: string, + filename: string, + mimeType: string + ): Promise => { + try { + const api = getElectronAPI(); + // Check if saveImageToTemp method exists + if (!api.saveImageToTemp) { + // Fallback for mock API - return a mock path + console.log("[DescriptionImageDropZone] Using mock path for image"); + return `/tmp/automaker-images/${Date.now()}_${filename}`; + } + const result = await api.saveImageToTemp(base64Data, filename, mimeType); + if (result.success && result.path) { + return result.path; + } + console.error("[DescriptionImageDropZone] Failed to save image:", result.error); + return null; + } catch (error) { + console.error("[DescriptionImageDropZone] Error saving image:", error); + return null; + } + }; + + const processFiles = useCallback( + async (files: FileList) => { + if (disabled || isProcessing) return; + + setIsProcessing(true); + const newImages: FeatureImagePath[] = []; + const newPreviews = new Map(previewImages); + 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 + images.length >= maxFiles) { + errors.push(`Maximum ${maxFiles} images allowed.`); + break; + } + + try { + const base64 = await fileToBase64(file); + const tempPath = await saveImageToTemp(base64, file.name, file.type); + + if (tempPath) { + const imageId = `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const imagePathRef: FeatureImagePath = { + id: imageId, + path: tempPath, + filename: file.name, + mimeType: file.type, + }; + newImages.push(imagePathRef); + // Store preview for display + newPreviews.set(imageId, base64); + } else { + errors.push(`${file.name}: Failed to save image.`); + } + } catch (error) { + errors.push(`${file.name}: Failed to process image.`); + } + } + + if (errors.length > 0) { + console.warn("Image upload errors:", errors); + } + + if (newImages.length > 0) { + onImagesChange([...images, ...newImages]); + setPreviewImages(newPreviews); + } + + setIsProcessing(false); + }, + [disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages] + ); + + 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) => { + 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) => { + onImagesChange(images.filter((img) => img.id !== imageId)); + setPreviewImages((prev) => { + const newMap = new Map(prev); + newMap.delete(imageId); + return newMap; + }); + }, + [images, onImagesChange] + ); + + return ( +
+ {/* Hidden file input */} + + + {/* Drop zone wrapper */} +
+ {/* Drag overlay */} + {isDragOver && !disabled && ( +
+
+ + Drop images here +
+
+ )} + + {/* Textarea */} +