tauri - lightbox prompt | file history | store | icons

This commit is contained in:
lovebird 2025-09-21 07:14:23 +02:00
parent 6eda5f2796
commit e6bf6e6f21
29 changed files with 579 additions and 105 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 KiB

After

Width:  |  Height:  |  Size: 1024 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 KiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@ -31,6 +31,7 @@
"@tauri-apps/plugin-store": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.9.0",
"@tauri-apps/plugin-upload": "^2.3.0",
"lucide-react": "^0.544.0",
"mime-types": "^2.1.35",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@ -2473,6 +2474,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.544.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz",
"integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",

View File

@ -34,6 +34,7 @@
"@tauri-apps/plugin-store": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.9.0",
"@tauri-apps/plugin-upload": "^2.3.0",
"lucide-react": "^0.544.0",
"mime-types": "^2.1.35",
"react": "^19.1.0",
"react-dom": "^19.1.0",

View File

@ -18,9 +18,6 @@ pub fn start_stdin_listener(app_handle: tauri::AppHandle) {
// Parse command from images.ts
if let Ok(command) = serde_json::from_str::<serde_json::Value>(&line_content) {
if let Some(cmd) = command.get("cmd").and_then(|v| v.as_str()) {
log_json("info", "Processing command", Some(serde_json::json!({
"command": cmd
})));
match cmd {
"forward_config_to_frontend" => {
@ -132,12 +129,6 @@ fn handle_image_forward(command: &serde_json::Value, app_handle: &tauri::AppHand
command.get("base64").and_then(|v| v.as_str()),
command.get("mimeType").and_then(|v| v.as_str())
) {
log_json("info", "Forwarding image to frontend", Some(serde_json::json!({
"filename": filename,
"mime_type": mime_type,
"base64_size": base64.len()
})));
let image_data = serde_json::json!({
"base64": base64,
"mimeType": mime_type,
@ -150,9 +141,7 @@ fn handle_image_forward(command: &serde_json::Value, app_handle: &tauri::AppHand
"filename": filename
})));
} else {
log_json("info", "Image emitted successfully", Some(serde_json::json!({
"filename": filename
})));
}
}
}

View File

@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { ImageFile, PromptTemplate } from "./types";
import { useTauriListeners } from "./hooks/useTauriListeners";
import { tauriApi } from "./lib/tauriApi";
import { saveStore } from "./lib/init";
import { saveToStore } from "./lib/init";
import { QUICK_STYLES, QUICK_ACTIONS } from "./constants";
import log from "./lib/log";
import Header from "./components/Header";
@ -46,6 +46,14 @@ function App() {
const [promptHistory, setPromptHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [fileHistory, setFileHistory] = useState<string[]>([]);
// Debug wrapper for setFileHistory
const setFileHistoryWithLogging = (history: string[]) => {
log.debug(`🔧 setFileHistory called with ${history.length} files`, {
files: history.map(f => f.split(/[/\\]/).pop())
});
setFileHistory(history);
};
const [showFileHistory, setShowFileHistory] = useState(false);
@ -80,7 +88,7 @@ function App() {
log.info(`🎨 Style replaced with: ${style}`);
};
const executeQuickAction = async (action: { name: string; prompt: string; icon: string }) => {
const executeQuickAction = async (action: { name: string; prompt: string; iconName: string }) => {
const selectedImages = getSelectedImages();
if (selectedImages.length === 0) {
@ -108,7 +116,7 @@ function App() {
// Auto-save to store
try {
await saveStore(prompts, newHistory);
await saveToStore({ history: newHistory });
log.info('💾 History saved to store');
} catch (error) {
log.error('Failed to save history', { error: (error as Error).message });
@ -138,6 +146,74 @@ function App() {
}
};
const addToFileHistory = async (filePath: string) => {
if (!filePath) return;
// Use functional update to get current state
setFileHistory(currentHistory => {
// Remove if already exists, then add to front, then limit to 8
const filteredHistory = currentHistory.filter(path => path !== filePath);
const newFileHistory = [filePath, ...filteredHistory].slice(0, 8);
log.info(`📁 Adding to file history: ${filePath.split(/[/\\]/).pop()}`, {
currentHistoryLength: currentHistory.length,
newHistoryLength: newFileHistory.length,
fullPath: filePath,
maxEntries: 8
});
// Auto-save to store
saveToStore({ fileHistory: newFileHistory }).then(() => {
log.info('💾 File history saved to store', {
savedCount: newFileHistory.length,
savedFiles: newFileHistory.map(f => f.split(/[/\\]/).pop())
});
}).catch(error => {
log.error('Failed to save file history', { error: error.message });
});
return newFileHistory;
});
};
const openFileFromHistory = async (filePath: string) => {
try {
if (await tauriApi.fs.readFile(filePath)) {
await addFiles([filePath]);
setShowFileHistory(false);
log.info(`📂 Reopened from history: ${filePath.split(/[/\\]/).pop()}`);
}
} catch (error) {
log.error(`File no longer exists: ${filePath.split(/[/\\]/).pop()}`);
// Remove from history if file doesn't exist
const updatedHistory = fileHistory.filter(f => f !== filePath);
setFileHistoryWithLogging(updatedHistory);
await saveToStore({ fileHistory: updatedHistory });
}
};
const onFileHistoryCleanup = async (validFiles: string[]) => {
setFileHistoryWithLogging(validFiles);
await saveToStore({ fileHistory: validFiles });
};
const handleLightboxPromptSubmit = async (promptText: string, imagePath: string) => {
// Set the prompt and select the image for editing
setPrompt(promptText);
// Find and select the image
setFiles(prev => prev.map(file => ({
...file,
selected: file.path === imagePath
})));
// Add to history and generate
await addToHistory(promptText);
await generateImage(promptText, [{ path: imagePath, src: '', isGenerated: false }]);
log.info(`🎨 Lightbox edit: "${promptText}" on ${imagePath.split(/[/\\]/).pop()}`);
};
const importPrompts = async () => {
try {
const selected = await tauriApi.dialog.open({
@ -270,6 +346,8 @@ function App() {
prompt,
setCurrentIndex,
setPromptHistory,
setFileHistory: setFileHistoryWithLogging,
addToFileHistory,
});
const addFiles = async (newPaths: string[]) => {
@ -552,7 +630,7 @@ function App() {
const savePrompts = async (promptsToSave: PromptTemplate[]) => {
try {
await saveStore(promptsToSave, promptHistory);
await saveToStore({ prompts: promptsToSave });
} catch (error) {
log.error('Failed to save prompts', {
error: (error as Error).message
@ -690,6 +768,12 @@ function App() {
promptHistory={promptHistory}
historyIndex={historyIndex}
navigateHistory={navigateHistory}
fileHistory={fileHistory}
showFileHistory={showFileHistory}
setShowFileHistory={setShowFileHistory}
openFileFromHistory={openFileFromHistory}
onFileHistoryCleanup={onFileHistoryCleanup}
onLightboxPromptSubmit={handleLightboxPromptSubmit}
/>
{/* Debug Panel */}

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { ImageFile } from '../types';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
import { ArrowUp } from 'lucide-react';
interface ImageGalleryProps {
images: ImageFile[];
@ -11,6 +12,12 @@ interface ImageGalleryProps {
showSelection?: boolean;
currentIndex: number;
setCurrentIndex: (index: number) => void;
onDoubleClick?: (imagePath: string) => void;
onLightboxPromptSubmit?: (prompt: string, imagePath: string) => void;
promptHistory?: string[];
historyIndex?: number;
navigateHistory?: (direction: 'up' | 'down') => void;
isGenerating?: boolean;
}
export default function ImageGallery({
@ -21,13 +28,27 @@ export default function ImageGallery({
onImageSaveAs,
showSelection = false,
currentIndex,
setCurrentIndex
setCurrentIndex,
onDoubleClick,
onLightboxPromptSubmit,
promptHistory = [],
historyIndex = -1,
navigateHistory,
isGenerating = false
}: ImageGalleryProps) {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxLoaded, setLightboxLoaded] = useState(false);
const [isPanning, setIsPanning] = useState(false);
const [lightboxPrompt, setLightboxPrompt] = useState('');
const panStartRef = useRef<{ x: number; y: number } | null>(null);
// Sync lightbox prompt with history navigation
useEffect(() => {
if (lightboxOpen && historyIndex >= 0 && historyIndex < promptHistory.length) {
setLightboxPrompt(promptHistory[historyIndex]);
}
}, [historyIndex, promptHistory, lightboxOpen]);
// Reset current index if images change, preventing out-of-bounds errors
useEffect(() => {
if (images.length > 0 && currentIndex >= images.length) {
@ -188,7 +209,13 @@ export default function ImageGallery({
type="button"
key={image.path}
onClick={(e) => handleThumbnailClick(e, image.path, index)}
onDoubleClick={() => openLightbox(index)}
onDoubleClick={() => {
if (onDoubleClick) {
onDoubleClick(image.path);
} else {
openLightbox(index);
}
}}
className={`group relative aspect-square rounded-lg overflow-hidden transition-all duration-300 border-2 ${
currentIndex === index
? 'ring-2 ring-orange-500 border-orange-500'
@ -373,10 +400,105 @@ export default function ImageGallery({
</button>
)}
{/* Lightbox Prompt Field */}
{lightboxLoaded && onLightboxPromptSubmit && (
<div className="absolute bottom-16 left-1/2 transform -translate-x-1/2 w-[80vw] max-w-4xl">
<div className="bg-black/80 backdrop-blur-sm rounded-xl p-4 shadow-2xl border border-white/20">
<div className="flex gap-3">
<div className="flex-1 relative">
<textarea
value={lightboxPrompt}
onChange={(e) => setLightboxPrompt(e.target.value)}
placeholder="Quick edit prompt..."
disabled={isGenerating}
rows={2}
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent backdrop-blur-sm disabled:opacity-50 resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && lightboxPrompt.trim() && !isGenerating) {
onLightboxPromptSubmit(lightboxPrompt, images[safeIndex].path);
setLightboxPrompt('');
// Keep lightbox open to show generation progress
} else if (e.key === 'Escape') {
setLightboxPrompt('');
} else if (e.ctrlKey && e.key === 'ArrowUp' && navigateHistory) {
e.preventDefault();
navigateHistory('up');
} else if (e.ctrlKey && e.key === 'ArrowDown' && navigateHistory) {
e.preventDefault();
navigateHistory('down');
}
}}
onClick={(e) => e.stopPropagation()}
/>
{/* History navigation buttons */}
{promptHistory.length > 0 && (
<div className="absolute right-2 top-2 flex gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigateHistory?.('up');
}}
disabled={isGenerating || historyIndex <= 0}
className="p-1 text-white/60 hover:text-white disabled:opacity-30 transition-colors"
title={`Previous prompt (${historyIndex + 1}/${promptHistory.length}) - Ctrl+↑`}
>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigateHistory?.('down');
}}
disabled={isGenerating}
className="p-1 text-white/60 hover:text-white disabled:opacity-30 transition-colors"
title={`Next prompt - Ctrl+↓`}
>
</button>
</div>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
if (lightboxPrompt.trim() && !isGenerating) {
onLightboxPromptSubmit(lightboxPrompt, images[safeIndex].path);
setLightboxPrompt('');
// Keep lightbox open to show generation progress
}
}}
disabled={!lightboxPrompt.trim() || isGenerating}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white p-2 rounded-lg transition-colors duration-200 disabled:opacity-50 flex items-center justify-center group relative self-start"
title="Generate (Enter)"
>
{isGenerating ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
) : (
<ArrowUp size={16} />
)}
{/* Hover tooltip */}
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-black text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap">
Generate
</div>
</button>
</div>
</div>
</div>
)}
{/* Info */}
{lightboxLoaded && (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black/75 text-white px-4 py-2 rounded-lg text-sm">
{images[safeIndex].path.split(/[/\\]/).pop()} {safeIndex + 1} of {images.length} Scroll to zoom Double-click to reset ESC to close
{isGenerating ? (
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-3 w-3 border-2 border-white border-t-transparent"></div>
Generating edit... ESC to close
</div>
) : (
`${images[safeIndex].path.split(/[/\\]/).pop()}${safeIndex + 1} of ${images.length} • Scroll to zoom • Double-click to reset • ESC to close`
)}
</div>
)}
</div>

View File

@ -1,9 +1,35 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { ImageFile, PromptTemplate } from '../types';
import { QUICK_ACTIONS } from '../constants';
import ImageGallery from './ImageGallery';
import { useDropZone } from '../hooks/useDropZone';
import PromptManager from './PromptManager';
import TemplateManager from './TemplateManager';
import { tauriApi } from '../lib/tauriApi';
import { saveToStore } from '../lib/init';
import log from '../lib/log';
import { Eraser, Sparkles, Crop, Palette, Package, FolderOpen, Plus, History, ChevronUp, ChevronDown } from 'lucide-react';
function arrayBufferToBase64(buffer: number[]) {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
function getActionIcon(iconName: string) {
const iconMap = {
'Eraser': Eraser,
'Sparkles': Sparkles,
'Crop': Crop,
'Palette': Palette,
'Package': Package
};
const IconComponent = iconMap[iconName as keyof typeof iconMap];
return IconComponent ? <IconComponent size={16} /> : <span>{iconName}</span>;
}
interface PromptFormProps {
prompt: string;
@ -34,10 +60,16 @@ interface PromptFormProps {
quickStyles: readonly string[];
appendStyle: (style: string) => void;
quickActions: typeof QUICK_ACTIONS;
executeQuickAction: (action: { name: string; prompt: string; icon: string }) => Promise<void>;
executeQuickAction: (action: { name: string; prompt: string; iconName: string }) => Promise<void>;
promptHistory: string[];
historyIndex: number;
navigateHistory: (direction: 'up' | 'down') => void;
fileHistory: string[];
showFileHistory: boolean;
setShowFileHistory: (show: boolean) => void;
openFileFromHistory: (filePath: string) => Promise<void>;
onFileHistoryCleanup: (validFiles: string[]) => Promise<void>;
onLightboxPromptSubmit: (prompt: string, imagePath: string) => Promise<void>;
}
const PromptForm: React.FC<PromptFormProps> = ({
@ -73,9 +105,72 @@ const PromptForm: React.FC<PromptFormProps> = ({
promptHistory,
historyIndex,
navigateHistory,
fileHistory,
showFileHistory,
setShowFileHistory,
openFileFromHistory,
onFileHistoryCleanup,
onLightboxPromptSubmit,
}) => {
const selectedCount = getSelectedImages().length;
const { ref: dropZoneRef, dragIn } = useDropZone({ onDrop: addFiles });
const [historyImages, setHistoryImages] = useState<ImageFile[]>([]);
const [historyCurrentIndex, setHistoryCurrentIndex] = useState(0);
// Load images for file history when modal opens
useEffect(() => {
if (showFileHistory && fileHistory.length > 0) {
const loadHistoryImages = async () => {
const imageFiles: ImageFile[] = [];
const validFiles: string[] = [];
for (const filePath of fileHistory) {
try {
// First check if file exists, then read it
log.debug(`Checking file: ${filePath}`);
const buffer = await tauriApi.fs.readFile(filePath);
// Use the same conversion method as main app
const base64 = arrayBufferToBase64(Array.from(buffer));
const ext = filePath.toLowerCase().split('.').pop();
const mimeType = ext === 'png' ? 'image/png' : 'image/jpeg';
const src = `data:${mimeType};base64,${base64}`;
imageFiles.push({
path: filePath,
src,
isGenerated: true,
selected: false
});
validFiles.push(filePath);
} catch (error) {
// File doesn't exist, skip it and log with full path
log.warn(`File not found in history: ${filePath}`, {
filename: filePath.split(/[/\\]/).pop(),
fullPath: filePath,
error: (error as Error).message
});
}
}
// If some files were invalid, update the fileHistory and store
if (validFiles.length !== fileHistory.length) {
log.info(`Cleaning up file history: ${fileHistory.length - validFiles.length} invalid files removed`);
try {
await onFileHistoryCleanup(validFiles);
log.info('💾 File history cleaned and saved');
} catch (cleanupError) {
log.error('Failed to cleanup file history', { error: (cleanupError as Error).message });
}
}
setHistoryImages(imageFiles);
setHistoryCurrentIndex(0);
};
loadHistoryImages();
} else if (showFileHistory) {
// No file history, show empty state immediately
setHistoryImages([]);
}
}, [showFileHistory, fileHistory]);
return (
<form
@ -147,7 +242,7 @@ const PromptForm: React.FC<PromptFormProps> = ({
className="glass-button px-3 py-3 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
title={`Previous prompt (${historyIndex + 1}/${promptHistory.length}) - Ctrl+↑`}
>
<ChevronUp size={16} />
</button>
<button
type="button"
@ -156,18 +251,19 @@ const PromptForm: React.FC<PromptFormProps> = ({
className="glass-button px-3 py-3 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
title={`Next prompt - Ctrl+↓`}
>
<ChevronDown size={16} />
</button>
</div>
)}
</div>
</div>
{/* Right column: Templates and Actions (1/3 width) */}
<div className="space-y-4">
{/* Quick Style Picker */}
{/* Right column: Quick Tools (1/3 width) */}
<div className="space-y-3">
{/* Quick Styles - Compact */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Quick Styles</h4>
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Styles</h4>
<div className="flex flex-wrap gap-1">
{quickStyles.map((style) => (
<button
@ -182,9 +278,9 @@ const PromptForm: React.FC<PromptFormProps> = ({
</div>
</div>
{/* Quick Actions */}
{/* Quick Actions - Icons Only */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Quick Actions</h4>
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Actions</h4>
<div className="flex flex-wrap gap-1">
{quickActions.map((action) => {
const hasSelectedImages = getSelectedImages().length > 0;
@ -195,16 +291,16 @@ const PromptForm: React.FC<PromptFormProps> = ({
type="button"
onClick={() => executeQuickAction(action)}
disabled={isGenerating || !hasSelectedImages}
className={`text-xs px-2 py-1 rounded transition-colors duration-200 ${
className={`text-lg px-2 py-2 rounded transition-colors duration-200 ${
!hasAnyImages
? 'bg-slate-50 text-slate-400 cursor-not-allowed'
: !hasSelectedImages
? 'bg-orange-50 text-orange-600 cursor-not-allowed'
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-700 dark:text-blue-300'
} ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
title={!hasAnyImages ? 'Add an image first' : !hasSelectedImages ? 'Select an image first' : `Apply ${action.name}`}
title={!hasAnyImages ? 'Add an image first' : !hasSelectedImages ? 'Select an image first' : action.name}
>
{action.icon} {action.name}
{getActionIcon(action.iconName)}
</button>
);
})}
@ -213,20 +309,18 @@ const PromptForm: React.FC<PromptFormProps> = ({
</div>
</div>
<div className="border border-slate-200/50 dark:border-slate-700/50 rounded-xl p-4 bg-slate-50/30 dark:bg-slate-800/30">
<PromptManager
prompts={prompts}
onSelectPrompt={setPrompt}
currentPrompt={prompt}
onSavePrompt={(name, text) => {
const newPrompts = [...prompts, { name, text }];
setPrompts(newPrompts);
savePrompts(newPrompts);
}}
onImportPrompts={importPrompts}
onExportPrompts={exportPrompts}
/>
</div>
<TemplateManager
prompts={prompts}
currentPrompt={prompt}
onSelectPrompt={setPrompt}
onSavePrompt={(name, text) => {
const newPrompts = [...prompts, { name, text }];
setPrompts(newPrompts);
savePrompts(newPrompts);
}}
onImportPrompts={importPrompts}
onExportPrompts={exportPrompts}
/>
{/* Two-column layout: Destination + Source */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -247,10 +341,11 @@ const PromptForm: React.FC<PromptFormProps> = ({
<button
type="button"
onClick={openSaveDialog}
className="glass-button font-semibold py-4 px-5 rounded-xl whitespace-nowrap"
className="glass-button font-semibold py-4 px-4 rounded-xl whitespace-nowrap flex items-center gap-2"
title="Browse for save location"
>
📁 Browse
<FolderOpen size={16} />
Browse
</button>
</div>
</div>
@ -263,24 +358,34 @@ const PromptForm: React.FC<PromptFormProps> = ({
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2 text-center">
Source Images
</label>
<div className="flex gap-3">
<div className="space-y-3">
<button
type="button"
onClick={openFilePicker}
className="w-full glass-button font-semibold py-4 px-6 rounded-xl hover:border-slate-400/60 dark:hover-border-slate-500/60"
className="w-full glass-button font-semibold py-4 px-6 rounded-xl hover:border-slate-400/60 dark:hover-border-slate-500/60 flex items-center justify-center gap-2"
>
📸 Select Images to Edit (or Drop Here)
</button>
<button
type="button"
onClick={() => addImageFromUrl('https://picsum.photos/640/640')}
className="glass-button font-semibold py-4 px-5 rounded-xl whitespace-nowrap"
title="Add random image from URL"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<FolderOpen size={16} />
Select Images to Edit (or Drop Here)
</button>
<div className="flex gap-2 justify-center">
<button
type="button"
onClick={() => addImageFromUrl('https://picsum.photos/640/640')}
className="glass-button py-2 px-3 rounded-lg"
title="Add random image from URL"
>
<Plus size={16} />
</button>
<button
type="button"
onClick={() => setShowFileHistory(true)}
disabled={fileHistory.length === 0}
className="glass-button py-2 px-3 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
title={fileHistory.length > 0 ? `Open from history (${fileHistory.length} files)` : 'No file history yet'}
>
<History size={16} />
</button>
</div>
</div>
</div>
</div>
@ -316,6 +421,11 @@ const PromptForm: React.FC<PromptFormProps> = ({
onImageDelete={onImageDelete}
currentIndex={currentIndex}
setCurrentIndex={setCurrentIndex}
onLightboxPromptSubmit={onLightboxPromptSubmit}
promptHistory={promptHistory}
historyIndex={historyIndex}
navigateHistory={navigateHistory}
isGenerating={isGenerating}
/>
</div>
</div>
@ -336,6 +446,56 @@ const PromptForm: React.FC<PromptFormProps> = ({
)}
</div>
</div>
{/* File History Modal */}
{showFileHistory && (
<div className="fixed inset-0 bg-black/50 z-[9999] flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-hidden">
<div className="flex justify-between items-center p-6 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">File History</h3>
<button
onClick={() => setShowFileHistory(false)}
className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 text-2xl"
>
×
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[60vh]">
{historyImages.length === 0 ? (
<div className="text-center text-slate-500 dark:text-slate-400 py-8">
{fileHistory.length === 0
? "No file history yet. Generated images will appear here."
: "Loading images from history..."
}
</div>
) : (
<ImageGallery
images={historyImages}
onImageSelection={(imagePath: string) => {
// Single click selects for preview
setHistoryImages(prev =>
prev.map(img => ({
...img,
selected: img.path === imagePath
}))
);
}}
onImageRemove={undefined}
onImageDelete={undefined}
onImageSaveAs={undefined}
showSelection={false}
currentIndex={historyCurrentIndex}
setCurrentIndex={setHistoryCurrentIndex}
onDoubleClick={(imagePath: string) => {
// Double click picks the image and closes modal
openFileFromHistory(imagePath);
}}
/>
)}
</div>
</div>
</div>
)}
</form>
);
};

View File

@ -0,0 +1,93 @@
import React from 'react';
import { PromptTemplate } from '../types';
import { Save, Download, Upload } from 'lucide-react';
interface TemplateManagerProps {
prompts: PromptTemplate[];
currentPrompt: string;
onSelectPrompt: (prompt: string) => void;
onSavePrompt: (name: string, text: string) => void;
onImportPrompts: () => void;
onExportPrompts: () => void;
}
const TemplateManager: React.FC<TemplateManagerProps> = ({
prompts,
currentPrompt,
onSelectPrompt,
onSavePrompt,
onImportPrompts,
onExportPrompts,
}) => {
const handleSaveTemplate = () => {
if (!currentPrompt.trim()) return;
const name = prompt('Enter template name:');
if (name && name.trim()) {
onSavePrompt(name.trim(), currentPrompt);
}
};
return (
<div className="border border-slate-200/50 dark:border-slate-700/50 rounded-xl p-4 bg-slate-50/30 dark:bg-slate-800/30">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Left: Template Picker */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Templates</h4>
<div className="flex flex-wrap gap-1">
{prompts.length === 0 ? (
<span className="text-xs text-slate-500 dark:text-slate-400">No templates saved yet</span>
) : (
prompts.map((template) => (
<button
key={template.name}
type="button"
onClick={() => onSelectPrompt(template.text)}
className="text-xs px-2 py-1 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900 dark:hover:bg-purple-800 text-purple-700 dark:text-purple-300 transition-colors duration-200"
title={`Load template: ${template.text.substring(0, 50)}...`}
>
{template.name}
</button>
))
)}
</div>
</div>
{/* Right: Template Management Icons */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Manage</h4>
<div className="flex gap-2">
<button
type="button"
onClick={handleSaveTemplate}
disabled={!currentPrompt.trim()}
className="p-2 rounded bg-green-100 hover:bg-green-200 dark:bg-green-900 dark:hover:bg-green-800 text-green-700 dark:text-green-300 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Save current prompt as template"
>
<Save size={16} />
</button>
<button
type="button"
onClick={onImportPrompts}
className="p-2 rounded bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-700 dark:text-blue-300 transition-colors duration-200"
title="Import templates from file"
>
<Upload size={16} />
</button>
<button
type="button"
onClick={onExportPrompts}
disabled={prompts.length === 0}
className="p-2 rounded bg-orange-100 hover:bg-orange-200 dark:bg-orange-900 dark:hover:bg-orange-800 text-orange-700 dark:text-orange-300 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Export templates to file"
>
<Download size={16} />
</button>
</div>
</div>
</div>
</div>
);
};
export default TemplateManager;

View File

@ -36,27 +36,27 @@ export const QUICK_ACTIONS = [
{
name: 'Remove Background',
prompt: 'remove the background, make it transparent',
icon: '🔲'
iconName: 'Eraser'
},
{
name: 'Cleanup',
prompt: 'clean up and enhance the image, remove noise and artifacts',
icon: '✨'
iconName: 'Sparkles'
},
{
name: 'Crop Foreground',
prompt: 'crop to focus on the main subject, remove unnecessary background',
icon: '✂️'
iconName: 'Crop'
},
{
name: 'Improve Colors',
prompt: 'enhance colors, improve saturation and contrast',
icon: '🎨'
iconName: 'Palette'
},
{
name: 'Product Presentation',
prompt: 'professional product photography style, clean background, studio lighting',
icon: '📦'
iconName: 'Package'
}
] as const;

View File

@ -14,6 +14,8 @@ interface TauriListenersProps extends InitCallbacks {
prompt: string;
setCurrentIndex: (index: number) => void;
setPromptHistory: (history: string[]) => void;
setFileHistory: (fileHistory: string[]) => void;
addToFileHistory: (filePath: string) => Promise<void>;
}
export function useTauriListeners({
@ -29,7 +31,9 @@ export function useTauriListeners({
setIsGenerating,
prompt,
setCurrentIndex,
setPromptHistory
setPromptHistory,
setFileHistory,
addToFileHistory
}: TauriListenersProps) {
useEffect(() => {
let unlistenConfig: (() => void) | undefined;
@ -52,9 +56,6 @@ export function useTauriListeners({
const listeners = await Promise.all([
tauriApi.listen(TauriEvent.CONFIG_RECEIVED, async (event: any) => {
const data = event.payload;
log.info('📨 Config received from backend');
// Process config and update state
const initCallbacks: InitCallbacks = {
setPrompt,
@ -62,7 +63,8 @@ export function useTauriListeners({
setApiKey,
setPrompts,
setIpcInitialized,
setPromptHistory
setPromptHistory,
setFileHistory
};
try {
@ -76,12 +78,7 @@ export function useTauriListeners({
}),
tauriApi.listen(TauriEvent.IMAGE_RECEIVED, (event: any) => {
const imageData = event.payload;
log.debug('🖼️ Processing image data', {
filename: imageData.filename,
mimeType: imageData.mimeType,
base64Length: imageData.base64?.length,
hasValidData: !!(imageData.base64 && imageData.mimeType && imageData.filename),
});
if (imageData.base64 && imageData.mimeType && imageData.filename) {
const src = `data:${imageData.mimeType};base64,${imageData.base64}`;
@ -106,14 +103,16 @@ export function useTauriListeners({
setGenerationTimeoutId(null);
}
setIsGenerating(false);
log.info('✅ Generated image added to files', { filename: imageData.filename, prompt });
// Add to file history for easy reopening
addToFileHistory(imageData.filename).catch(err =>
log.error('Failed to add to file history', { error: err.message })
);
} else {
const newImageFile: ImageFile = { path: imageData.filename, src, isGenerated: false };
setFiles(prevFiles => {
const exists = prevFiles.some(f => f.path === imageData.filename);
if (!exists) {
log.info(`📁 Adding input image: ${imageData.filename}`);
return [...prevFiles, newImageFile];
return [...prevFiles, newImageFile];
}
log.warn(`🔄 Image already exists: ${imageData.filename}`);
return prevFiles;

View File

@ -25,6 +25,7 @@ export interface InitCallbacks {
setPrompts: (prompts: PromptTemplate[]) => void;
setIpcInitialized: (initialized: boolean) => void;
setPromptHistory?: (history: string[]) => void;
setFileHistory?: (fileHistory: string[]) => void;
}
const STORE_FILE_NAME = '.kbot-gui.json';
@ -97,7 +98,6 @@ export async function loadStore(
log.debug(`📄 Store path resolved to: ${storePath}`);
const content = await tauriApi.fs.readTextFile(storePath);
log.debug(`📖 File content length: ${content?.length || 0}`);
if (content) {
const data = JSON.parse(content);
@ -113,9 +113,11 @@ export async function loadStore(
// Load file history if available
if (data.fileHistory && Array.isArray(data.fileHistory) && setFileHistory) {
log.debug(`Setting file history with ${data.fileHistory.length} files`, {
files: data.fileHistory.map((f: string) => f.split(/[/\\]/).pop())
});
setFileHistory(data.fileHistory);
}
}
log.info(`✅ Loaded ${data.prompts.length} prompts, ${data.history?.length || 0} history entries, ${data.fileHistory?.length || 0} file history entries from store`);
return data.prompts;
@ -236,10 +238,6 @@ export async function initializeApp(callbacks: InitCallbacks): Promise<InitState
if (state.isTauriEnv) {
// Step 2: Request config (this will trigger CONFIG_RECEIVED event)
await getConfig();
log.info('⏳ Waiting for config to be received via event...');
// Note: The actual config processing and store loading will happen
// in the CONFIG_RECEIVED event handler
} else {
log.warn('🌐 Running in browser mode, skipping config request');
state.isInitialized = true;
@ -265,18 +263,12 @@ export async function completeInitialization(
callbacks: InitCallbacks
): Promise<{ config: InitConfig; prompts: PromptTemplate[] }> {
try {
log.info('🎯 Completing initialization after config received...');
// Process the received config
const config = processConfig(configData, callbacks);
// Load the prompts store
const prompts = await loadStore(callbacks, callbacks.setPromptHistory);
log.info('✅ App initialization completed successfully', {
configKeys: Object.keys(config),
promptCount: prompts.length
});
// Load the prompts store
const prompts = await loadStore(callbacks, callbacks.setPromptHistory, callbacks.setFileHistory);
return { config, prompts };
@ -290,9 +282,13 @@ export async function completeInitialization(
}
/**
* Save prompts, history, and file history to store
* Save specific data to store (merges with existing)
*/
export async function saveStore(prompts: PromptTemplate[], history?: string[], fileHistory?: string[]): Promise<void> {
export async function saveToStore(updates: {
prompts?: PromptTemplate[],
history?: string[],
fileHistory?: string[]
}): Promise<void> {
const { isTauri: isTauriEnv } = await tauriApi.ensureTauriApi();
if (!isTauriEnv) {
@ -306,15 +302,35 @@ export async function saveStore(prompts: PromptTemplate[], history?: string[], f
log.debug(`📁 Got data dir: ${dataDir}`);
const storePath = await tauriApi.path.join(dataDir, STORE_FILE_NAME);
log.debug(`📄 Store path: ${storePath}`);
const dataToSave = JSON.stringify({
prompts,
history: history || [],
fileHistory: fileHistory || []
}, null, 2);
log.debug(`💾 Data to save:`, { promptCount: prompts.length, dataLength: dataToSave.length });
// Load existing data first to merge
let existingData = { prompts: [], history: [], fileHistory: [] };
try {
const existingContent = await tauriApi.fs.readTextFile(storePath);
if (existingContent) {
existingData = JSON.parse(existingContent);
}
} catch (loadError) {
log.debug('No existing store to merge with');
}
// Merge with existing data - only update provided fields
const mergedData = {
prompts: updates.prompts !== undefined ? updates.prompts : existingData.prompts || [],
history: updates.history !== undefined ? updates.history : existingData.history || [],
fileHistory: updates.fileHistory !== undefined ? updates.fileHistory : existingData.fileHistory || []
};
const dataToSave = JSON.stringify(mergedData, null, 2);
log.debug(`💾 Data to save:`, {
promptCount: mergedData.prompts.length,
historyCount: mergedData.history.length,
fileHistoryCount: mergedData.fileHistory.length,
dataLength: dataToSave.length
});
await tauriApi.fs.writeTextFile(storePath, dataToSave);
log.info(`✅ Prompts saved to ${storePath}`);
log.info(`Store saved with ${mergedData.prompts.length} prompts, ${mergedData.history.length} history, ${mergedData.fileHistory.length} files`);
} catch (error) {
log.error('Failed to save prompts', {
error: (error as Error).message,