diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 150f0bad..0b7d6f0e 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -5,6 +5,18 @@ import tsParser from "@typescript-eslint/parser"; const eslintConfig = defineConfig([ js.configs.recommended, + { + files: ["**/*.mjs", "**/*.cjs"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + require: "readonly", + __dirname: "readonly", + __filename: "readonly", + }, + }, + }, { files: ["**/*.ts", "**/*.tsx"], languageOptions: { @@ -13,6 +25,70 @@ const eslintConfig = defineConfig([ ecmaVersion: "latest", sourceType: "module", }, + globals: { + // Browser/DOM APIs + window: "readonly", + document: "readonly", + navigator: "readonly", + Navigator: "readonly", + localStorage: "readonly", + sessionStorage: "readonly", + fetch: "readonly", + WebSocket: "readonly", + File: "readonly", + FileList: "readonly", + FileReader: "readonly", + Blob: "readonly", + atob: "readonly", + crypto: "readonly", + prompt: "readonly", + confirm: "readonly", + getComputedStyle: "readonly", + requestAnimationFrame: "readonly", + // DOM Element Types + HTMLElement: "readonly", + HTMLInputElement: "readonly", + HTMLDivElement: "readonly", + HTMLButtonElement: "readonly", + HTMLSpanElement: "readonly", + HTMLTextAreaElement: "readonly", + HTMLHeadingElement: "readonly", + HTMLParagraphElement: "readonly", + HTMLImageElement: "readonly", + Element: "readonly", + // Event Types + Event: "readonly", + KeyboardEvent: "readonly", + DragEvent: "readonly", + PointerEvent: "readonly", + CustomEvent: "readonly", + ClipboardEvent: "readonly", + WheelEvent: "readonly", + DataTransfer: "readonly", + // Web APIs + ResizeObserver: "readonly", + AbortSignal: "readonly", + Audio: "readonly", + ScrollBehavior: "readonly", + // Timers + setTimeout: "readonly", + setInterval: "readonly", + clearTimeout: "readonly", + clearInterval: "readonly", + // Node.js (for scripts and Electron) + process: "readonly", + require: "readonly", + __dirname: "readonly", + __filename: "readonly", + NodeJS: "readonly", + // React + React: "readonly", + JSX: "readonly", + // Electron + Electron: "readonly", + // Console + console: "readonly", + }, }, plugins: { "@typescript-eslint": ts, diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index b6a05ab0..dc9c1c2e 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback } from 'react'; import { FolderOpen, Folder, @@ -9,7 +9,7 @@ import { CornerDownLeft, Clock, X, -} from "lucide-react"; +} from 'lucide-react'; import { Dialog, DialogContent, @@ -17,14 +17,11 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { getJSON, setJSON } from "@/lib/storage"; -import { - getDefaultWorkspaceDirectory, - saveLastProjectDirectory, -} from "@/lib/workspace-config"; +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { getJSON, setJSON } from '@/lib/storage'; +import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; interface DirectoryEntry { name: string; @@ -50,7 +47,7 @@ interface FileBrowserDialogProps { initialPath?: string; } -const RECENT_FOLDERS_KEY = "file-browser-recent-folders"; +const RECENT_FOLDERS_KEY = 'file-browser-recent-folders'; const MAX_RECENT_FOLDERS = 5; function getRecentFolders(): string[] { @@ -76,18 +73,18 @@ export function FileBrowserDialog({ open, onOpenChange, onSelect, - title = "Select Project Directory", - description = "Navigate to your project folder or paste a path directly", + title = 'Select Project Directory', + description = 'Navigate to your project folder or paste a path directly', initialPath, }: FileBrowserDialogProps) { - const [currentPath, setCurrentPath] = useState(""); - const [pathInput, setPathInput] = useState(""); + const [currentPath, setCurrentPath] = useState(''); + const [pathInput, setPathInput] = useState(''); const [parentPath, setParentPath] = useState(null); const [directories, setDirectories] = useState([]); const [drives, setDrives] = useState([]); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [warning, setWarning] = useState(""); + const [error, setError] = useState(''); + const [warning, setWarning] = useState(''); const [recentFolders, setRecentFolders] = useState([]); const pathInputRef = useRef(null); @@ -98,28 +95,24 @@ export function FileBrowserDialog({ } }, [open]); - const handleRemoveRecent = useCallback( - (e: React.MouseEvent, path: string) => { - e.stopPropagation(); - const updated = removeRecentFolder(path); - setRecentFolders(updated); - }, - [] - ); + const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => { + e.stopPropagation(); + const updated = removeRecentFolder(path); + setRecentFolders(updated); + }, []); const browseDirectory = useCallback(async (dirPath?: string) => { setLoading(true); - setError(""); - setWarning(""); + setError(''); + setWarning(''); try { // Get server URL from environment or default - const serverUrl = - import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; + const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; const response = await fetch(`${serverUrl}/api/fs/browse`, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dirPath }), }); @@ -131,14 +124,12 @@ export function FileBrowserDialog({ setParentPath(result.parentPath); setDirectories(result.directories); setDrives(result.drives || []); - setWarning(result.warning || ""); + setWarning(result.warning || ''); } else { - setError(result.error || "Failed to browse directory"); + setError(result.error || 'Failed to browse directory'); } } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to load directories" - ); + setError(err instanceof Error ? err.message : 'Failed to load directories'); } finally { setLoading(false); } @@ -154,12 +145,12 @@ export function FileBrowserDialog({ // Reset current path when dialog closes useEffect(() => { if (!open) { - setCurrentPath(""); - setPathInput(""); + setCurrentPath(''); + setPathInput(''); setParentPath(null); setDirectories([]); - setError(""); - setWarning(""); + setError(''); + setWarning(''); } }, [open]); @@ -189,7 +180,7 @@ export function FileBrowserDialog({ // No default directory, browse home directory browseDirectory(); } - } catch (err) { + } catch { // If config fetch fails, try initialPath or fall back to home directory if (initialPath) { setPathInput(initialPath); @@ -230,7 +221,7 @@ export function FileBrowserDialog({ }; const handlePathInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { + if (e.key === 'Enter') { e.preventDefault(); handleGoToPath(); } @@ -252,7 +243,7 @@ export function FileBrowserDialog({ const handleKeyDown = (e: KeyboardEvent) => { // Check for Command+Enter (Mac) or Ctrl+Enter (Windows/Linux) - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (currentPath && !loading) { handleSelect(); @@ -260,8 +251,8 @@ export function FileBrowserDialog({ } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); }, [open, currentPath, loading, handleSelect]); // Helper to get folder name from path @@ -326,9 +317,7 @@ export function FileBrowserDialog({ title={folder} > - - {getFolderName(folder)} - + {getFolderName(folder)} ))} @@ -388,7 +375,7 @@ export function FileBrowserDialog({ )}
- {currentPath || "Loading..."} + {currentPath || 'Loading...'}
@@ -396,9 +383,7 @@ export function FileBrowserDialog({
{loading && (
-
- Loading directories... -
+
Loading directories...
)} @@ -416,9 +401,7 @@ export function FileBrowserDialog({ {!loading && !error && !warning && directories.length === 0 && (
-
- No subdirectories found -
+
No subdirectories found
)} @@ -440,8 +423,8 @@ export function FileBrowserDialog({
- Paste a full path above, or click on folders to navigate. Press - Enter or click Go to jump to a path. + Paste a full path above, or click on folders to navigate. Press Enter or click Go to + jump to a path.
@@ -458,10 +441,9 @@ export function FileBrowserDialog({ Select Current Folder - {typeof navigator !== "undefined" && - navigator.platform?.includes("Mac") - ? "⌘" - : "Ctrl"} + {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') + ? '⌘' + : 'Ctrl'} +↵ diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index e59c6744..16b1e5cb 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -155,12 +155,6 @@ export function Sidebar() { setNewProjectPath, }); - // Handle bug report button click - const handleBugReportClick = useCallback(() => { - const api = getElectronAPI(); - api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); - }, []); - /** * Opens the system folder selection dialog and initializes the selected project. * Used by both the 'O' keyboard shortcut and the folder icon button. @@ -273,11 +267,7 @@ export function Sidebar() { />
- + {/* Project Actions - Moved above project selector */} {sidebarOpen && ( diff --git a/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx index 68a413c4..8139dc55 100644 --- a/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx +++ b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx @@ -1,11 +1,21 @@ import { Bug } from 'lucide-react'; import { cn } from '@/lib/utils'; -import type { BugReportButtonProps } from '../types'; +import { useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; + +interface BugReportButtonProps { + sidebarExpanded: boolean; +} + +export function BugReportButton({ sidebarExpanded }: BugReportButtonProps) { + const handleBugReportClick = useCallback(() => { + const api = getElectronAPI(); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + }, []); -export function BugReportButton({ sidebarExpanded, onClick }: BugReportButtonProps) { return (
{/* Bug Report Button - Collapsed sidebar version */} {!sidebarOpen && (
- +
)} diff --git a/apps/ui/src/components/layout/sidebar/types.ts b/apps/ui/src/components/layout/sidebar/types.ts index e76e4917..4d9ecc35 100644 --- a/apps/ui/src/components/layout/sidebar/types.ts +++ b/apps/ui/src/components/layout/sidebar/types.ts @@ -1,4 +1,5 @@ import type { Project } from '@/lib/electron'; +import type React from 'react'; export interface NavSection { label?: string; @@ -29,8 +30,3 @@ export interface ThemeMenuItemProps { onPreviewEnter: (value: string) => void; onPreviewLeave: (e: React.PointerEvent) => void; } - -export interface BugReportButtonProps { - sidebarExpanded: boolean; - onClick: () => void; -} diff --git a/apps/ui/src/components/ui/accordion.tsx b/apps/ui/src/components/ui/accordion.tsx index 0c8b6101..3cb256b3 100644 --- a/apps/ui/src/components/ui/accordion.tsx +++ b/apps/ui/src/components/ui/accordion.tsx @@ -1,9 +1,10 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ -import * as React from "react"; -import { ChevronDown } from "lucide-react"; -import { cn } from "@/lib/utils"; +import * as React from 'react'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; -type AccordionType = "single" | "multiple"; +type AccordionType = 'single' | 'multiple'; interface AccordionContextValue { type: AccordionType; @@ -12,12 +13,10 @@ interface AccordionContextValue { collapsible?: boolean; } -const AccordionContext = React.createContext( - null -); +const AccordionContext = React.createContext(null); interface AccordionProps extends React.HTMLAttributes { - type?: "single" | "multiple"; + type?: 'single' | 'multiple'; value?: string | string[]; defaultValue?: string | string[]; onValueChange?: (value: string | string[]) => void; @@ -27,7 +26,7 @@ interface AccordionProps extends React.HTMLAttributes { const Accordion = React.forwardRef( ( { - type = "single", + type = 'single', value, defaultValue, onValueChange, @@ -38,13 +37,11 @@ const Accordion = React.forwardRef( }, ref ) => { - const [internalValue, setInternalValue] = React.useState( - () => { - if (value !== undefined) return value; - if (defaultValue !== undefined) return defaultValue; - return type === "single" ? "" : []; - } - ); + const [internalValue, setInternalValue] = React.useState(() => { + if (value !== undefined) return value; + if (defaultValue !== undefined) return defaultValue; + return type === 'single' ? '' : []; + }); const currentValue = value !== undefined ? value : internalValue; @@ -52,9 +49,9 @@ const Accordion = React.forwardRef( (itemValue: string) => { let newValue: string | string[]; - if (type === "single") { + if (type === 'single') { if (currentValue === itemValue && collapsible) { - newValue = ""; + newValue = ''; } else if (currentValue === itemValue && !collapsible) { return; } else { @@ -91,27 +88,21 @@ const Accordion = React.forwardRef( return ( -
+
{children}
); } ); -Accordion.displayName = "Accordion"; +Accordion.displayName = 'Accordion'; interface AccordionItemContextValue { value: string; isOpen: boolean; } -const AccordionItemContext = - React.createContext(null); +const AccordionItemContext = React.createContext(null); interface AccordionItemProps extends React.HTMLAttributes { value: string; @@ -122,25 +113,22 @@ const AccordionItem = React.forwardRef( const accordionContext = React.useContext(AccordionContext); if (!accordionContext) { - throw new Error("AccordionItem must be used within an Accordion"); + throw new Error('AccordionItem must be used within an Accordion'); } const isOpen = Array.isArray(accordionContext.value) ? accordionContext.value.includes(value) : accordionContext.value === value; - const contextValue = React.useMemo( - () => ({ value, isOpen }), - [value, isOpen] - ); + const contextValue = React.useMemo(() => ({ value, isOpen }), [value, isOpen]); return (
{children} @@ -149,47 +137,45 @@ const AccordionItem = React.forwardRef( ); } ); -AccordionItem.displayName = "AccordionItem"; +AccordionItem.displayName = 'AccordionItem'; -interface AccordionTriggerProps - extends React.ButtonHTMLAttributes {} +interface AccordionTriggerProps extends React.ButtonHTMLAttributes {} -const AccordionTrigger = React.forwardRef< - HTMLButtonElement, - AccordionTriggerProps ->(({ className, children, ...props }, ref) => { - const accordionContext = React.useContext(AccordionContext); - const itemContext = React.useContext(AccordionItemContext); +const AccordionTrigger = React.forwardRef( + ({ className, children, ...props }, ref) => { + const accordionContext = React.useContext(AccordionContext); + const itemContext = React.useContext(AccordionItemContext); - if (!accordionContext || !itemContext) { - throw new Error("AccordionTrigger must be used within an AccordionItem"); + if (!accordionContext || !itemContext) { + throw new Error('AccordionTrigger must be used within an AccordionItem'); + } + + const { onValueChange } = accordionContext; + const { value, isOpen } = itemContext; + + return ( +
+ +
+ ); } - - const { onValueChange } = accordionContext; - const { value, isOpen } = itemContext; - - return ( -
- -
- ); -}); -AccordionTrigger.displayName = "AccordionTrigger"; +); +AccordionTrigger.displayName = 'AccordionTrigger'; interface AccordionContentProps extends React.HTMLAttributes {} @@ -200,7 +186,7 @@ const AccordionContent = React.forwardRef const [height, setHeight] = React.useState(undefined); if (!itemContext) { - throw new Error("AccordionContent must be used within an AccordionItem"); + throw new Error('AccordionContent must be used within an AccordionItem'); } const { isOpen } = itemContext; @@ -220,16 +206,16 @@ const AccordionContent = React.forwardRef return (
-
+
{children}
@@ -237,6 +223,6 @@ const AccordionContent = React.forwardRef ); } ); -AccordionContent.displayName = "AccordionContent"; +AccordionContent.displayName = 'AccordionContent'; export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/ui/src/components/ui/description-image-dropzone.tsx b/apps/ui/src/components/ui/description-image-dropzone.tsx index af3f9019..7020ca75 100644 --- a/apps/ui/src/components/ui/description-image-dropzone.tsx +++ b/apps/ui/src/components/ui/description-image-dropzone.tsx @@ -1,10 +1,9 @@ - -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"; -import { useAppStore, type FeatureImagePath } from "@/store/app-store"; +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'; +import { useAppStore, type FeatureImagePath } from '@/store/app-store'; // Map to store preview data by image ID (persisted across component re-mounts) export type ImagePreviewMap = Map; @@ -26,13 +25,7 @@ interface DescriptionImageDropZoneProps { error?: boolean; // Show error state with red border } -const ACCEPTED_IMAGE_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", -]; +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({ @@ -40,7 +33,7 @@ export function DescriptionImageDropZone({ onChange, images, onImagesChange, - placeholder = "Describe the feature...", + placeholder = 'Describe the feature...', className, disabled = false, maxFiles = 5, @@ -59,71 +52,76 @@ export function DescriptionImageDropZone({ // Determine which preview map to use - prefer parent-controlled state const previewImages = previewMap !== undefined ? previewMap : localPreviewImages; - const setPreviewImages = useCallback((updater: Map | ((prev: Map) => Map)) => { - if (onPreviewMapChange) { - const currentMap = previewMap !== undefined ? previewMap : localPreviewImages; - const newMap = typeof updater === 'function' ? updater(currentMap) : updater; - onPreviewMapChange(newMap); - } else { - setLocalPreviewImages((prev) => { - const newMap = typeof updater === 'function' ? updater(prev) : updater; - return newMap; - }); - } - }, [onPreviewMapChange, previewMap, localPreviewImages]); + const setPreviewImages = useCallback( + (updater: Map | ((prev: Map) => Map)) => { + if (onPreviewMapChange) { + const currentMap = previewMap !== undefined ? previewMap : localPreviewImages; + const newMap = typeof updater === 'function' ? updater(currentMap) : updater; + onPreviewMapChange(newMap); + } else { + setLocalPreviewImages((prev) => { + const newMap = typeof updater === 'function' ? updater(prev) : updater; + return newMap; + }); + } + }, + [onPreviewMapChange, previewMap, localPreviewImages] + ); const fileInputRef = useRef(null); const currentProject = useAppStore((state) => state.currentProject); // Construct server URL for loading saved images - const getImageServerUrl = useCallback((imagePath: string): string => { - const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; - const projectPath = currentProject?.path || ""; - return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; - }, [currentProject?.path]); + const getImageServerUrl = useCallback( + (imagePath: string): string => { + const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; + const projectPath = currentProject?.path || ''; + return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; + }, + [currentProject?.path] + ); const fileToBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { - if (typeof reader.result === "string") { + if (typeof reader.result === 'string') { resolve(reader.result); } else { - reject(new Error("Failed to read file as base64")); + reject(new Error('Failed to read file as base64')); } }; - reader.onerror = () => reject(new Error("Failed to read file")); + reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsDataURL(file); }); }; - const saveImageToTemp = useCallback(async ( - base64Data: string, - filename: string, - mimeType: string - ): Promise => { - try { - const api = getElectronAPI(); - // Check if saveImageToTemp method exists - if (!api.saveImageToTemp) { - // Fallback path when saveImageToTemp is not available - console.log("[DescriptionImageDropZone] Using fallback path for image"); - return `.automaker/images/${Date.now()}_${filename}`; - } + const saveImageToTemp = useCallback( + async (base64Data: string, filename: string, mimeType: string): Promise => { + try { + const api = getElectronAPI(); + // Check if saveImageToTemp method exists + if (!api.saveImageToTemp) { + // Fallback path when saveImageToTemp is not available + console.log('[DescriptionImageDropZone] Using fallback path for image'); + return `.automaker/images/${Date.now()}_${filename}`; + } - // Get projectPath from the store if available - const projectPath = currentProject?.path; - const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath); - if (result.success && result.path) { - return result.path; + // Get projectPath from the store if available + const projectPath = currentProject?.path; + const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath); + 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; } - console.error("[DescriptionImageDropZone] Failed to save image:", result.error); - return null; - } catch (error) { - console.error("[DescriptionImageDropZone] Error saving image:", error); - return null; - } - }, [currentProject?.path]); + }, + [currentProject?.path] + ); const processFiles = useCallback( async (files: FileList) => { @@ -137,18 +135,14 @@ export function DescriptionImageDropZone({ 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.` - ); + 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.` - ); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); continue; } @@ -176,13 +170,13 @@ export function DescriptionImageDropZone({ } else { errors.push(`${file.name}: Failed to save image.`); } - } catch (error) { + } catch { errors.push(`${file.name}: Failed to process image.`); } } if (errors.length > 0) { - console.warn("Image upload errors:", errors); + console.warn('Image upload errors:', errors); } if (newImages.length > 0) { @@ -192,7 +186,16 @@ export function DescriptionImageDropZone({ setIsProcessing(false); }, - [disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp] + [ + disabled, + isProcessing, + images, + maxFiles, + maxFileSize, + onImagesChange, + previewImages, + saveImageToTemp, + ] ); const handleDrop = useCallback( @@ -236,7 +239,7 @@ export function DescriptionImageDropZone({ } // Reset the input so the same file can be selected again if (fileInputRef.current) { - fileInputRef.current.value = ""; + fileInputRef.current.value = ''; } }, [processFiles] @@ -276,17 +279,15 @@ export function DescriptionImageDropZone({ const item = clipboardItems[i]; // Check if the item is an image - if (item.type.startsWith("image/")) { + if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { // Generate a filename for pasted images since they don't have one - const extension = item.type.split("/")[1] || "png"; - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const renamedFile = new File( - [file], - `pasted-image-${timestamp}.${extension}`, - { type: file.type } - ); + const extension = item.type.split('/')[1] || 'png'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const renamedFile = new File([file], `pasted-image-${timestamp}.${extension}`, { + type: file.type, + }); imageFiles.push(renamedFile); } } @@ -307,13 +308,13 @@ export function DescriptionImageDropZone({ ); return ( -
+
{/* Hidden file input */} {/* Drag overlay */} {isDragOver && !disabled && ( @@ -355,17 +352,14 @@ export function DescriptionImageDropZone({ disabled={disabled} autoFocus={autoFocus} aria-invalid={error} - className={cn( - "min-h-[120px]", - isProcessing && "opacity-50 pointer-events-none" - )} + className={cn('min-h-[120px]', isProcessing && 'opacity-50 pointer-events-none')} data-testid="feature-description-input" />
{/* Hint text */}

- Paste, drag and drop images, or{" "} + Paste, drag and drop images, or{' '} {" "} + {' '} to attach context images

@@ -390,7 +384,7 @@ export function DescriptionImageDropZone({

- {images.length} image{images.length > 1 ? "s" : ""} attached + {images.length} image{images.length > 1 ? 's' : ''} attached

))} diff --git a/apps/ui/src/components/ui/feature-image-upload.tsx b/apps/ui/src/components/ui/feature-image-upload.tsx index a16dfcb6..0cb5403c 100644 --- a/apps/ui/src/components/ui/feature-image-upload.tsx +++ b/apps/ui/src/components/ui/feature-image-upload.tsx @@ -1,7 +1,6 @@ - -import React, { useState, useRef, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { ImageIcon, X, Upload } from "lucide-react"; +import React, { useState, useRef, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ImageIcon, X, Upload } from 'lucide-react'; export interface FeatureImage { id: string; @@ -20,13 +19,7 @@ interface FeatureImageUploadProps { disabled?: boolean; } -const ACCEPTED_IMAGE_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", -]; +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 FeatureImageUpload({ @@ -45,13 +38,13 @@ export function FeatureImageUpload({ return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { - if (typeof reader.result === "string") { + if (typeof reader.result === 'string') { resolve(reader.result); } else { - reject(new Error("Failed to read file as base64")); + reject(new Error('Failed to read file as base64')); } }; - reader.onerror = () => reject(new Error("Failed to read file")); + reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsDataURL(file); }); }; @@ -67,18 +60,14 @@ export function FeatureImageUpload({ 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.` - ); + 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.` - ); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); continue; } @@ -98,13 +87,13 @@ export function FeatureImageUpload({ size: file.size, }; newImages.push(imageAttachment); - } catch (error) { + } catch { errors.push(`${file.name}: Failed to process image.`); } } if (errors.length > 0) { - console.warn("Image upload errors:", errors); + console.warn('Image upload errors:', errors); } if (newImages.length > 0) { @@ -157,7 +146,7 @@ export function FeatureImageUpload({ } // Reset the input so the same file can be selected again if (fileInputRef.current) { - fileInputRef.current.value = ""; + fileInputRef.current.value = ''; } }, [processFiles] @@ -180,22 +169,14 @@ export function FeatureImageUpload({ onImagesChange([]); }, [onImagesChange]); - const 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]; - }; - return ( -
+
{/* Hidden file input */}
{isProcessing ? ( @@ -237,13 +215,10 @@ export function FeatureImageUpload({ )}

- {isDragOver && !disabled - ? "Drop images here" - : "Click or drag images here"} + {isDragOver && !disabled ? 'Drop images here' : 'Click or drag images here'}

- Up to {maxFiles} images, max{" "} - {Math.round(maxFileSize / (1024 * 1024))}MB each + Up to {maxFiles} images, max {Math.round(maxFileSize / (1024 * 1024))}MB each

@@ -253,7 +228,7 @@ export function FeatureImageUpload({

- {images.length} image{images.length > 1 ? "s" : ""} selected + {images.length} image{images.length > 1 ? 's' : ''} selected

))} diff --git a/apps/ui/src/components/ui/image-drop-zone.tsx b/apps/ui/src/components/ui/image-drop-zone.tsx index 5494bdc3..04e53491 100644 --- a/apps/ui/src/components/ui/image-drop-zone.tsx +++ b/apps/ui/src/components/ui/image-drop-zone.tsx @@ -1,8 +1,7 @@ - -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"; +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; @@ -35,88 +34,100 @@ export function ImageDropZone({ 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 updateImages = useCallback( + (newImages: ImageAttachment[]) => { + if (images === undefined) { + setInternalImages(newImages); + } + onImagesSelected(newImages); + }, + [images, onImagesSelected] + ); - const processFiles = useCallback(async (files: FileList) => { - if (disabled || isProcessing) return; + const processFiles = useCallback( + async (files: FileList) => { + if (disabled || isProcessing) return; - setIsProcessing(true); - const newImages: ImageAttachment[] = []; - const errors: string[] = []; + 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; + 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 { + errors.push(`${file.name}: Failed to process image.`); + } } - // 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; + if (errors.length > 0) { + console.warn('Image upload errors:', errors); + // You could show these errors to the user via a toast or notification } - // Check if we've reached max files - if (newImages.length + selectedImages.length >= maxFiles) { - errors.push(`Maximum ${maxFiles} images allowed.`); - break; + if (newImages.length > 0) { + const allImages = [...selectedImages, ...newImages]; + updateImages(allImages); } - 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.`); + 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] + ); - 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 handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) { + setIsDragOver(true); + } + }, + [disabled] + ); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -124,16 +135,19 @@ export function ImageDropZone({ 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 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) { @@ -141,17 +155,20 @@ export function ImageDropZone({ } }, [disabled]); - const removeImage = useCallback((imageId: string) => { - const updated = selectedImages.filter(img => img.id !== imageId); - updateImages(updated); - }, [selectedImages, updateImages]); + const removeImage = useCallback( + (imageId: string) => { + const updated = selectedImages.filter((img) => img.id !== imageId); + updateImages(updated); + }, + [selectedImages, updateImages] + ); const clearAllImages = useCallback(() => { updateImages([]); }, [updateImages]); return ( -
+
{/* Hidden file input */} {children || (
-
+
{isProcessing ? ( ) : ( @@ -191,10 +208,13 @@ export function ImageDropZone({ )}

- {isDragOver && !disabled ? "Drop your images here" : "Drag images here or click to browse"} + {isDragOver && !disabled + ? 'Drop your images here' + : 'Drag images here or click to browse'}

- {maxFiles > 1 ? `Up to ${maxFiles} images` : "1 image"}, max {Math.round(maxFileSize / (1024 * 1024))}MB each + {maxFiles > 1 ? `Up to ${maxFiles} images` : '1 image'}, max{' '} + {Math.round(maxFileSize / (1024 * 1024))}MB each

{!disabled && (