layout | history
This commit is contained in:
parent
ddcd3d52f9
commit
75abc7830f
BIN
packages/kbot/cat_gen_0.png
Normal file
BIN
packages/kbot/cat_gen_0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
BIN
packages/kbot/dist/win-64/tauri-app.exe
vendored
BIN
packages/kbot/dist/win-64/tauri-app.exe
vendored
Binary file not shown.
BIN
packages/kbot/generated_gen_8.png
Normal file
BIN
packages/kbot/generated_gen_8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
BIN
packages/kbot/generated_gen_9.png
Normal file
BIN
packages/kbot/generated_gen_9.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
115
packages/kbot/gui/tauri-app/package-lock.json
generated
115
packages/kbot/gui/tauri-app/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user