mono/packages/kbot/gui/tauri-app/src/components/PromptForm.tsx
2025-09-20 20:10:01 +02:00

344 lines
14 KiB
TypeScript

import React 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';
interface PromptFormProps {
prompt: string;
setPrompt: (prompt: string) => void;
dst: string;
setDst: (dst: string) => void;
openSaveDialog: () => void;
openFilePicker: () => void;
files: ImageFile[];
getSelectedImages: () => ImageFile[];
clearAllFiles: () => void;
handleImageSelection: (path: string, isMultiSelect: boolean) => void;
removeFile: (path: string) => void;
isGenerating: boolean;
saveAndClose: () => void;
submit: () => void;
addImageFromUrl: (url: string) => void;
onImageDelete?: (path: string) => void;
onImageSaveAs?: (path: string) => void;
addFiles: (paths: string[]) => void;
currentIndex: number;
setCurrentIndex: (index: number) => void;
prompts: PromptTemplate[];
setPrompts: (prompts: PromptTemplate[]) => void;
savePrompts: (prompts: PromptTemplate[]) => void;
importPrompts: () => void;
exportPrompts: () => void;
quickStyles: readonly string[];
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> = ({
prompt,
setPrompt,
dst,
setDst,
openSaveDialog,
openFilePicker,
files,
getSelectedImages,
clearAllFiles,
handleImageSelection,
removeFile,
isGenerating,
saveAndClose,
submit,
addImageFromUrl,
onImageDelete,
onImageSaveAs,
addFiles,
currentIndex,
setCurrentIndex,
prompts,
setPrompts,
savePrompts,
importPrompts,
exportPrompts,
quickStyles,
appendStyle,
quickActions,
executeQuickAction,
promptHistory,
historyIndex,
navigateHistory,
}) => {
const selectedCount = getSelectedImages().length;
const { ref: dropZoneRef, dragIn } = useDropZone({ onDrop: addFiles });
return (
<form
className="flex flex-col items-center glass-card p-8 glass-shimmer shadow-2xl"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<div className="w-full space-y-6">
{/* 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>
{/* 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
type="button"
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+↑`}
>
</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>
{/* 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>
<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>
{/* 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>
<div className="flex gap-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"
>
📸 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>
</button>
</div>
</div>
</div>
{files.length > 0 && (
<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})
{selectedCount > 0 && (
<span className="ml-2 text-blue-600 dark:text-blue-400">
{selectedCount} selected
</span>
)}
</h3>
<button
type="button"
onClick={clearAllFiles}
className="glass-button border-red-400/50 text-red-600 hover:bg-red-500/20 text-sm px-3 py-2 rounded-lg"
title="Remove all images"
>
Clear All
</button>
</div>
<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}
onImageRemove={removeFile}
onImageSaveAs={onImageSaveAs}
showSelection={true}
onImageDelete={onImageDelete}
currentIndex={currentIndex}
setCurrentIndex={setCurrentIndex}
/>
</div>
</div>
)}
<div className="w-full mt-8 space-y-3">
{files.some(file => file.path.startsWith('generated_')) && (
<button
type="button"
onClick={saveAndClose}
className="w-full glass-button border-green-400/50 text-green-700 dark:text-green-400 hover:bg-green-500/20 font-semibold py-3 px-6 rounded-xl shadow-md hover:shadow-lg transition-all duration-300"
disabled={isGenerating}
>
<span className="flex items-center justify-center gap-2">
💾 Save Last Generated Image and Close
</span>
</button>
)}
</div>
</div>
</form>
);
};
export default PromptForm;