layout | history

This commit is contained in:
lovebird 2025-09-20 20:10:01 +02:00
parent ddcd3d52f9
commit 75abc7830f
9 changed files with 407 additions and 172 deletions

BIN
packages/kbot/cat_gen_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -33,7 +33,8 @@
"@tauri-apps/plugin-upload": "^2.3.0",
"mime-types": "^2.1.35",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"react-terminal": "^1.4.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
@ -2010,6 +2011,26 @@
}
}
},
"node_modules/detect-europe-js": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz",
"integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
},
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@ -2151,6 +2172,26 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/is-standalone-pwa": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz",
"integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
},
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"license": "MIT"
},
"node_modules/jiti": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
@ -2623,6 +2664,17 @@
"node": ">=0.10.0"
}
},
"node_modules/react-terminal": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/react-terminal/-/react-terminal-1.4.5.tgz",
"integrity": "sha512-uv50LYNb4G/ACr8ghPUuZkypY0UxWm+YBK3M0MLaBG6kALeS9FxNCBhmQw/Uu9ckkE2k5E3kZPUVbR5D6uGOlA==",
"license": "MIT",
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"ua-parser-js": "^2.0.0"
}
},
"node_modules/rollup": {
"version": "4.50.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz",
@ -2763,6 +2815,67 @@
"node": ">=14.17"
}
},
"node_modules/ua-is-frozen": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz",
"integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
},
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"license": "MIT"
},
"node_modules/ua-parser-js": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.5.tgz",
"integrity": "sha512-sZErtx3rhpvZQanWW5umau4o/snfoLqRcQwQIZ54377WtRzIecnIKvjpkd5JwPcSUMglGnbIgcsQBGAbdi3S9Q==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"license": "AGPL-3.0-or-later",
"dependencies": {
"detect-europe-js": "^0.1.2",
"is-standalone-pwa": "^0.1.1",
"ua-is-frozen": "^0.1.2",
"undici": "^7.12.0"
},
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/undici": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",

View File

@ -36,7 +36,8 @@
"@tauri-apps/plugin-upload": "^2.3.0",
"mime-types": "^2.1.35",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"react-terminal": "^1.4.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",

View File

