mono/packages/ui/src/components/ImageWizard.tsx

1876 lines
67 KiB
TypeScript

import React, { useEffect } from "react";
import { supabase } from '@/integrations/supabase/client';
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
// 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('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...'));
const { data: post, error } = await supabase
.from('posts')
.select(`*, pictures (*)`)
.eq('id', postId)
.single();
if (error) throw error;
// Transform existing pictures
const existingImages = (post.pictures || [])
.sort((a: any, b: any) => (a.position - b.position))
.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 || '');
// We need to update the hook state for editingPostId which is destructured as currentEditingPostId
// But we can't update it directly as it comes from props/hook defaults usually.
// `useImageWizardState` initializes it. We might need to force a re-init or just use a local override?
// Actually, `postTitle` and `setPostTitle` etc are from local state returned by hook.
// But `editingPostId` is derived.
// CreationWizardPopup navigates to `/wizard` with state.
// We are ALREADY in the wizard.
// We can re-navigate to self with new state?
navigate('/wizard', {
state: {
mode: 'post',
editingPostId: postId,
initialImages: combinedImages,
postTitle: post.title,
postDescription: post.description
},
replace: true
});
// Since re-navigation might not fully reset state if component doesn't unmount or effect doesn't fire right,
// we might rely on the router state update.
// `useImageWizardState` reads `location.state`.
// So a navigate replace should trigger re-render with new state values if the hook depends on location.
// Let's check `useImageWizardState` implementation...
// We don't have access to it here, but assuming it reacts to location state or we force reload.
// Alternatively, a full window reload is clumsy.
// Let's try navigate replace.
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('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) => {
setIsGenerating(true);
try {
let result: { imageData: ArrayBuffer; text?: string } | null = null;
// Edit the specific target image
if (targetImage.file) {
result = await editImage(
promptText,
[targetImage.file],
selectedModel,
undefined,
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,
undefined,
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) {
console.error('Error generating image:', error);
toast.error(translate('Failed to generate image. Please check your Google API key.'));
} 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();
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/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iI2YwZjBmMCIvPjwvc3ZnPg==', // Gray placeholder
title: 'Generating...',
selected: false,
isGenerated: true,
};
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
let googleApiKey: string | undefined = undefined;
if (user?.id) {
const secrets = await getUserSecrets(user.id);
if (secrets && secrets.google_api_key) {
googleApiKey = secrets.google_api_key;
}
}
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,
googleApiKey,
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,
googleApiKey,
aspectRatio,
resolution,
searchGrounding
);
} else {
toast.error(translate('Failed to convert selected images for editing'));
return;
}
}
} else {
// Generate new image
result = await createImage(
fullPrompt,
selectedModel,
googleApiKey,
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) {
console.error('Error generating image:', error);
logger.error(`Failed to generate image: ${error instanceof Error ? error.message : 'Unknown error'}`);
toast.error(translate('Failed to generate image. Please check your Google API key.'));
// Remove placeholder on error
setImages(prev => prev.filter(img => img.id !== placeholderId));
} 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="container mx-auto p-3 md:p-6 space-y-4 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={() => 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}
>
<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;