mono/packages/ui/src/components/ImageWizard.tsx
2026-02-25 10:11:54 +01:00

1961 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect } from "react";
import { fetchPostById } from '@/modules/posts/client-posts';
import { Button } from "@/components/ui/button";
import { useWizardContext } from "@/hooks/useWizardContext";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Wand2,
Save,
ArrowLeft,
Plus,
Trash2
} from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
import { createImage, editImage } from "@/lib/image-router";
import PublishDialog from "@/components/PublishDialog";
import { runTools } from "@/lib/openai";
import { T, translate } from "@/i18n";
import { DEFAULT_QUICK_ACTIONS, QuickAction as QuickActionType } from "@/constants";
import { PromptPreset } from "@/components/PresetManager";
import VoiceRecordingPopup from "@/components/VoiceRecordingPopup";
import { Workflow } from "@/components/WorkflowManager";
import { useLog } from "@/contexts/LogContext";
import LogViewer from "@/components/LogViewer";
import ImageLightbox from "@/components/ImageLightbox";
import { uploadInternalVideo } from '@/utils/uploadUtils';
import { ImageEditor } from "@/components/ImageEditor";
import EditImageModal from "@/components/EditImageModal";
import PostPicker from "@/components/PostPicker";
// Import types and handlers
import { ImageFile, ImageWizardProps } from "./ImageWizard/types";
import { getUserApiKeys as getUserSecrets } from "@/modules/user/client-user";
type QuickAction = QuickActionType; // Re-export for compatibility
import {
handleFileUpload,
toggleImageSelection as toggleImageSelectionUtil,
removeImageRequest,
confirmDeleteImage as confirmDeleteImageUtil,
setAsSelected as setAsSelectedUtil,
handleDownloadImage,
} from "./ImageWizard/handlers/imageHandlers";
import {
handleOptimizePrompt as handleOptimizePromptUtil,
buildFullPrompt,
abortGeneration,
} from "./ImageWizard/handlers/generationHandlers";
import {
publishImage as publishImageUtil,
quickPublishAsNew as quickPublishAsNewUtil,
publishToGallery as publishToGalleryUtil,
} from "./ImageWizard/handlers/publishHandlers";
import {
loadFamilyVersions as loadFamilyVersionsUtil,
loadAvailableImages as loadAvailableImagesUtil,
} from "./ImageWizard/handlers/dataHandlers";
// Settings handlers are now used via useUserSettings hook
import {
handleMicrophone as handleMicrophoneUtil,
handleVoiceToImage as handleVoiceToImageUtil,
} from "./ImageWizard/handlers/voiceHandlers";
import {
handleAgentGeneration as handleAgentGenerationUtil,
} from "./ImageWizard/handlers/agentHandlers";
import {
generateImageSplit as generateImageSplitUtil,
} from "./ImageWizard/handlers/promptHandlers";
import {
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
} from "./ImageWizard/handlers/dropHandlers";
import {
WizardSidebar,
Prompt,
ImageGalleryPanel,
PostComposer,
} from "./ImageWizard/components";
import { createLogger } from "./ImageWizard/utils/logger";
import { useImageWizardState } from "./ImageWizard/hooks/useImageWizardState";
import * as wizardDb from "./ImageWizard/db";
import {
loadPromptTemplates,
savePromptTemplates,
loadPromptPresets,
loadWorkflows,
loadQuickActions,
saveQuickActions,
loadPromptHistory,
addToPromptHistory,
navigatePromptHistory,
savePromptPreset,
updatePromptPreset,
deletePromptPreset,
saveWorkflow,
updateWorkflow,
deleteWorkflow,
} from "./ImageWizard/handlers/settingsHandlers";
import { WizardProvider } from "./ImageWizard/context/WizardContext";
const ImageWizard: React.FC<ImageWizardProps> = ({
isOpen,
onClose,
initialImages = [],
onPublish,
originalImageId,
mode = 'default',
initialPostTitle = "",
initialPostDescription = "",
editingPostId = undefined
}) => {
const { user } = useAuth();
const navigate = useNavigate();
const { addLog, isLoggerVisible, setLoggerVisible } = useLog();
// Create logger instance for this component
const logger = createLogger(addLog, 'ImageWizard');
// Don't render if not open
// Centralized state management - flat destructuring
const {
// Image state
images, setImages, availableImages, setAvailableImages, generatedImage, setGeneratedImage,
// Generation state
isGenerating, setIsGenerating, isAgentMode, setIsAgentMode, isSplitMode, setIsSplitMode,
isOptimizingPrompt, setIsOptimizingPrompt, abortControllerRef,
// UI state
dragIn, setDragIn, loadingImages, setLoadingImages, isPublishing, setIsPublishing,
dropZoneRef, dragLeaveTimeoutRef,
// Form state
prompt, setPrompt, postTitle, setPostTitle, postDescription, setPostDescription,
selectedModel, setSelectedModel,
// New state for Gemini 3 Pro
aspectRatio, setAspectRatio,
resolution, setResolution,
searchGrounding, setSearchGrounding,
// Dialog state
showDeleteConfirmDialog, setShowDeleteConfirmDialog, imageToDelete, setImageToDelete,
showSaveTemplateDialog, setShowSaveTemplateDialog, newTemplateName, setNewTemplateName,
showTemplateManager, setShowTemplateManager, showEditActionsDialog, setShowEditActionsDialog,
showLightboxPublishDialog, setShowLightboxPublishDialog, showVoicePopup, setShowVoicePopup,
// Lightbox state
lightboxOpen, setLightboxOpen, currentImageIndex, setCurrentImageIndex,
lightboxPrompt, setLightboxPrompt, selectedOriginalImageId, setSelectedOriginalImageId,
// Settings state
promptTemplates, setPromptTemplates, loadingTemplates, setLoadingTemplates,
promptPresets, setPromptPresets, selectedPreset, setSelectedPreset,
loadingPresets, setLoadingPresets, workflows, setWorkflows,
loadingWorkflows, setLoadingWorkflows, quickActions, setQuickActions,
editingActions, setEditingActions, loadingActions, setLoadingActions,
promptHistory, setPromptHistory, historyIndex, setHistoryIndex,
// Voice state
isRecording, setIsRecording, isTranscribing, setIsTranscribing,
mediaRecorderRef, audioChunksRef, voicePopupRef,
// Add editingPostId to destructuring
editingPostId: currentEditingPostId,
} = useImageWizardState(initialImages, initialPostTitle, initialPostDescription, editingPostId);
const [settings, setSettings] = React.useState<any>({ visibility: 'public' }); // Post settings
const [lastError, setLastError] = React.useState<string | null>(null);
// Auto-retry state for 503 "high demand" errors
const retryTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const retryCountdownRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
const retryCountRef = React.useRef(0);
const isRetryCallRef = React.useRef(false);
const [retryInfo, setRetryInfo] = React.useState<{ attempt: number; secondsLeft: number } | null>(null);
const cancelRetry = React.useCallback(() => {
if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
if (retryCountdownRef.current) { clearInterval(retryCountdownRef.current); retryCountdownRef.current = null; }
retryCountRef.current = 0;
isRetryCallRef.current = false;
setRetryInfo(null);
}, []);
const scheduleRetry = React.useCallback((retryFn: () => void, errorMsg: string) => {
const MAX_RETRIES = 5;
const RETRY_SECS = 120;
if (retryCountRef.current >= MAX_RETRIES) {
retryCountRef.current = 0;
setLastError(`${errorMsg} — gave up after ${MAX_RETRIES} attempts`);
return;
}
retryCountRef.current++;
const attempt = retryCountRef.current;
let seconds = RETRY_SECS;
setRetryInfo({ attempt, secondsLeft: seconds });
retryCountdownRef.current = setInterval(() => {
seconds--;
if (seconds > 0) { setRetryInfo({ attempt, secondsLeft: seconds }); }
else if (retryCountdownRef.current) { clearInterval(retryCountdownRef.current); retryCountdownRef.current = null; }
}, 1000);
retryTimerRef.current = setTimeout(() => {
if (retryCountdownRef.current) { clearInterval(retryCountdownRef.current); retryCountdownRef.current = null; }
retryTimerRef.current = null;
setRetryInfo(null);
setLastError(null);
isRetryCallRef.current = true;
retryFn();
}, RETRY_SECS * 1000);
}, []);
// Cleanup retry on unmount
React.useEffect(() => () => cancelRetry(), [cancelRetry]);
// Editor and Settings state
const [editingImage, setEditingImage] = React.useState<{ url: string; id: string } | null>(null);
const [configuringImageId, setConfiguringImageId] = React.useState<{ id: string; title: string; description: string; visible: boolean } | null>(null);
const [showPostPicker, setShowPostPicker] = React.useState(false);
// const [isPublishing, setIsPublishing] = React.useState(false); // Using hook state or other defined state
useEffect(() => {
if (user?.id) {
loadPromptTemplates(user.id, setPromptTemplates, setLoadingTemplates);
loadPromptPresets(user.id, setPromptPresets, setLoadingPresets);
loadWorkflows(user.id, setWorkflows, setLoadingWorkflows);
loadQuickActions(user.id, setQuickActions, setLoadingActions, DEFAULT_QUICK_ACTIONS);
loadPromptHistory(user.id, setPromptHistory);
}
}, [user?.id]);
// Auto-upload effect for videos that come from share target (or other sources) without upload status
React.useEffect(() => {
// Find videos that have a file but no upload status (meaning they were just added via prop/state)
const pendingVideos = images.filter(img =>
img.type === 'video' &&
img.file &&
!img.uploadStatus
);
if (pendingVideos.length > 0) {
console.log('Found pending videos for upload:', pendingVideos.length);
pendingVideos.forEach(async (video) => {
if (!video.file || !user?.id) return;
// Mark as uploading immediately
setImages(prev => prev.map(img =>
img.id === video.id ? { ...img, uploadStatus: 'uploading', uploadProgress: 0 } : img
));
try {
// Perform upload
await uploadInternalVideo(video.file, user.id, (progress) => {
setImages(prev => prev.map(img =>
img.id === video.id ? { ...img, uploadProgress: progress } : img
));
}).then(data => {
setImages(prev => prev.map(img =>
img.id === video.id ? {
...img,
id: data.dbId || video.id, // Update ID to DB ID
realDatabaseId: data.dbId,
uploadStatus: 'ready',
src: data.thumbnailUrl || video.src || '',
} : img
));
toast.success(translate('Video uploaded successfully'));
});
} catch (err) {
console.error("Auto-upload video failed", err);
setImages(prev => prev.map(img =>
img.id === video.id ? { ...img, uploadStatus: 'error' } : img
));
toast.error(translate('Failed to upload video'));
}
});
}
}, [images, user?.id, setImages]);
// Drop zone handlers are used directly from imported functions
// Load initial image from Zustand store (replaces sessionStorage)
const { wizardInitialImage } = useWizardContext();
useEffect(() => {
if (wizardInitialImage) {
setImages(prev => {
// Load family versions for this image first, then add the initial image
if (wizardInitialImage.realDatabaseId) {
// Check the current selection status from database for this image
wizardDb.getImageSelectionStatus(wizardInitialImage.realDatabaseId)
.then((isSelected) => {
const updatedImageData = { ...wizardInitialImage, selected: isSelected };
setImages(currentImages => {
// Check if image already exists to avoid duplicates
const exists = currentImages.find(img => img.id === updatedImageData.id);
if (exists) return currentImages;
return [...currentImages, updatedImageData];
});
});
loadFamilyVersions([wizardInitialImage]);
} else {
// For non-database images, just add as is
const exists = prev.find(img => img.id === wizardInitialImage.id);
if (!exists) {
return [...prev, wizardInitialImage];
}
}
return prev;
});
// Clear wizard image after loading (optional - keep it if you want to persist)
// clearWizardImage();
}
}, [wizardInitialImage]);
// Load complete family versions when initial images are provided
useEffect(() => {
if (initialImages.length > 0) {
loadFamilyVersions(initialImages);
}
}, [initialImages]);
// Load available images from user and others
useEffect(() => {
loadAvailableImages();
}, []);
// Settings are now loaded in useUserSettings hook
// Use extracted handlers with local state
const loadFamilyVersions = (parentImages: ImageFile[]) =>
loadFamilyVersionsUtil(parentImages, setImages, logger);
const loadAvailableImages = () =>
loadAvailableImagesUtil(setAvailableImages, setLoadingImages);
// Template operations
const applyTemplate = (template: string) => {
if (lightboxOpen) {
setLightboxPrompt(template);
} else {
setPrompt(template);
}
};
const deleteTemplate = async (index: number) => {
if (!user?.id) {
toast.error(translate('User not authenticated'));
return;
}
const newTemplates = promptTemplates.filter((_, i) => i !== index);
await savePromptTemplates(user.id, newTemplates);
setPromptTemplates(newTemplates);
};
const executeWorkflow = async (workflow: Workflow) => {
if (!user) {
toast.error(translate('User not authenticated'));
return;
}
toast.info(`🔄 Executing workflow: ${workflow.name}`);
logger.info(`Starting workflow: ${workflow.name} (${workflow.actions.length} actions)`);
try {
for (let i = 0; i < workflow.actions.length; i++) {
const action = workflow.actions[i];
toast.info(`⚙️ Step ${i + 1}/${workflow.actions.length}: ${action.label}`);
logger.info(`Workflow step ${i + 1}: ${action.label} (${action.type})`);
switch (action.type) {
case 'optimize_prompt':
await handleOptimizePrompt();
break;
case 'generate_image':
await generateImage();
break;
case 'generate_metadata':
// Generate title and description using AI
if (prompt.trim()) {
const apiKey = await wizardDb.getUserOpenAIKey(user.id);
const result = await runTools({
prompt: `Generate a title and description for this image prompt: "${prompt}"`,
preset: 'metadata-only',
apiKey,
});
const metadataToolCall = result.toolCalls?.find(
tc => 'function' in tc && tc.function?.name === 'generate_image_metadata'
);
if (metadataToolCall && 'function' in metadataToolCall) {
const metadata = JSON.parse(metadataToolCall.function.arguments || '{}');
if (metadata.title) setPostTitle(metadata.title);
if (metadata.description) setPostDescription(metadata.description);
}
}
break;
case 'quick_publish':
await quickPublishAsNew();
break;
/* Removed publish_to_gallery from workflow actions as it's not supported in type */
case 'download_image':
if (images.length > 0) {
const lastImage = images[images.length - 1];
await handleDownloadImage(lastImage);
}
break;
case 'enhance_image':
if (images.filter(img => img.selected).length > 0) {
setPrompt('Enhance and improve this image with better quality and details');
await generateImage();
}
break;
case 'apply_style':
if (images.filter(img => img.selected).length > 0) {
setPrompt('Apply artistic style transformation to this image');
await generateImage();
}
break;
}
// Small delay between actions
await new Promise(resolve => setTimeout(resolve, 500));
}
toast.success(`✅ Workflow "${workflow.name}" completed successfully!`);
logger.success(`Workflow "${workflow.name}" completed successfully`);
} catch (error: any) {
console.error('Error executing workflow:', error);
logger.error(`Workflow "${workflow.name}" failed: ${error.message}`);
toast.error(`Workflow failed: ${error.message}`);
}
};
const handleSaveCurrentPromptAsTemplate = () => {
const currentPrompt = lightboxOpen ? lightboxPrompt : prompt;
if (!currentPrompt.trim()) {
toast.error(translate('Please enter a prompt first'));
return;
}
setShowSaveTemplateDialog(true);
};
const confirmSaveTemplate = async () => {
if (!newTemplateName.trim()) {
toast.error(translate('Please enter a template name'));
return;
}
if (!user?.id) {
toast.error(translate('User not authenticated'));
return;
}
const currentPrompt = lightboxOpen ? lightboxPrompt : prompt;
const newTemplates = [...promptTemplates, { name: newTemplateName.trim(), template: currentPrompt }];
await savePromptTemplates(user.id, newTemplates);
setPromptTemplates(newTemplates);
setNewTemplateName("");
setShowSaveTemplateDialog(false);
};
const toggleImageSelection = (imageId: string, isMultiSelect = false, fromGallery = false) =>
toggleImageSelectionUtil(imageId, isMultiSelect, fromGallery, availableImages, images, setImages);
const removeImage = (imageId: string) =>
removeImageRequest(imageId, setImageToDelete, setShowDeleteConfirmDialog);
const confirmDeleteImage = () =>
confirmDeleteImageUtil(imageToDelete, setImages, setImageToDelete, setShowDeleteConfirmDialog, initialImages, loadFamilyVersions);
const deleteSelectedImages = () => {
const selectedImages = images.filter(img => img.selected);
if (selectedImages.length === 0) {
toast.error(translate('No images selected'));
return;
}
// Show confirmation for bulk delete
const count = selectedImages.length;
setShowDeleteConfirmDialog(true);
setImageToDelete(`bulk:${count}`); // Special marker for bulk delete
};
const confirmBulkDelete = () => {
const selectedImages = images.filter(img => img.selected);
const selectedIds = selectedImages.map(img => img.id);
// Remove all selected images
setImages(prev => prev.filter(img => !selectedIds.includes(img.id)));
toast.success(translate(`${selectedImages.length} image(s) deleted`));
logger.info(`Deleted ${selectedImages.length} selected images`);
setShowDeleteConfirmDialog(false);
setImageToDelete(null);
};
const setAsSelected = (imageId: string) =>
setAsSelectedUtil(imageId, images, setImages, initialImages, loadFamilyVersions);
const handleMicrophone = () =>
handleMicrophoneUtil(
isRecording,
mediaRecorderRef,
setIsRecording,
setIsTranscribing,
audioChunksRef,
lightboxOpen,
setLightboxPrompt,
setPrompt
);
const handleOptimizePrompt = () =>
handleOptimizePromptUtil(
lightboxOpen ? lightboxPrompt : prompt,
lightboxOpen,
setLightboxPrompt,
setPrompt,
setIsOptimizingPrompt
);
const handleVoiceTranscription = (transcribedText: string) => {
setPrompt(transcribedText);
toast.success(translate('Voice transcribed successfully!'));
};
const handlePresetSelect = (preset: PromptPreset) => {
setSelectedPreset(preset);
toast.success(`Preset "${preset.name}" activated as context`);
};
const handlePresetClear = () => {
setSelectedPreset(null);
toast.info(translate('Preset context cleared'));
};
const handleVoiceToImage = (transcribedText: string) =>
handleVoiceToImageUtil(
transcribedText,
user?.id,
selectedPreset,
selectedModel,
setPrompt,
setImages,
setPostTitle,
setPostDescription,
voicePopupRef,
logger
);
const handleAgentGeneration = () =>
handleAgentGenerationUtil(
prompt,
user?.id,
buildFullPrompt(selectedPreset, prompt),
selectedModel,
setIsAgentMode,
setIsGenerating,
setImages,
setPostTitle,
setPostDescription,
logger
);
const handlePublishToGallery = async () => {
await publishToGalleryUtil({
user: user,
generatedImage: typeof generatedImage === 'string' ? generatedImage : (generatedImage as any)?.src || null,
images: images,
lightboxOpen: lightboxOpen,
currentImageIndex: currentImageIndex,
postTitle: '',
postDescription: '',
prompt: prompt,
isOrgContext: false,
orgSlug: null,
onPublish: (url) => {
// No navigation needed for gallery publish, maybe just stay or go specific place?
// For now stay in wizard or close? Implementation plan said "Verify success message" so staying might be fine, but closing is better UX usually.
// Let's close.
onClose();
navigate('/'); // Or to gallery view if possible
}
}, setIsPublishing);
};
const handleAppendToPost = () => {
setShowPostPicker(true);
};
const handlePostSelected = async (postId: string) => {
setShowPostPicker(false);
// Switch to edit mode for this post
// This logic mimics CreationWizardPopup's handlePostSelected
try {
const toastId = toast.loading(translate('Loading post...'));
// Fetch full post via API (returns FeedPost with pictures)
const post = await fetchPostById(postId);
if (!post) throw new Error('Post not found');
// Transform existing pictures
const existingImages = (post.pictures || [])
.sort((a: any, b: any) => ((a.position || 0) - (b.position || 0)))
.map((p: any) => ({
id: p.id,
path: p.id,
src: p.image_url,
title: p.title,
description: p.description || '',
selected: false,
realDatabaseId: p.id,
type: p.type || 'image',
isGenerated: false
}));
// Merge current wizard images as new
const newImages = images.map(img => ({
...img,
selected: true
}));
const combinedImages = [...existingImages, ...newImages];
setImages(combinedImages);
setPostTitle(post.title);
setPostDescription(post.description || '');
navigate('/wizard', {
state: {
mode: 'post',
editingPostId: postId,
initialImages: combinedImages,
postTitle: post.title,
postDescription: post.description
},
replace: true
});
toast.dismiss(toastId);
toast.success(translate('Switched to post editing mode'));
} catch (err) {
console.error("Error loading post for append:", err);
toast.error(translate('Failed to load post'));
}
};
const executeQuickAction = (action: QuickAction) => {
const selectedImages = images.filter(img => img.selected);
if (selectedImages.length === 0) {
toast.error(translate('Please select at least one image for this action'));
return;
}
setPrompt(action.prompt);
};
const openEditActionsDialog = () => {
setEditingActions([...quickActions]);
setShowEditActionsDialog(true);
};
const addQuickAction = () => {
const newAction: QuickAction = {
id: `action_${Date.now()}`,
name: 'New Action',
prompt: '',
icon: '⭐'
};
setEditingActions([...editingActions, newAction]);
};
const updateQuickAction = (id: string, field: keyof QuickAction, value: string) => {
setEditingActions(editingActions.map(action =>
action.id === id ? { ...action, [field]: value } : action
));
};
const deleteQuickAction = (id: string) => {
setEditingActions(editingActions.filter(action => action.id !== id));
};
const saveEditedActions = async () => {
// Validate
const invalid = editingActions.find(a => !a.name.trim() || !a.prompt.trim());
if (invalid) {
toast.error(translate('All actions must have a name and prompt'));
return;
}
if (!user?.id) {
toast.error(translate('User not authenticated'));
return;
}
await saveQuickActions(user.id, editingActions);
setQuickActions(editingActions);
setShowEditActionsDialog(false);
};
const handleLightboxPromptSubmit = async (promptText: string) => {
if (!lightboxOpen || currentImageIndex >= images.length) return;
// Set the prompt and get the current lightbox image for editing
setPrompt(promptText);
const targetImage = images[currentImageIndex];
if (targetImage) {
// Store current lightbox state
const wasLightboxOpen = lightboxOpen;
// Directly pass the target image to avoid state timing issues
await generateImageWithSpecificImage(promptText, targetImage);
setLightboxPrompt(""); // Clear the lightbox prompt
// Keep lightbox open if it was open during generation
if (wasLightboxOpen && !lightboxOpen) {
setLightboxOpen(true);
}
}
};
const openLightbox = (index: number) => {
setCurrentImageIndex(index);
setLightboxOpen(true);
// Set the parent ID for version creation when opening lightbox
const currentImage = images[index];
let parentId = null;
if (currentImage) {
// If this image has a parentForNewVersions (from version map), use that
if (currentImage.parentForNewVersions) {
parentId = currentImage.parentForNewVersions;
}
// If this is the initial image from a database post, use its real ID
else if (currentImage.realDatabaseId) {
parentId = currentImage.realDatabaseId;
}
// If this is a saved image (has UUID), use it as parent
else if (currentImage.id && currentImage.id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
parentId = currentImage.id;
}
// Otherwise, find the first non-generated image in the array
else {
const originalImage = images.find(img => !img.isGenerated && img.id && img.id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i));
parentId = originalImage?.id || null;
}
}
setSelectedOriginalImageId(parentId);
// Pre-populate lightbox prompt with main prompt for quick publish context
// Only if lightbox prompt is empty to avoid overwriting user's edits
if (!lightboxPrompt.trim() && prompt.trim()) {
setLightboxPrompt(prompt);
}
};
// Auto-update lightbox when images change
useEffect(() => {
if (lightboxOpen && currentImageIndex >= images.length && images.length > 0) {
// If current index is out of bounds, go to the last image (newest)
const newIndex = images.length - 1;
setCurrentImageIndex(newIndex);
}
}, [images.length, lightboxOpen, currentImageIndex]);
const generateImageWithSpecificImage = async (promptText: string, targetImage: ImageFile) => {
if (!isRetryCallRef.current) { cancelRetry(); }
isRetryCallRef.current = false;
setIsGenerating(true);
setLastError(null);
try {
let result: { imageData: ArrayBuffer; text?: string } | null = null;
// Resolve correct API key for the selected provider
let apiKey: string | undefined = undefined;
if (user?.id) {
const secrets = await getUserSecrets(user.id);
if (secrets) {
const provider = selectedModel.split('/')[0]?.toLowerCase();
switch (provider) {
case 'aimlapi': apiKey = secrets.aimlapi_api_key; break;
case 'replicate': apiKey = secrets.replicate_api_key; break;
case 'bria': apiKey = secrets.bria_api_key; break;
default: apiKey = secrets.google_api_key; break;
}
}
}
// Edit the specific target image
if (targetImage.file) {
result = await editImage(
promptText,
[targetImage.file],
selectedModel,
apiKey,
aspectRatio,
resolution,
searchGrounding
);
} else {
// Convert any image source (data URL or HTTP URL) to File for editing
try {
const response = await fetch(targetImage.src);
const blob = await response.blob();
const file = new File([blob], targetImage.title || 'image.png', {
type: blob.type || 'image/png'
});
result = await editImage(
promptText,
[file],
selectedModel,
apiKey,
aspectRatio,
resolution,
searchGrounding
);
} catch (error) {
console.error('Error converting image:', error);
toast.error(translate('Failed to convert image for editing'));
return;
}
}
if (result) {
// Convert ArrayBuffer to base64 data URL
const uint8Array = new Uint8Array(result.imageData);
const blob = new Blob([uint8Array], { type: 'image/png' });
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
// Add generated image to the images list and auto-select it
const newImage: ImageFile = {
id: `generated-${Date.now()}`,
src: dataUrl,
title: promptText.substring(0, 50) + (promptText.length > 50 ? '...' : ''),
selected: true,
isGenerated: true,
aiText: result.text, // Store AI description
parentForNewVersions: targetImage.parentForNewVersions || targetImage.realDatabaseId || (targetImage.id && /^[0-9a-f]{8}-/.test(targetImage.id) ? targetImage.id : undefined)
};
const newIndex = images.length; // Calculate the new index BEFORE updating state
// Deselect all other images and add the new one as selected
setImages(prev => {
const updated = [...prev.map(img => ({ ...img, selected: false })), newImage];
// If lightbox is open, update to show the new image immediately after state update
if (lightboxOpen) {
// Use setTimeout to ensure React has processed the state update
setTimeout(() => {
setCurrentImageIndex(newIndex);
}, 50); // Reduced timeout
}
return updated;
});
};
reader.readAsDataURL(blob);
}
} catch (error: any) {
console.error('Error generating image:', error);
const errMsg = error?.message || String(error);
// Extract meaningful message from GoogleGenerativeAI errors
// Pattern: "[GoogleGenerativeAI Error]: Error fetching from <url>: [503 ] Actual message"
const httpMatch = errMsg.match(/\[\d{3}\s*\]\s*(.+)/s);
const userMessage = httpMatch ? httpMatch[1].trim() : errMsg.replace(/\[GoogleGenerativeAI Error\][:\s]*/i, '').trim();
setLastError(userMessage || 'Failed to generate image');
// Auto-retry for "high demand" / rate-limit errors
if (userMessage.toLowerCase().includes('later')) {
scheduleRetry(() => generateImageWithSpecificImage(promptText, targetImage), userMessage);
} else {
toast.error(userMessage || translate('Failed to generate image'));
}
} finally {
setIsGenerating(false);
}
};
const generateImageSplit = async () => {
await generateImageSplitUtil(
prompt,
images,
selectedModel,
abortControllerRef,
setIsGenerating,
setImages,
async (promptText: string) => {
if (user?.id) {
await addToPromptHistory(user.id, promptText, setPromptHistory, setHistoryIndex);
}
},
logger
);
};
const generateImage = async () => {
const fullPrompt = buildFullPrompt(selectedPreset, prompt);
if (!fullPrompt.trim()) {
toast.error(translate('Please enter a prompt'));
return;
}
// Add to history before generating
if (prompt.trim() && user?.id) {
await addToPromptHistory(user.id, prompt.trim(), setPromptHistory, setHistoryIndex);
}
// Create new abort controller
abortControllerRef.current = new AbortController();
// Clear retry state only for manual triggers (not auto-retries)
if (!isRetryCallRef.current) { cancelRetry(); }
isRetryCallRef.current = false;
setLastError(null);
setIsGenerating(true);
logger.info(`Starting image generation with prompt: "${fullPrompt.substring(0, 50)}..."`);
// Create placeholder image with loading state
const placeholderId = `placeholder-${Date.now()}`;
const placeholderImage: ImageFile = {
id: placeholderId,
src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', // Transparent pixel — spinner overlay provides visual
title: 'Generating...',
selected: false,
isGenerated: false,
};
// Remember which images were selected before we deselect for the placeholder
const previouslySelectedIds = images.filter(img => img.selected).map(img => img.id);
setImages(prev => [...prev.map(img => ({ ...img, selected: false })), placeholderImage]);
try {
// Check if aborted
if (abortControllerRef.current.signal.aborted) {
// Remove placeholder on abort
setImages(prev => prev.filter(img => img.id !== placeholderId));
return;
}
// Get API Key resolve the correct key based on the selected model's provider
let apiKey: string | undefined = undefined;
if (user?.id) {
const secrets = await getUserSecrets(user.id);
if (secrets) {
const provider = selectedModel.split('/')[0]?.toLowerCase();
switch (provider) {
case 'aimlapi': apiKey = secrets.aimlapi_api_key; break;
case 'replicate': apiKey = secrets.replicate_api_key; break;
case 'bria': apiKey = secrets.bria_api_key; break;
default: apiKey = secrets.google_api_key; break;
}
}
}
const selectedImages = images.filter(img => img.selected);
let result: { imageData: ArrayBuffer; text?: string } | null = null;
if (selectedImages.length > 0) {
// Edit existing images - ensure we have File objects for ALL selected images
const files = selectedImages
.map(img => img.file)
.filter((file): file is File => file !== undefined);
// Check if ALL selected images have File objects
if (files.length === selectedImages.length) {
// All images have files, use them directly
result = await editImage(
fullPrompt,
files,
selectedModel,
apiKey,
aspectRatio,
resolution,
searchGrounding
);
} else {
// Some images don't have File objects, convert all from src to ensure consistency
const convertedFiles: File[] = [];
for (const img of selectedImages) {
try {
const response = await fetch(img.src);
const blob = await response.blob();
const file = new File([blob], img.title || 'image.png', {
type: blob.type || 'image/png'
});
convertedFiles.push(file);
} catch (error) {
console.error('Error converting image:', img.title, error);
}
}
if (convertedFiles.length > 0) {
result = await editImage(
fullPrompt,
convertedFiles,
selectedModel,
apiKey,
aspectRatio,
resolution,
searchGrounding
);
} else {
toast.error(translate('Failed to convert selected images for editing'));
return;
}
}
} else {
// Generate new image
result = await createImage(
fullPrompt,
selectedModel,
apiKey,
aspectRatio,
resolution,
searchGrounding
);
}
if (result) {
logger.success('Image generated successfully');
// Convert ArrayBuffer to base64 data URL
const uint8Array = new Uint8Array(result.imageData);
const blob = new Blob([uint8Array], { type: 'image/png' });
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
setGeneratedImage(dataUrl);
// Determine parent ID inheritance from selected image (if any)
let inheritedParentId: string | undefined;
if (selectedImages.length === 1) {
// If selected image is a Real DB image, it becomes the parent.
// If it's a generated image that has a parent pointer, we inherit that (sibling/child logic).
// However, strictly speaking, if we edit 'A', 'B' is a child of 'A'.
// But if 'A' is unsaved, we can't link to it. We fall back to 'A's parent if available.
inheritedParentId = selectedImages[0].realDatabaseId || selectedImages[0].parentForNewVersions;
}
// Add generated image to the images list and auto-select it
const newImage: ImageFile = {
id: `generated-${Date.now()}`,
src: dataUrl,
title: (selectedPreset ? `[${selectedPreset.name}] ` : '') + prompt.substring(0, 40) + (prompt.length > 40 ? '...' : ''),
selected: true,
isGenerated: true,
aiText: result.text, // Store AI description
parentForNewVersions: inheritedParentId // Propagate parent ID
};
logger.debug(`Added new image: ${newImage.title}`);
const newIndex = images.length; // Calculate the new index BEFORE updating state
// Replace placeholder with actual image
setImages(prev => {
const withoutPlaceholder = prev.filter(img => img.id !== placeholderId);
const updated = [...withoutPlaceholder.map(img => ({ ...img, selected: false })), newImage];
// If lightbox is open, update to show the new image immediately after state update
if (lightboxOpen) {
// Use setTimeout to ensure React has processed the state update
setTimeout(() => {
setCurrentImageIndex(newIndex);
}, 50); // Reduced timeout
}
return updated;
});
};
reader.readAsDataURL(blob);
}
} catch (error: any) {
console.error('Error generating image:', error);
const errMsg = error?.message || String(error);
logger.error(`Failed to generate image: ${errMsg}`);
// Extract meaningful message from GoogleGenerativeAI errors
// Pattern: "[GoogleGenerativeAI Error]: Error fetching from <url>: [503 ] Actual message"
const httpMatch = errMsg.match(/\[\d{3}\s*\]\s*(.+)/s);
const userMessage = httpMatch ? httpMatch[1].trim() : errMsg.replace(/\[GoogleGenerativeAI Error\][:\s]*/i, '').trim();
setLastError(userMessage || 'Failed to generate image');
// Auto-retry for "high demand" / rate-limit errors
if (userMessage.toLowerCase().includes('later')) {
scheduleRetry(() => generateImage(), userMessage);
} else {
toast.error(userMessage || translate('Failed to generate image'));
}
// Remove placeholder on error and restore previous selection
setImages(prev => prev
.filter(img => img.id !== placeholderId)
.map(img => ({ ...img, selected: previouslySelectedIds.includes(img.id) }))
);
} finally {
setIsGenerating(false);
}
};
const publishImage = () =>
publishImageUtil(
{
user,
generatedImage,
images,
lightboxOpen,
currentImageIndex,
postTitle,
prompt,
isOrgContext: false,
orgSlug: null,
onPublish,
},
setIsPublishing
);
// Quick publish function that uses prompt as description
const quickPublishAsNew = () =>
quickPublishAsNewUtil(
{
user,
generatedImage,
images,
lightboxOpen,
currentImageIndex,
postTitle,
prompt,
isOrgContext: false,
orgSlug: null,
onPublish,
},
setIsPublishing
);
const handleAddToPost = async (imageSrc: string, title: string, description?: string) => {
if (!currentEditingPostId) {
toast.error("No active post to add to");
return;
}
if (!user) {
toast.error(translate('User not authenticated'));
return;
}
setIsPublishing(true);
try {
// Fetch the image
const response = await fetch(imageSrc);
const blob = await response.blob();
await wizardDb.publishImageToPost({
userId: user.id,
blob,
title: title || prompt, // Fallback to prompt if no title
description: description,
postId: currentEditingPostId,
isOrgContext: false,
orgSlug: null,
collectionIds: []
});
// Navigate back success
navigate(`/post/${currentEditingPostId}`);
} catch (error: any) {
console.error('Error adding to post:', error);
// Reset loading state
if (currentImageIndex !== -1) {
setImages(prev => {
const newImgs = [...prev];
// Only reset if it still exists at that index (simple check)
if (newImgs[currentImageIndex]) {
newImgs[currentImageIndex] = { ...newImgs[currentImageIndex], isAddingToPost: false };
}
return newImgs;
});
}
if (error.code === '23503') {
toast.error(translate('Could not add to this post. The post might have been deleted or the link is invalid.'));
} else {
toast.error(translate('Failed to add image to post'));
}
}
};
const handleLightboxPublish = async (option: 'overwrite' | 'new' | 'version' | 'add-to-post', imageUrl: string, title: string, description?: string, parentId?: string, collectionIds?: string[]) => {
if (!user) {
toast.error(translate('User not authenticated'));
return;
}
if (currentImageIndex >= images.length) {
toast.error(translate('No image selected'));
return;
}
const currentImage = images[currentImageIndex];
if (!currentImage) {
toast.error(translate('No image available to publish'));
return;
}
// Handle Add to Post
if (option === 'add-to-post' && currentEditingPostId) {
await handleAddToPost(currentImage.src, title, description);
setShowLightboxPublishDialog(false);
return;
}
setIsPublishing(true);
try {
// Convert image to blob for upload
const response = await fetch(currentImage.src);
const blob = await response.blob();
if (option === 'overwrite') {
toast.info(translate('Overwrite not supported in wizard, creating new image instead'));
option = 'new';
}
if (option === 'new') {
await wizardDb.publishImageAsNew({
userId: user.id,
blob,
title,
description,
isOrgContext: false,
orgSlug: null,
collectionIds,
});
} else if (option === 'version' && parentId) {
await wizardDb.publishImageAsVersion({
userId: user.id,
blob,
title,
description,
parentId,
isOrgContext: false,
orgSlug: null,
collectionIds,
});
} else if (option === 'version' && !parentId) {
toast.info(translate('No parent image found, creating as new post instead'));
// Recursive call with 'new'
// FIX: call self but ensure we don't loop if logic is wrong.
// Here it changes option to 'new', so it should be fine.
// We need to re-call handleLightboxPublish logic essentially.
// For simplicity, just run the new logic here:
await wizardDb.publishImageAsNew({
userId: user.id,
blob,
title,
description,
isOrgContext: false,
orgSlug: null,
collectionIds,
});
}
setShowLightboxPublishDialog(false);
// Only navigate/close if NOT "Add to Post" (which handled navigation)
// For standard publish, we usually close wizard or call onPublish prop
onPublish?.(currentImage.src, lightboxPrompt);
} catch (error) {
console.error('Error publishing image:', error);
toast.error(translate('Failed to publish image'));
} finally {
setIsPublishing(false);
}
};
const handleEditImage = (index: number) => {
const img = images[index];
if (img) {
setEditingImage({ url: img.src, id: img.realDatabaseId || img.id });
}
};
const handleConfigureImage = (index: number) => {
const img = images[index];
if (img && img.realDatabaseId) {
setConfiguringImageId({
id: img.realDatabaseId,
title: img.title,
description: img.aiText || null,
visible: true // Default
});
} else {
toast.error(translate("Please save the image first to configure settings"));
}
};
// Prepare context value
const contextValue = {
// User
userId: user?.id,
// Images
images,
setImages,
availableImages,
generatedImage,
// Generation state
isGenerating,
isAgentMode,
isSplitMode,
setIsSplitMode,
isOptimizingPrompt,
// Form state
prompt,
setPrompt,
postTitle,
setPostTitle,
selectedModel,
setSelectedModel,
aspectRatio,
setAspectRatio,
resolution,
setResolution,
searchGrounding,
setSearchGrounding,
// Settings
selectedPreset,
promptPresets,
loadingPresets,
workflows,
loadingWorkflows,
promptTemplates,
quickActions,
// Prompt history
promptHistory,
historyIndex,
setHistoryIndex,
// Voice
isRecording,
isTranscribing,
// UI state
isPublishing,
dragIn,
setDragIn,
loadingImages,
dragLeaveTimeoutRef,
// Actions - Image operations
toggleImageSelection,
openLightbox,
setAsSelected,
removeImage,
deleteSelectedImages,
// Actions - Generation
generateImage,
generateImageSplit,
handleAgentGeneration,
handleOptimizePrompt,
handleMicrophone,
// Actions - Settings
handlePresetSelect,
handlePresetClear,
savePreset: async (preset: Omit<PromptPreset, 'id' | 'createdAt'>) => {
if (user?.id) await savePromptPreset(user.id, preset, setPromptPresets);
},
updatePreset: async (id: string, preset: Omit<PromptPreset, 'id' | 'createdAt'>) => {
if (user?.id) await updatePromptPreset(user.id, id, preset, setPromptPresets);
},
deletePreset: async (id: string) => {
if (user?.id) await deletePromptPreset(user.id, id, setPromptPresets);
},
saveWorkflow: async (workflow: Omit<Workflow, 'id' | 'createdAt'>) => {
if (user?.id) await saveWorkflow(user.id, workflow, setWorkflows);
},
updateWorkflow: async (id: string, workflow: Omit<Workflow, 'id' | 'createdAt'>) => {
if (user?.id) await updateWorkflow(user.id, id, workflow, setWorkflows);
},
deleteWorkflow: async (id: string) => {
if (user?.id) await deleteWorkflow(user.id, id, setWorkflows);
},
executeWorkflow,
applyTemplate,
deleteTemplate,
handleSaveCurrentPromptAsTemplate,
executeQuickAction,
openEditActionsDialog,
navigateHistory: (direction: 'up' | 'down') =>
navigatePromptHistory(direction, promptHistory, historyIndex, setHistoryIndex, setPrompt),
// Actions - Publishing
quickPublishAsNew,
publishImage,
// Actions - Misc
setShowVoicePopup,
// Logger
logger,
// Add To Post State
currentEditingPostId,
handleAddToPost: (imageSrc: string) => handleAddToPost(imageSrc, postTitle || prompt, postDescription)
};
if (!isOpen) return null;
return (
<WizardProvider value={contextValue}>
{/* Editor Overlay */}
{editingImage && (
<div className="fixed inset-0 z-[60] bg-background">
<ImageEditor
imageUrl={editingImage.url}
pictureId={editingImage.id}
onClose={() => setEditingImage(null)}
onSave={(newUrl) => {
setImages(prev => prev.map(img =>
(img.id === editingImage.id || img.realDatabaseId === editingImage.id)
? { ...img, src: newUrl, isGenerated: false }
: img
));
setEditingImage(null);
}}
/>
</div>
)}
{/* Settings Modal */}
{configuringImageId && (
<EditImageModal
open={!!configuringImageId}
onOpenChange={(open) => !open && setConfiguringImageId(null)}
pictureId={configuringImageId.id}
currentTitle={configuringImageId.title}
currentDescription={configuringImageId.description}
currentVisible={configuringImageId.visible}
imageUrl={images.find(i => i.realDatabaseId === configuringImageId.id)?.src}
onUpdateSuccess={() => {
setConfiguringImageId(null);
toast.success(translate("Image settings updated"));
}}
/>
)}
<div className="space-y-4 p-2 md:space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 md:gap-3">
<Button variant="ghost" size="sm" onClick={onClose} className="h-10 w-10 md:h-9 md:w-9 p-0 touch-manipulation">
<ArrowLeft className="h-5 w-5 md:h-4 md:w-4" />
</Button>
<div className="flex items-center gap-2">
<Wand2 className="h-5 w-5 md:h-6 md:w-6 text-primary" />
<h1 className="text-xl md:text-2xl font-bold">
{mode === 'post' ? <T>Post Wizard</T> : <T>AI Image Wizard</T>}
</h1>
</div>
</div>
{/* Unified Lightbox Component */}
<ImageLightbox
isOpen={lightboxOpen && images.length > 0}
onClose={() => setLightboxOpen(false)}
imageUrl={images[currentImageIndex]?.src || ''}
imageTitle={images[currentImageIndex]?.title || 'Generated Image'}
originalImageId={selectedOriginalImageId || undefined}
onPromptSubmit={(promptText) => handleLightboxPromptSubmit(promptText)}
onPublish={handleLightboxPublish}
isGenerating={isGenerating}
isPublishing={isPublishing}
showPrompt={true}
showPublish={images[currentImageIndex]?.isGenerated}
generatedImageUrl={undefined}
currentIndex={currentImageIndex}
totalCount={images.length}
onNavigate={(direction) => {
const newIndex = direction === 'next' ? currentImageIndex + 1 : currentImageIndex - 1;
if (newIndex >= 0 && newIndex < images.length) {
setCurrentImageIndex(newIndex);
}
}}
// Wizard features
showWizardFeatures={true}
promptTemplates={promptTemplates}
onApplyTemplate={(template) => setLightboxPrompt(template)}
onSaveTemplate={handleSaveCurrentPromptAsTemplate}
onDeleteTemplate={deleteTemplate}
onOptimizePrompt={handleOptimizePrompt}
isOptimizing={isOptimizingPrompt}
onMicrophoneToggle={handleMicrophone}
isRecording={isRecording}
isTranscribing={isTranscribing}
showQuickPublish={images[currentImageIndex]?.isGenerated && lightboxPrompt.trim().length > 0}
onQuickPublish={quickPublishAsNew}
prompt={lightboxPrompt}
onPromptChange={setLightboxPrompt}
// Prompt history
promptHistory={promptHistory}
historyIndex={historyIndex}
onNavigateHistory={(direction) => navigatePromptHistory(direction, promptHistory, historyIndex, setHistoryIndex, setLightboxPrompt)}
onManualPromptEdit={() => setHistoryIndex(-1)}
/>
</div>
{/* Logger Panel - Collapsible */}
{isLoggerVisible && (
<div className="mb-4 md:mb-6">
<div className="h-48 md:h-64">
<LogViewer onClose={() => setLoggerVisible(false)} />
</div>
</div>
)}
<div className={mode === 'post' ? "h-[calc(100vh-140px)]" : "grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6"}>
{mode === 'post' ? (
<PostComposer
images={images}
setImages={setImages}
onRemoveImage={removeImage}
onFileUpload={(event) => handleFileUpload(event, setImages, user)}
dropZoneRef={dropZoneRef}
isDragging={dragIn}
onDragEnter={(e) => handleDragEnter(e, dragLeaveTimeoutRef, setDragIn)}
onDragOver={(e) => handleDragOver(e, dragIn, setDragIn)}
onDragLeave={(e) => handleDragLeave(e, dragLeaveTimeoutRef, setDragIn)}
onDrop={(e) => handleDrop(e, dragLeaveTimeoutRef, setDragIn, setImages, user)}
postTitle={postTitle}
setPostTitle={setPostTitle}
postDescription={postDescription}
setPostDescription={setPostDescription}
isEditing={!!currentEditingPostId}
postId={currentEditingPostId}
settings={settings}
setSettings={setSettings}
onPublish={() => {
// Auto-inject link into settings if present
const externalPage = images.find(img => img.type === 'page-external');
const publishSettings = { ...settings };
if (externalPage && externalPage.path && !publishSettings.link) {
publishSettings.link = externalPage.path;
}
publishImageUtil({
user: user,
generatedImage: typeof generatedImage === 'string' ? generatedImage : (generatedImage as any)?.src || null,
images: images,
lightboxOpen: lightboxOpen,
currentImageIndex: currentImageIndex,
postTitle: postTitle,
postDescription: postDescription,
settings: publishSettings, // Pass enriched settings
prompt: prompt,
isOrgContext: false,
orgSlug: null,
publishAll: mode === 'post',
editingPostId: currentEditingPostId,
onPublish: (url, postId) => {
onClose();
// If we have a postId (passed as 2nd arg for posts), navigate to it
if (postId && (mode === 'post' || currentEditingPostId)) {
navigate(`/post/${postId}`);
} else {
navigate('/');
}
}
}, setIsPublishing);
}}
onPublishToGallery={handlePublishToGallery}
onAppendToPost={handleAppendToPost}
isPublishing={isPublishing}
/>
) : (
<>
{/* Left Panel - Controls */}
<WizardSidebar
selectedModel={selectedModel}
onModelChange={setSelectedModel}
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
resolution={resolution}
onResolutionChange={setResolution}
searchGrounding={searchGrounding}
onSearchGroundingChange={setSearchGrounding}
selectedPreset={selectedPreset}
presets={promptPresets}
loadingPresets={loadingPresets}
onPresetSelect={handlePresetSelect}
onPresetClear={handlePresetClear}
onSavePreset={(preset) => user?.id && savePromptPreset(user.id, preset, setPromptPresets)}
onUpdatePreset={(id, preset) => user?.id && updatePromptPreset(user.id, id, preset, setPromptPresets)}
onDeletePreset={(id) => user?.id && deletePromptPreset(user.id, id, setPromptPresets)}
workflows={workflows}
loadingWorkflows={loadingWorkflows}
onSaveWorkflow={(workflow) => user?.id && saveWorkflow(user.id, workflow, setWorkflows)}
onUpdateWorkflow={(id, workflow) => user?.id && updateWorkflow(user.id, id, workflow, setWorkflows)}
onDeleteWorkflow={(id) => user?.id && deleteWorkflow(user.id, id, setWorkflows)}
onExecuteWorkflow={executeWorkflow}
isGenerating={isGenerating}
isAgentMode={isAgentMode}
isSplitMode={isSplitMode}
prompt={prompt}
onGenerate={generateImage}
onGenerateSplit={generateImageSplit}
onAgentGenerate={handleAgentGeneration}
onVoiceGenerate={() => setShowVoicePopup(true)}
onAbort={() => { cancelRetry(); abortGeneration(abortControllerRef, setIsGenerating, setIsAgentMode, setImages, logger); }}
images={images}
generatedImage={generatedImage}
postTitle={postTitle}
onPostTitleChange={setPostTitle}
isPublishing={isPublishing}
onQuickPublish={quickPublishAsNew}
onPublish={() => setShowLightboxPublishDialog(true)}
onPublishToGallery={handlePublishToGallery}
onAppendToPost={handleAppendToPost}
onAddToPost={() => {
const currentImage = images[images.length - 1]; // Default to most recent for sidebar action
if (currentImage) handleAddToPost(currentImage.src, postTitle || prompt, postDescription)
}}
editingPostId={currentEditingPostId}
lastError={lastError}
retryInfo={retryInfo}
onDismissError={() => { cancelRetry(); setLastError(null); }}
>
<Prompt
prompt={prompt}
onPromptChange={setPrompt}
isSplitMode={isSplitMode}
onSplitModeChange={setIsSplitMode}
selectedPreset={selectedPreset}
templates={promptTemplates}
onApplyTemplate={applyTemplate}
onSaveTemplate={handleSaveCurrentPromptAsTemplate}
onDeleteTemplate={deleteTemplate}
onOptimizePrompt={handleOptimizePrompt}
isOptimizing={isOptimizingPrompt}
onMicrophoneToggle={handleMicrophone}
isRecording={isRecording}
isTranscribing={isTranscribing}
promptHistory={promptHistory}
historyIndex={historyIndex}
onNavigateHistory={(direction) => navigatePromptHistory(direction, promptHistory, historyIndex, setHistoryIndex, setPrompt)}
onManualEdit={() => setHistoryIndex(-1)}
isGenerating={isGenerating}
onGenerate={generateImage}
onImagePaste={(image) => setImages(prev => [...prev, image])}
/>
</WizardSidebar>
{/* Right Panel - Images */}
<ImageGalleryPanel
images={images}
onImageClick={toggleImageSelection}
onImageDoubleClick={openLightbox}
onFileUpload={(event) => handleFileUpload(event, setImages, user)}
onDeleteSelected={deleteSelectedImages}
onDownload={handleDownloadImage}
onSetAsSelected={setAsSelected}
onSaveAsVersion={(img, idx) => {
setCurrentImageIndex(idx);
const currentImage = images[idx];
let parentId = null;
if (currentImage.parentForNewVersions) {
parentId = currentImage.parentForNewVersions;
} else if (currentImage.realDatabaseId) {
parentId = currentImage.realDatabaseId;
} else {
const originalImage = images.find(img => !img.isGenerated && img.id && img.id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i));
parentId = originalImage?.id || null;
}
setSelectedOriginalImageId(parentId);
setShowLightboxPublishDialog(true);
}}
onEdit={handleEditImage}
onRemove={removeImage}
availableImages={availableImages}
loadingImages={loadingImages}
onGalleryImageSelect={(imageId, isMultiSelect) => toggleImageSelection(imageId, isMultiSelect, true)}
quickActions={quickActions}
onExecuteAction={executeQuickAction}
onEditActions={openEditActionsDialog}
onSettings={handleConfigureImage}
isGenerating={isGenerating}
dragIn={dragIn}
onDragEnter={(e) => handleDragEnter(e, dragLeaveTimeoutRef, setDragIn)}
onDragOver={(e) => handleDragOver(e, dragIn, setDragIn)}
onDragLeave={(e) => handleDragLeave(e, dragLeaveTimeoutRef, setDragIn)}
onDrop={(e) => handleDrop(e, dragLeaveTimeoutRef, setDragIn, setImages, user)}
dropZoneRef={dropZoneRef}
onAddToPost={(image) => handleAddToPost(image.src, postTitle || prompt, postDescription)}
editingPostId={currentEditingPostId}
/>
</>
)}
</div>
<Dialog open={showPostPicker} onOpenChange={setShowPostPicker}>
<DialogContent className="sm:max-w-[800px] h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle><T>Select a Post to Append To</T></DialogTitle>
<DialogDescription>
<T>Choose one of your existing posts to add these images to.</T>
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden min-h-0">
<PostPicker onSelect={handlePostSelected} />
</div>
</DialogContent>
</Dialog>
{/* Lightbox Publish Dialog */}
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteConfirmDialog} onOpenChange={setShowDeleteConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{imageToDelete?.startsWith('bulk:') ? (
<T>Delete Multiple Images</T>
) : (
<T>Delete Image Version</T>
)}
</AlertDialogTitle>
<AlertDialogDescription>
{imageToDelete?.startsWith('bulk:') ? (
<>
<T>Are you sure you want to delete {imageToDelete.split(':')[1]} selected image(s)? This action cannot be undone.</T>
</>
) : (
<T>Are you sure you want to delete this image version? This action cannot be undone and will permanently remove the image from your account.</T>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowDeleteConfirmDialog(false)}>
<T>Cancel</T>
</AlertDialogCancel>
<AlertDialogAction
onClick={imageToDelete?.startsWith('bulk:') ? confirmBulkDelete : confirmDeleteImage}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<T>Delete</T>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<PublishDialog
isOpen={showLightboxPublishDialog}
onClose={() => setShowLightboxPublishDialog(false)}
onPublish={(option, title, description, parentId, collectionIds) => {
const currentImage = images[currentImageIndex];
if (currentImage) {
// Now accepts 'add-to-post' as option
handleLightboxPublish(option as any, currentImage.src, title || '', description, parentId, collectionIds);
}
}}
originalTitle={images[currentImageIndex]?.title || 'Generated Image'}
originalImageId={selectedOriginalImageId || (originalImageId && originalImageId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) ? originalImageId : undefined)}
isPublishing={isPublishing}
editingPostId={currentEditingPostId} // Pass prop
/>
{/* Save Template Dialog */}
<Dialog open={showSaveTemplateDialog} onOpenChange={setShowSaveTemplateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle><T>Save Prompt Template</T></DialogTitle>
<DialogDescription>
<T>Give your prompt template a name to save it for later use.</T>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium"><T>Template Name</T></label>
<Input
value={newTemplateName}
onChange={(e) => setNewTemplateName(e.target.value)}
placeholder={translate("e.g. Cyberpunk Portrait")}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
confirmSaveTemplate();
}
}}
autoFocus
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground"><T>Prompt Preview</T></label>
<div className="text-sm p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
{lightboxOpen ? lightboxPrompt : prompt}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowSaveTemplateDialog(false)}>
<T>Cancel</T>
</Button>
<Button onClick={confirmSaveTemplate} disabled={!newTemplateName.trim()}>
<Save className="h-4 w-4 mr-2" />
<T>Save Template</T>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Voice Recording Popup */}
<VoiceRecordingPopup
ref={voicePopupRef}
isOpen={showVoicePopup}
onClose={() => setShowVoicePopup(false)}
onTranscriptionComplete={handleVoiceTranscription}
onGenerateImage={handleVoiceToImage}
showToolCalls={true}
/>
{/* Edit Quick Actions Dialog */}
<Dialog open={showEditActionsDialog} onOpenChange={setShowEditActionsDialog}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle><T>Edit Quick Actions</T></DialogTitle>
<DialogDescription>
<T>Customize the quick actions that appear above your images. Add, edit, or remove actions.</T>
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
{editingActions.map((action, index) => (
<div key={action.id} className="flex gap-2 items-start p-3 bg-muted/50 rounded-lg border">
<Input
placeholder="Icon (emoji)"
value={action.icon}
onChange={(e) => updateQuickAction(action.id, 'icon', e.target.value)}
className="w-16 text-center"
maxLength={2}
/>
<Input
placeholder="Name"
value={action.name}
onChange={(e) => updateQuickAction(action.id, 'name', e.target.value)}
className="flex-1"
maxLength={30}
/>
<Input
placeholder="Prompt"
value={action.prompt}
onChange={(e) => updateQuickAction(action.id, 'prompt', e.target.value)}
className="flex-[2]"
maxLength={200}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => deleteQuickAction(action.id)}
className="h-9 w-9 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
title="Delete action"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addQuickAction}
className="w-full"
>
<Plus className="h-4 w-4 mr-2" />
<T>Add Quick Action</T>
</Button>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setShowEditActionsDialog(false);
setEditingActions([]);
}}
>
<T>Cancel</T>
</Button>
<Button
type="button"
onClick={() => {
setEditingActions(DEFAULT_QUICK_ACTIONS);
}}
variant="secondary"
>
<T>Reset to Defaults</T>
</Button>
<Button
type="button"
onClick={saveEditedActions}
>
<Save className="w-4 h-4 mr-2" />
<T>Save Actions</T>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</WizardProvider >
);
};
export default ImageWizard;