@ -43,39 +43,88 @@ function App() {
const [generationTimeoutId, setGenerationTimeoutId] = useState<NodeJS.Timeout | null>(null);
const [currentIndex, setCurrentIndex] = useState(0);
const [prompts, setPrompts] = useState<PromptTemplate[]>([]);
const [promptHistory, setPromptHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const appendStyle = (style: string) => {
setPrompt(prev => {
const trimmed = prev.trim();
if (trimmed) {
return `${trimmed}, ${style}`;
// Remove any existing styles from QUICK_STYLES
let cleanPrompt = trimmed;
QUICK_STYLES.forEach(existingStyle => {
// Remove style if it exists (with or without comma)
const patterns = [
new RegExp(`,\\s*${existingStyle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi'),
new RegExp(`^${existingStyle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*,?\\s*`, 'gi'),
new RegExp(`\\s*,\\s*${existingStyle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'gi')
];
patterns.forEach(pattern => {
cleanPrompt = cleanPrompt.replace(pattern, '');
});
});
// Clean up any double commas or trailing/leading commas
cleanPrompt = cleanPrompt.replace(/,\s*,/g, ',').replace(/^,\s*|,\s*$/g, '').trim();
// Add the new style
if (cleanPrompt) {
return `${cleanPrompt}, ${style}`;
} else {
return style;
}
});
log.info(`🎨 Style applied: ${style}`);
log.info(`🎨 Style replaced with: ${style}`);
};
const executeQuickAction = async (action: { name: string; prompt: string; icon: string }) => {
// Find the last non-generated image to use as input
const inputImages = files.filter(f => !f.isGenerated);
if (inputImages.length === 0) {
log.warn('No input images available for quick action');
const selectedImages = getSelectedImages();
if (selectedImages.length === 0) {
log.warn('Please select an image first to use quick actions');
return;
}
const lastImage = inputImages[inputImages.length - 1];
// Select the last image
handleImageSelection(lastImage.path, false);
// Use the first selected image
const targetImage = selectedImages[0];
// Set the action prompt
setPrompt(action.prompt);
// Generate with the selected image
log.info(`🚀 Executing quick action: ${action.name}`);
await generateImage(action.prompt, [lastImage]);
log.info(`🚀 Executing quick action: ${action.name} on selected image ${targetImage.path}`);
await generateImage(action.prompt, [targetImage]);
};
const addToHistory = (promptText: string) => {
if (promptText.trim() && !promptHistory.includes(promptText.trim())) {
setPromptHistory(prev => [...prev, promptText.trim()]);
setHistoryIndex(-1); // Reset to end of history
log.info(`📝 Added to history: "${promptText.substring(0, 50)}..."`);
}
};
const navigateHistory = (direction: 'up' | 'down') => {
if (promptHistory.length === 0) return;
let newIndex = historyIndex;
if (direction === 'up') {
newIndex = historyIndex === -1 ? promptHistory.length - 1 : Math.max(0, historyIndex - 1);
} else {
newIndex = historyIndex === -1 ? -1 : historyIndex + 1;
if (newIndex >= promptHistory.length) newIndex = -1;
}
setHistoryIndex(newIndex);
if (newIndex === -1) {
setPrompt('');
} else {
setPrompt(promptHistory[newIndex]);
log.info(`📜 History: ${newIndex + 1}/${promptHistory.length}`);
}
};
const importPrompts = async () => {
@ -436,8 +485,15 @@ function App() {
}, []);
async function submit() {
if (!prompt.trim()) {
log.warn('Please enter a prompt first');
return;
}
if (apiKey) {
// Add to history before generating
addToHistory(prompt);
// Generate image via backend (always chat mode now)
// Only use explicitly selected images. If none are selected, generate from prompt alone.
const imagesToUse = getSelectedImages();
@ -618,6 +674,9 @@ function App() {
appendStyle={appendStyle}
quickActions={QUICK_ACTIONS}
executeQuickAction={executeQuickAction}
promptHistory={promptHistory}
historyIndex={historyIndex}
navigateHistory={navigateHistory}
/>
{/* Debug Panel */}

View File

@ -124,52 +124,57 @@ export default function ImageGallery({
const isSelected = currentImage.selected || false;
return (
<div className="flex flex-col">
{/* Main Image Display - Compact */}
<div className="flex items-center justify-center rounded-lg mb-4">
<div className="relative w-full h-[200px] flex items-center justify-center">
<img
src={currentImage.src}
alt={currentImage.path}
className={`h-[200px] object-contain rounded-lg shadow-lg border-2 transition-all duration-300 cursor-pointer ${
isSelected
? 'border-blue-400 shadow-blue-400/30'
: isGenerated
? 'border-green-300'
: 'border-white/30'
}`}
onDoubleClick={() => openLightbox(safeIndex)}
title="Double-click for fullscreen"
/>
<div className="absolute top-2 left-2 flex flex-col gap-2">
{/* Compact overlays */}
{isGenerated && isSelected && (
<div className="bg-blue-500 text-white px-2 py-1 rounded text-xs font-semibold shadow-lg flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column: Main Image Display */}
<div className="lg:col-span-2">
<div className="flex items-center justify-center rounded-lg">
<div className="relative w-full h-[300px] flex items-center justify-center">
<img
src={currentImage.src}
alt={currentImage.path}
className={`max-h-[300px] max-w-full object-contain rounded-lg shadow-lg border-2 transition-all duration-300 cursor-pointer ${
isSelected
? 'border-blue-400 shadow-blue-400/30'
: isGenerated
? 'border-green-300'
: 'border-white/30'
}`}
onDoubleClick={() => openLightbox(safeIndex)}
title="Double-click for fullscreen"
/>
<div className="absolute top-2 left-2 flex flex-col gap-2">
{/* Compact overlays */}
{isGenerated && isSelected && (
<div className="bg-blue-500 text-white px-2 py-1 rounded text-xs font-semibold shadow-lg flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
{isGenerated && !isSelected && (
<div className="bg-green-500 text-white px-2 py-1 rounded text-xs font-semibold shadow-lg">
</div>
)}
</div>
)}
{isGenerated && !isSelected && (
<div className="bg-green-500 text-white px-2 py-1 rounded text-xs font-semibold shadow-lg">
</div>
)}
</div>
</div>
{/* Image Info */}
<div className="text-center mt-3">
<p className="text-sm text-slate-600 dark:text-slate-400">
{currentImage.path.split(/[/\\]/).pop()} {safeIndex + 1}/{images.length}
</p>
</div>
</div>
{/* Compact Image Info */}
<div className="text-center mb-3">
<p className="text-xs text-slate-600 dark:text-slate-400">
{currentImage.path.split(/[/\\]/).pop()} {safeIndex + 1}/{images.length}
</p>
</div>
{/* Thumbnails Grid - Compact */}
<div className="grid grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
{/* Right column: Thumbnails */}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Images ({images.length})</h4>
<div className="grid grid-cols-2 gap-2">
{images.map((image, index) => {
const thumbIsGenerating = image.path.startsWith('generating_');
const thumbIsGenerated = !!image.isGenerated;
@ -255,6 +260,8 @@ export default function ImageGallery({
);
})}
</div>
</div>
</div>
{/* Lightbox Modal */}
{lightboxOpen && (

View File

@ -35,6 +35,9 @@ interface PromptFormProps {
appendStyle: (style: string) => void;
quickActions: typeof QUICK_ACTIONS;
executeQuickAction: (action: { name: string; prompt: string; icon: string }) => Promise<void>;
promptHistory: string[];
historyIndex: number;
navigateHistory: (direction: 'up' | 'down') => void;
}
const PromptForm: React.FC<PromptFormProps> = ({
@ -67,6 +70,9 @@ const PromptForm: React.FC<PromptFormProps> = ({
appendStyle,
quickActions,
executeQuickAction,
promptHistory,
historyIndex,
navigateHistory,
}) => {
const selectedCount = getSelectedImages().length;
const { ref: dropZoneRef, dragIn } = useDropZone({ onDrop: addFiles });
@ -80,132 +86,180 @@ const PromptForm: React.FC<PromptFormProps> = ({
}}
>
<div className="w-full space-y-6">
<div>
<label htmlFor="prompt-input" className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Image Description
</label>
<textarea
id="prompt-input"
value={prompt}
onChange={(e) => setPrompt(e.currentTarget.value)}
placeholder="Describe the image you want to generate or edit..."
className="w-full glass-input p-4 rounded-xl min-h-[120px] resize-none"
rows={4}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submit();
}
}}
/>
{/* Quick Style Picker */}
<div className="mt-3">
<div className="flex flex-wrap gap-2">
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 self-center mr-2">Quick styles:</span>
{quickStyles.map((style) => (
<button
key={style}
type="button"
onClick={() => appendStyle(style)}
className="text-xs px-3 py-1 rounded-full bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 transition-colors duration-200 border border-slate-200 dark:border-slate-700"
>
{style}
</button>
))}
{/* Two-column layout: Prompt + Templates/Actions */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column: Prompt area (2/3 width) */}
<div className="lg:col-span-2">
<label htmlFor="prompt-input" className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Image Description
</label>
<div className="border-2 border-slate-200 dark:border-slate-700 rounded-xl p-4 focus-within:border-indigo-500 dark:focus-within:border-indigo-400 transition-colors duration-200">
<textarea
id="prompt-input"
value={prompt}
onChange={(e) => setPrompt(e.currentTarget.value)}
placeholder="Describe the image you want to generate or edit..."
className="w-full bg-transparent border-none outline-none min-h-[120px] resize-none text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400"
rows={5}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submit();
} else if (e.key === 'ArrowUp' && e.ctrlKey) {
e.preventDefault();
navigateHistory('up');
} else if (e.key === 'ArrowDown' && e.ctrlKey) {
e.preventDefault();
navigateHistory('down');
}
}}
/>
</div>
</div>
{/* Quick Actions */}
<div className="mt-3">
<div className="flex flex-wrap gap-2">
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 self-center mr-2">Quick actions:</span>
{quickActions.map((action) => {
const hasInputImages = files.filter(f => !f.isGenerated).length > 0;
return (
{/* Generate Button + History Navigation */}
<div className="flex gap-3 mt-4">
<button
type="button"
onClick={submit}
disabled={isGenerating || !prompt.trim()}
className="flex-1 glass-button bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white px-6 py-3 rounded-xl font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300"
>
{isGenerating ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Generating...
</span>
) : (
'🎨 Generate Image'
)}
</button>
{/* History Navigation */}
{promptHistory.length > 0 && (
<div className="flex gap-1">
<button
key={action.name}
type="button"
onClick={() => executeQuickAction(action)}
disabled={isGenerating || !hasInputImages}
className={`text-xs px-3 py-1 rounded-full transition-colors duration-200 border ${
!hasInputImages
? 'bg-slate-50 text-slate-400 border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 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 border-blue-200 dark:border-blue-700'
} ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
title={!hasInputImages ? 'Add an image first to use quick actions' : `Apply ${action.name} to the last image`}
onClick={() => navigateHistory('up')}
disabled={isGenerating}
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+↑`}
>
{action.icon} {action.name}
</button>
);
})}
<button
type="button"
onClick={() => navigateHistory('down')}
disabled={isGenerating}
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+↓`}
>
</button>
</div>
)}
</div>
</div>
{/* Generate Button */}
<div className="flex gap-3 mt-4">
<button
type="button"
onClick={submit}
disabled={isGenerating || !prompt.trim()}
className="flex-1 glass-button bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white px-6 py-3 rounded-xl font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300"
>
{isGenerating ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Generating...
</span>
) : (
'🎨 Generate Image'
)}
</button>
{/* Right column: Templates and Actions (1/3 width) */}
<div className="space-y-4">
{/* Quick Style Picker */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Quick Styles</h4>
<div className="flex flex-wrap gap-1">
{quickStyles.map((style) => (
<button
key={style}
type="button"
onClick={() => appendStyle(style)}
className="text-xs px-2 py-1 rounded bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 transition-colors duration-200"
>
{style}
</button>
))}
</div>
</div>
{/* Quick Actions */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Quick Actions</h4>
<div className="flex flex-wrap gap-1">
{quickActions.map((action) => {
const hasSelectedImages = getSelectedImages().length > 0;
const hasAnyImages = files.length > 0;
return (
<button
key={action.name}
type="button"
onClick={() => executeQuickAction(action)}
disabled={isGenerating || !hasSelectedImages}
className={`text-xs px-2 py-1 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}`}
>
{action.icon} {action.name}
</button>
);
})}
</div>
</div>
</div>
</div>
<PromptManager
prompts={prompts}
onSelectPrompt={setPrompt}
currentPrompt={prompt}
onSavePrompt={(name, text) => {
const newPrompts = [...prompts, { name, text }];
setPrompts(newPrompts);
savePrompts(newPrompts);
}}
onImportPrompts={importPrompts}
onExportPrompts={exportPrompts}
/>
<div>
<label htmlFor="output-path" className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Output File Path
</label>
<div className="flex gap-3">
<input
id="output-path"
type="text"
value={dst}
onChange={(e) => setDst(e.target.value)}
placeholder="output.png"
className="flex-1 glass-input p-4 rounded-xl"
/>
<button
type="button"
onClick={openSaveDialog}
className="glass-button font-semibold py-4 px-5 rounded-xl whitespace-nowrap"
title="Browse for save location"
>
📁 Browse
</button>
</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>
<div
ref={dropZoneRef}
className={`p-4 rounded-xl border-2 border-dashed transition-all duration-300 ${dragIn ? 'border-blue-500 bg-blue-500/10' : 'border-slate-300/50 dark:border-slate-600/50'}`}
>
{/* Two-column layout: Destination + Source */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left: Output destination */}
<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">
<label htmlFor="output-path" className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Output File Path
</label>
<div className="flex gap-3">
<input
id="output-path"
type="text"
value={dst}
onChange={(e) => setDst(e.target.value)}
placeholder="output.png"
className="flex-1 glass-input p-4 rounded-xl"
/>
<button
type="button"
onClick={openSaveDialog}
className="glass-button font-semibold py-4 px-5 rounded-xl whitespace-nowrap"
title="Browse for save location"
>
📁 Browse
</button>
</div>
</div>
{/* Right: Source images */}
<div
ref={dropZoneRef}
className={`p-4 rounded-xl border-2 border-dashed transition-all duration-300 bg-slate-50/30 dark:bg-slate-800/30 ${dragIn ? 'border-blue-500 bg-blue-500/10' : 'border-slate-300/50 dark:border-slate-600/50'}`}
>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2 text-center">
Source Images
</label>
@ -228,11 +282,11 @@ const PromptForm: React.FC<PromptFormProps> = ({
</svg>
</button>
</div>
</div>
</div>
</div>
{files.length > 0 && (
<div className="w-full mt-6">
<div className="w-full mt-6 border border-slate-200/50 dark:border-slate-700/50 rounded-xl p-6 bg-slate-50/30 dark:bg-slate-800/30">
<div className="flex justify-between items-center mb-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
Images ({files.length})
@ -252,7 +306,7 @@ const PromptForm: React.FC<PromptFormProps> = ({
</button>
</div>
<div className="glass-card p-4">
<div className="border border-slate-200/30 dark:border-slate-700/30 rounded-lg p-4 bg-white/50 dark:bg-slate-900/50">
<ImageGallery
images={files}
onImageSelection={handleImageSelection}
@ -281,6 +335,7 @@ const PromptForm: React.FC<PromptFormProps> = ({
</button>
)}
</div>
</div>
</form>
);
};