344 lines
14 KiB
TypeScript
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;
|