From 30aa7375aab2f4270a9cffc54c554fc336382f6b Mon Sep 17 00:00:00 2001 From: amemya Date: Sun, 14 Jun 2026 06:58:46 +0900 Subject: [PATCH] feat: Implement folder batch import and filmstrip UI - Unified 'Open Photos' and 'Open Folder' into a single OS dialog allowing both file and directory selection - Implemented filmstrip UI for batch image navigation - Extracted 'ensureValidExtension' helper in Go backend to safely validate and append extensions for batch exports - Fixed critical UI freeze bug caused by 'isSelectingRef' leaking on dialog cancellation - Corrected 'ExifResult' TypeScript interface to strictly match the flat JSON structure emitted by the Go backend - Implemented robust error skipping for 'filepath.Walk' to prevent a single unreadable file from aborting the entire import process - Cleaned up unused state ('openMenuVisible') and resolved variable shadowing in map callbacks - Removed deprecated click-to-open logic from the empty state in favor of clear action buttons --- app.go | 141 +++++- frontend/src/App.css | 157 +++++- frontend/src/App.tsx | 460 +++++++++++++----- .../src/components/MetadataSettingsPanel.tsx | 19 +- frontend/src/types.ts | 33 ++ 5 files changed, 662 insertions(+), 148 deletions(-) diff --git a/app.go b/app.go index 96a3a23..cb0830f 100644 --- a/app.go +++ b/app.go @@ -116,6 +116,92 @@ func (a *App) OpenImage() ExifResult { return a.ProcessImageFile(filePath) } +// OpenImages opens a native file dialog for multiple files or directories, reads EXIF metadata, and returns +// a list of HTTP URLs and metadata for the frontend. +func (a *App) OpenImages() []ExifResult { + filePaths, err := application.Get().Dialog.OpenFile(). + SetTitle("Select Photos or Folders"). + AddFilter("Images", "*.jpg;*.jpeg;*.png"). + CanChooseDirectories(true). + CanChooseFiles(true). + PromptForMultipleSelection() + if err != nil { + return []ExifResult{{Error: err.Error()}} + } + if len(filePaths) == 0 { + return []ExifResult{{Cancelled: true}} + } + + return a.ProcessPaths(filePaths) +} + +// OpenFolder opens a native directory dialog and processes all valid images within. +func (a *App) OpenFolder() []ExifResult { + folderPath, err := application.Get().Dialog.OpenFile(). + SetTitle("Select Folder"). + CanChooseDirectories(true). + CanChooseFiles(false). + PromptForSingleSelection() + if err != nil { + return []ExifResult{{Error: err.Error()}} + } + if folderPath == "" { + return []ExifResult{{Cancelled: true}} // user cancelled + } + + return a.ProcessPaths([]string{folderPath}) +} + +// ProcessPaths recursively walks provided paths (or single files) and processes valid images. +func (a *App) ProcessPaths(paths []string) []ExifResult { + var results []ExifResult + var validPaths []string + + for _, p := range paths { + info, err := os.Stat(p) + if err != nil { + results = append(results, ExifResult{Error: "Failed to access path: " + err.Error(), FilePath: p}) + continue + } + + if info.IsDir() { + err = filepath.Walk(p, func(path string, info os.FileInfo, err error) error { + if err != nil { + log.Printf("Error accessing path %s: %v", path, err) + return nil // Skip this file/folder but continue walking + } + if !info.IsDir() { + lower := strings.ToLower(path) + if strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".png") { + validPaths = append(validPaths, path) + } + } + return nil + }) + if err != nil { + results = append(results, ExifResult{Error: "Failed to read directory: " + err.Error(), FilePath: p}) + } + } else { + validPaths = append(validPaths, p) + } + } + + for _, path := range validPaths { + res := a.ProcessImageFile(path) + if res.Error != "" { + log.Printf("Skipped file %s: %v", path, res.Error) + continue + } + results = append(results, res) + } + + if len(results) == 0 { + return []ExifResult{{Error: "No valid images found in the selected paths."}} + } + + return results +} + // ProcessImageFile reads a file, validates it, and extracts EXIF func (a *App) ProcessImageFile(filePath string) ExifResult { f, err := os.Open(filePath) @@ -332,21 +418,9 @@ func (a *App) SaveImage(isPng bool, defaultName string) SaveResult { return SaveResult{Cancelled: true} } - ext := strings.ToLower(filepath.Ext(savePath)) - if ext == "" { - // User omitted extension, append the correct one - if isPng { - savePath += ".png" - } else { - savePath += ".jpg" - } - } else { - // User provided an extension, make sure it matches the output format - if isPng && ext != ".png" { - return SaveResult{Error: "Invalid extension. Please save as .png"} - } else if !isPng && ext != ".jpg" && ext != ".jpeg" { - return SaveResult{Error: "Invalid extension. Please save as .jpg or .jpeg"} - } + savePath, err = ensureValidExtension(savePath, isPng) + if err != nil { + return SaveResult{Error: err.Error()} } // Signal the HTTP handler that a save path is ready. @@ -413,6 +487,25 @@ func (a *App) SaveAutoImage(isPng bool, savePath string) SaveResult { return SaveResult{SaveToken: token} } +// SaveBatchImage bypasses ExportFolder validation for explicit batch exports. +func (a *App) SaveBatchImage(isPng bool, exportDir string, exportName string) SaveResult { + savePath := filepath.Join(exportDir, exportName) + savePath, err := ensureValidExtension(savePath, isPng) + if err != nil { + return SaveResult{Error: err.Error()} + } + + expectedMime := "image/jpeg" + if isPng { + expectedMime = "image/png" + } + if a.handler == nil { + return SaveResult{Error: "Internal error: image handler not initialized"} + } + token := a.handler.prepareSave(savePath, expectedMime) + return SaveResult{SaveToken: token} +} + // SelectWatchFolder opens a directory dialog to pick a watch folder func (a *App) SelectWatchFolder() string { path, err := application.Get().Dialog.OpenFile(). @@ -457,6 +550,24 @@ func formatAperture(num, den int64) string { return fmt.Sprintf("f/%.1f", val) } +// ensureValidExtension checks the file path and appends or validates the required extension. +func ensureValidExtension(savePath string, isPng bool) (string, error) { + ext := strings.ToLower(filepath.Ext(savePath)) + if ext == "" { + if isPng { + return savePath + ".png", nil + } + return savePath + ".jpg", nil + } + + if isPng && ext != ".png" { + return "", fmt.Errorf("Invalid extension. Please save as .png") + } else if !isPng && ext != ".jpg" && ext != ".jpeg" { + return "", fmt.Errorf("Invalid extension. Please save as .jpg or .jpeg") + } + return savePath, nil +} + func gcd(a, b int64) int64 { if a < 0 { a = -a diff --git a/frontend/src/App.css b/frontend/src/App.css index dcc765c..c67260c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -149,6 +149,65 @@ body { color: #fff; } +/* Button Groups & Dropdowns */ +.btn-group { + display: flex; + align-items: stretch; +} + +.btn-group .btn:not(:last-child), +.btn-group .dropdown:not(:last-child) .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 1px solid rgba(255, 255, 255, 0.1); +} + +.btn-group .btn:not(:first-child), +.btn-group .dropdown:not(:first-child) .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.dropdown { + position: relative; + display: flex; +} + +.dropdown-menu { + position: absolute; + right: 0; + top: 100%; + margin-top: 0.25rem; + background-color: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + z-index: 1000; + min-width: 140px; + padding: 0.25rem 0; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 0.5rem 1rem; + text-align: left; + background: none; + border: none; + color: var(--text-primary); + cursor: pointer; + font-size: 0.85rem; + transition: background-color 0.2s, color 0.2s; +} + +.dropdown-item:hover, .dropdown-item:focus { + background-color: var(--accent-color); + color: white; + outline: none; +} + /* Main Workspace */ .workspace { display: flex; @@ -160,6 +219,7 @@ body { .preview-area { flex: 1; display: flex; + flex-direction: column; justify-content: center; align-items: center; background-color: var(--bg-workspace); @@ -211,7 +271,8 @@ body { justify-content: center; align-items: center; width: 100%; - height: 100%; + flex: 1; + min-height: 0; } .preview-canvas { @@ -220,6 +281,42 @@ body { object-fit: contain; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); background: #fff; + opacity: 1; + transition: opacity 0.1s ease; /* fast restore */ +} + +.preview-canvas.loading { + opacity: 0.6; + transition: opacity 0.2s ease 0.1s; /* wait 100ms before dimming */ +} + +.hidden-canvas { + opacity: 0 !important; + box-shadow: none !important; + visibility: hidden; +} + +/* Delayed Spinner Overlay */ +.loading-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease 0.15s; /* wait 150ms before showing spinner */ + display: flex; + flex-direction: column; + align-items: center; + color: var(--text-primary); + background-color: rgba(0,0,0,0.5); + padding: 1.5rem; + border-radius: 12px; + backdrop-filter: blur(4px); +} + +.loading-overlay.visible { + opacity: 1; } /* Drag and Drop Overlay */ @@ -254,6 +351,64 @@ body { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } +.filmstrip-area { + display: flex; + gap: 0.5rem; + padding: 1.5rem 0 1rem 0; /* Added bottom padding to prevent scrollbar overlap */ + margin-top: 1.5rem; + width: 100%; + overflow-x: auto; + background-color: transparent; + border-top: 1px solid var(--border-color); + box-sizing: border-box; +} + +/* Custom thin scrollbar for filmstrip */ +.filmstrip-area::-webkit-scrollbar { + height: 8px; +} +.filmstrip-area::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} +.filmstrip-area::-webkit-scrollbar-track { + background: transparent; +} + +.filmstrip-item { + height: 60px; + width: 80px; + flex-shrink: 0; + border: 2px solid transparent; + border-radius: 4px; + overflow: hidden; + cursor: pointer; + transition: border-color 0.2s ease, opacity 0.2s ease; + opacity: 0.6; + background-color: transparent; + display: flex; + align-items: center; + justify-content: center; +} + +.filmstrip-item:hover { + opacity: 0.8; +} + +.filmstrip-item.selected { + border-color: var(--accent-color); + opacity: 1; +} + +.filmstrip-item img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + -webkit-user-drag: none; + user-select: none; +} + /* Sidebar */ .sidebar { width: 320px; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 371f990..0c9efdf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,7 @@ interface UpdateInfo { releaseNotes: string; url: string; } -import { ExifData, MetadataVisibility, toVisibility, applyVisibility } from './types'; +import { ExifData, MetadataVisibility, toVisibility, applyVisibility, ImportedImage, ExifResult } from './types'; import { FrameSettingsPanel } from './components/FrameSettingsPanel'; import { MetadataSettingsPanel } from './components/MetadataSettingsPanel'; @@ -286,20 +286,31 @@ function App() { }, []); const canvasRef = useRef(null); - const [imageLoaded, setImageLoaded] = useState(false); - const [exif, setExif] = useState({ - camera: "", - lens: "", - focalLength: "", - aperture: "", - shutterSpeed: "", - iso: "", - film: "", - developer: "", - dilution: "", - temperature: "", - time: "" - }); + const [importedImages, setImportedImages] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + + const currentImage = importedImages[selectedIndex]; + const hasImages = importedImages.length > 0; + const isCurrentImageLoaded = currentImage?.imageObj !== null; + const imageLoaded = hasImages && isCurrentImageLoaded; + const imageObj = currentImage?.imageObj || null; + const filePath = currentImage?.filePath || ""; + const sourceMimeType = currentImage?.sourceMimeType || ""; + const exif = currentImage?.exif || { + camera: "", lens: "", focalLength: "", aperture: "", shutterSpeed: "", iso: "", film: "", developer: "", dilution: "", temperature: "", time: "" + }; + + const setExif: React.Dispatch> = useCallback((action) => { + setImportedImages(prev => { + if (prev.length === 0) return prev; + const newImages = [...prev]; + const current = newImages[selectedIndex]; + if (!current) return prev; + const updatedExif = typeof action === 'function' ? action(current.exif) : action; + newImages[selectedIndex] = { ...current, exif: updatedExif }; + return newImages; + }); + }, [selectedIndex]); const [visibility, setVisibility] = useState({ camera: true, @@ -315,17 +326,20 @@ function App() { time: true }); - const [imageObj, setImageObj] = useState(null); const [isSelecting, setIsSelecting] = useState(false); const isSelectingRef = useRef(false); - const [filePath, setFilePath] = useState(""); const isInitialLoad = useRef(true); const [isMac, setIsMac] = useState(false); - const [sourceMimeType, setSourceMimeType] = useState(""); const [toastMessage, setToastMessage] = useState(null); const toastTimerRef = useRef(null); const toastRafRef = useRef(null); const [updateInfo, setUpdateInfo] = useState(null); + + // Dropdown states + const [exportMenuVisible, setExportMenuVisible] = useState(false); + + // Canvas state + const [isCanvasReady, setIsCanvasReady] = useState(false); // Toast cleanup useEffect(() => { @@ -368,48 +382,85 @@ function App() { }); }, []); - const handleExifResult = useCallback(async (result: any) => { - if (result.cancelled) return; - if (result.error) { - console.error(result.error); - showToast(result.error); - return; - } - if (!result.imageURL) { - console.error("Server returned an empty image URL"); - showToast("Server returned an empty image URL"); - return; - } - - await new Promise((resolve, reject) => { + useEffect(() => { + const current = importedImages[selectedIndex]; + if (current && !current.imageObj && !current.loadError) { const img = new Image(); img.onload = () => { - setExif(prev => ({ - ...prev, - camera: result.camera || "", - lens: result.lens || "", - focalLength: result.focalLength || "", - aperture: result.aperture || "", - shutterSpeed: result.shutterSpeed || "", - iso: result.iso || "" - })); - setFilePath(result.filePath || ""); - setSourceMimeType(result.mimeType || ""); - - setImageObj(img); + setImportedImages(prev => { + const newImages = [...prev]; + // Make sure the image we loaded is still at this index (or find by url if order changed, but we only append for now) + if (newImages[selectedIndex] && newImages[selectedIndex].imageURL === current.imageURL) { + newImages[selectedIndex] = { ...current, imageObj: img }; + } else { + // Fallback: find it + const idx = newImages.findIndex(item => item.imageURL === current.imageURL); + if (idx !== -1) newImages[idx] = { ...current, imageObj: img }; + } + return newImages; + }); setOrientation(img.height > img.width ? "portrait" : "landscape"); - setImageLoaded(true); - resolve(); }; img.onerror = () => { - console.error("Failed to load image"); showToast("Failed to load image preview"); - reject(new Error("Failed to load image")); + setImportedImages(prev => { + const newImages = [...prev]; + const idx = newImages.findIndex(item => item.imageURL === current.imageURL); + if (idx !== -1) newImages[idx] = { ...current, loadError: true }; + return newImages; + }); }; - img.src = result.imageURL; + img.src = current.imageURL; + } else if (current && current.imageObj) { + setOrientation(current.imageObj.height > current.imageObj.width ? "portrait" : "landscape"); + } + }, [selectedIndex, importedImages, showToast]); + + const handleExifResults = useCallback((results: ExifResult[]) => { + const validResults = results.filter(r => !r.cancelled && !r.error && r.imageURL); + if (validResults.length === 0) { + const firstError = results.find(r => r.error); + if (firstError && firstError.error) { + console.error(firstError.error); + showToast(firstError.error); + } + return; + } + + setImportedImages(() => { + const newImages: ImportedImage[] = validResults.map(r => ({ + filePath: r.filePath || "", + imageURL: r.imageURL!, + sourceMimeType: (r.mimeType as 'image/jpeg' | 'image/png') || 'image/jpeg', + imageObj: null, + exif: { + camera: r.camera || "", + lens: r.lens || "", + focalLength: r.focalLength || "", + aperture: r.aperture || "", + shutterSpeed: r.shutterSpeed || "", + iso: r.iso || "", + film: "", + developer: "", + dilution: "", + temperature: "", + time: "", + } + })); + return newImages; }); + setSelectedIndex(0); + setIsCanvasReady(false); }, [showToast]); + const handleApplyToAll = useCallback(() => { + if (importedImages.length === 0) return; + setImportedImages(prev => prev.map(img => ({ + ...img, + exif: { ...exif } // copy + }))); + showToast("Applied metadata to all images"); + }, [exif, importedImages.length, showToast]); useEffect(() => { // Check for updates @@ -477,28 +528,18 @@ function App() { } if (files.length > 0) { - if (files.length > 1) { - showToast("Please drop only one file at a time."); - return; - } - const filePath = files[0]; - const lower = filePath.toLowerCase(); - if (lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png")) { - if (isSelectingRef.current) return; - isSelectingRef.current = true; - setIsSelecting(true); - try { - const result = await AppAPI.ProcessImageFile(filePath); - await handleExifResult(result); - } catch (err: any) { - console.error("Failed to process dropped file:", err); - showToast("Failed to process file: " + (err instanceof Error ? err.message : String(err))); - } finally { - setIsSelecting(false); - isSelectingRef.current = false; - } - } else { - showToast("Invalid file: only JPG and PNG are supported."); + if (isSelectingRef.current) return; + isSelectingRef.current = true; + setIsSelecting(true); + try { + const results = await AppAPI.ProcessPaths(files); + handleExifResults(results); + } catch (err: any) { + console.error("Failed to process dropped files:", err); + showToast("Failed to process files: " + (err instanceof Error ? err.message : String(err))); + } finally { + setIsSelecting(false); + isSelectingRef.current = false; } } }); @@ -507,7 +548,7 @@ function App() { unsubSettings(); offFilesDropped(); }; - }, [handleExifResult]); + }, [handleExifResults]); const handleSaveAutoExportDefault = async () => { const s = new Settings(); s.watchFolder = watchFolder; @@ -556,25 +597,43 @@ function App() { } }; - useEffect(() => { - setIsMac(System.IsMac()); - }, []); - - const handleSelectImage = async () => { + const handleAddFiles = async () => { if (isSelectingRef.current) return; isSelectingRef.current = true; setIsSelecting(true); try { - const result = await AppAPI.OpenImage(); - await handleExifResult(result); - } catch (err) { - console.error("Failed to open image:", err); + const results = await AppAPI.OpenImages(); + handleExifResults(results); + } catch (err: any) { + console.error("Failed to open images:", err); + showToast("Failed to open images: " + (err instanceof Error ? err.message : String(err))); } finally { + setIsSelecting(false); isSelectingRef.current = false; + } + }; + + const handleAddFolder = async () => { + if (isSelectingRef.current) return; + isSelectingRef.current = true; + setIsSelecting(true); + try { + const results = await AppAPI.OpenFolder(); + handleExifResults(results); + } catch (err: any) { + console.error("Failed to open folder:", err); + showToast("Failed to open folder: " + (err instanceof Error ? err.message : String(err))); + } finally { setIsSelecting(false); + isSelectingRef.current = false; } }; + useEffect(() => { + setIsMac(System.IsMac()); + }, []); + + const drawCanvas = useCallback((img: HTMLImageElement) => { const canvas = canvasRef.current; if (!canvas) return; @@ -588,12 +647,17 @@ function App() { profile, visibility }); + setIsCanvasReady(true); }, [exif, aspectRatioPreset, customRatioW, customRatioH, orientation, alignment, showPipeSeparator, profile, visibility]); useEffect(() => { - if (!imageObj || !canvasRef.current) return; + if (!canvasRef.current) return; - drawCanvas(imageObj); + if (imageObj) { + drawCanvas(imageObj); + } + // If imageObj is null, we do NOTHING. + // The canvas natively holds its previous pixels, acting as a seamless placeholder. }, [imageObj, drawCanvas]); const downloadImage = async () => { @@ -622,34 +686,129 @@ function App() { return; } - // Step 2: Convert canvas to binary Blob (no Base64 intermediate) const blob = await new Promise((resolve, reject) => { canvasRef.current!.toBlob( (b) => b ? resolve(b) : reject(new Error("toBlob returned null")), targetMime, - 1.0 // For JPEG: highest quality. PNG ignores this. + 1.0 ); }); - // Step 3: Send binary directly to Go HTTP handler with save token. - // WARNING: On macOS WebKit, using a Blob body with a custom URL scheme (wails://) - // often results in an empty payload (0kb file). We MUST convert it to an ArrayBuffer first. - const arrayBuffer = await blob.arrayBuffer(); - const response = await fetch(`/api/save?token=${encodeURIComponent(result.saveToken)}`, { + const response = await fetch(`/api/save?token=${result.saveToken}`, { method: 'POST', - headers: { 'Content-Type': targetMime }, - body: arrayBuffer, + body: blob, + headers: { 'Content-Type': targetMime } }); if (!response.ok) { - const errText = await response.text(); - console.error("Save failed:", errText); - alert("Failed to save image: " + errText); - } else { - showToast("Image saved successfully"); + const text = await response.text(); + console.error("HTTP POST failed for", exportName, text); + alert("Failed to save image via HTTP POST: " + text); } + showToast("Export complete!"); } catch (err) { console.error("Failed to execute SaveImage:", err); + showToast("Failed to save image"); + } + }; + + const downloadAllImages = async () => { + if (importedImages.length === 0) return; + isSelectingRef.current = true; + let successCount = 0; + let failCount = 0; + + try { + const exportDir = await AppAPI.SelectExportFolder(); + if (!exportDir) { + setIsSelecting(false); + isSelectingRef.current = false; + return; // Cancelled + } + + showToast("Exporting images..."); + setIsSelecting(true); + + for (let i = 0; i < importedImages.length; i++) { + const imgState = importedImages[i]; + let imgToDraw = imgState.imageObj; + + if (!imgToDraw) { + try { + imgToDraw = await new Promise((resolve, reject) => { + const tempImg = new Image(); + tempImg.onload = () => resolve(tempImg); + tempImg.onerror = () => reject(new Error("Failed to load image")); + tempImg.src = imgState.imageURL; + }); + } catch (e) { + console.error("Failed to load image for export:", e); + failCount++; + continue; + } + } + + const offCanvas = document.createElement("canvas"); + renderImageToCanvas(offCanvas, imgToDraw, imgState.exif, { + aspectRatioPreset, + customRatioW, + customRatioH, + orientation: imgToDraw.height > imgToDraw.width ? "portrait" : "landscape", + alignment, + showPipeSeparator, + profile, + visibility + }); + + const isPng = imgState.sourceMimeType === 'image/png'; + const targetMime = isPng ? 'image/png' : 'image/jpeg'; + + const filenameMatch = imgState.filePath ? imgState.filePath.split(/[/\\]/).pop() : ""; + const baseName = (filenameMatch ? filenameMatch.replace(/\.[^/.]+$/, "") : "") || `exif-frame-${i}`; + const exportName = `${baseName}_ExifFrame`; + + const result = await AppAPI.SaveBatchImage(isPng, exportDir, exportName); + if (result.error) { + console.error("Export failed for", exportName, result.error); + failCount++; + continue; + } + + const blob = await new Promise((resolve, reject) => { + offCanvas.toBlob( + (b) => b ? resolve(b) : reject(new Error("toBlob returned null")), + targetMime, + 1.0 + ); + }); + + const response = await fetch(`/api/save?token=${result.saveToken}`, { + method: 'POST', + body: blob, + headers: { 'Content-Type': targetMime } + }); + + if (!response.ok) { + const text = await response.text(); + console.error("Save failed:", text); + failCount++; + continue; + } + + successCount++; + } + + if (failCount > 0) { + showToast(`Export complete: ${successCount} succeeded, ${failCount} failed.`); + } else { + showToast(`Successfully exported all ${successCount} images.`); + } + } catch (err: any) { + console.error("Failed to export all:", err); + showToast("Failed to export all: " + err.message); + } finally { + setIsSelecting(false); + isSelectingRef.current = false; } }; @@ -685,24 +844,45 @@ function App() { > - - {imageLoaded && ( - + {hasImages && ( +
+ + {importedImages.length > 1 && ( +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setExportMenuVisible(false); + } + }} + > + + {exportMenuVisible && ( +
+ +
+ )} +
+ )} +
)}
- {/* - Note: The data-file-drop-target="true" attribute is required by the Wails runtime - to detect native drag-and-drop targets. During a drag operation, Wails will automatically - add the .file-drop-target-active class to this element, which we use in CSS to display - the .drop-overlay. - */}
Drop image here
- {!imageLoaded ? ( + {!hasImages ? (
{ if (isSelecting) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); // Prevent page scroll for Space - handleSelectImage(); + handleAddFiles(); } }} > {isSelecting ? ( <> -

Opening photo...

+

Processing images...

) : ( <> -

Click or drag a photo here

+

Drop files/folders here or click to open

)}
) : ( -
- +
+ +
+ + Loading... +
+
+ )} + + {importedImages.length > 0 && ( +
+ {importedImages.map((img, idx) => ( +
setSelectedIndex(idx)} + > + {`Thumbnail +
+ ))}
)}
- {imageLoaded && ( + {hasImages && (