diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index e6bd26df..5e9f32b8 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -16,6 +16,7 @@ import { WebSocketProvider } from "@/contexts/WS_Socket"; import { registerAllWidgets } from "@/lib/registerWidgets"; import TopNavigation from "@/components/TopNavigation"; import GlobalDragDrop from "@/components/GlobalDragDrop"; +import { DragDropProvider } from "@/contexts/DragDropContext"; // Register all widgets on app boot registerAllWidgets(); @@ -191,20 +192,23 @@ const App = () => { - - - - - - - - - - - - + + + + + + + + + + + + + + + @@ -213,7 +217,7 @@ const App = () => { - + ); }; diff --git a/packages/ui/src/components/GlobalDragDrop.tsx b/packages/ui/src/components/GlobalDragDrop.tsx index 3f97bd62..f27ffb47 100644 --- a/packages/ui/src/components/GlobalDragDrop.tsx +++ b/packages/ui/src/components/GlobalDragDrop.tsx @@ -1,59 +1,23 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { set } from 'idb-keyval'; import { toast } from 'sonner'; import { Upload } from 'lucide-react'; import { T, translate } from '@/i18n'; import { supabase } from '@/integrations/supabase/client'; +import { useDragDrop } from '@/contexts/DragDropContext'; const GlobalDragDrop = () => { const navigate = useNavigate(); - const [isDragging, setIsDragging] = useState(false); - - const dragCounter = React.useRef(0); + const { isDragging, isLocalZoneActive } = useDragDrop(); useEffect(() => { - - const isValidDrag = (e: DragEvent) => { - const types = e.dataTransfer?.types || []; - if (types.includes('polymech/internal')) return false; - // Allow Files or Links (text/uri-list) or text/plain (often used for links on mobile) - return types.includes('Files') || types.includes('text/uri-list') || types.includes('text/plain'); - }; - - const handleDragEnter = (e: DragEvent) => { - if (!isValidDrag(e)) return; - - e.preventDefault(); - e.stopPropagation(); - dragCounter.current++; - if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) { - setIsDragging(true); - } - }; - - const handleDragLeave = (e: DragEvent) => { - if (!isValidDrag(e)) return; - - e.preventDefault(); - e.stopPropagation(); - dragCounter.current--; - if (dragCounter.current === 0) { - setIsDragging(false); - } - }; - - const handleDragOver = (e: DragEvent) => { - if (!isDragging) return; - e.preventDefault(); - e.stopPropagation(); - }; - const handleDrop = async (e: DragEvent) => { e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - dragCounter.current = 0; + // We attach this to window, so it captures drops that bubble up. + // If a widget handled the drop and stopped propagation, this listener will NOT fire (if bubbling). + // If the widget didn't stop propagation, it will fire. + // We assume widgets stop propagation. const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : []; const url = e.dataTransfer?.getData('text/uri-list') || e.dataTransfer?.getData('text/plain'); @@ -65,9 +29,6 @@ const GlobalDragDrop = () => { const isUrl = url && (url.startsWith('http://') || url.startsWith('https://')); if (supportedFiles.length === 0 && !isUrl) { - // Only error if we really expected something and got nothing valid - // But since we pre-filter on dragEnter, maybe we should just return silently or notify? - // If files were dropped but unsupported type: if (files.length > 0) { toast.error("Unsupported file type. Please drop images or videos."); } @@ -75,15 +36,18 @@ const GlobalDragDrop = () => { } try { - let droppedItems = []; - let text = ''; - let title = ''; - if (supportedFiles.length > 0) { // Normal file workflow - droppedItems = supportedFiles; + await set('share-target', { + files: supportedFiles, + title: '', + text: '', + url: isUrl ? url : '', + timestamp: Date.now() + }); + navigate('/new?shared=true'); } else if (isUrl) { - // URL Workflow + // URL workflow toast.info(translate('Processing link...')); const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333'; @@ -101,29 +65,21 @@ const GlobalDragDrop = () => { const siteInfo = await response.json(); - // Construct a "Virtual File" object to pass to the wizard - // It won't have a real File object, but validation in CreationWizardPopup will need to handle this const virtualItem = { id: crypto.randomUUID(), path: url, - src: siteInfo.page?.image || 'https://picsum.photos/800/600', // Pseudo picture + src: siteInfo.page?.image || 'https://picsum.photos/800/600', title: siteInfo.page?.title || siteInfo.title || url, description: siteInfo.page?.description || siteInfo.description || '', - type: 'page-external', // New type + type: 'page-external', meta: siteInfo, - file: null, // No physical file + file: null, selected: true }; - // We'll pass this array. The IDB structure expects "files" usually, - // but we can adapt CreationWizardPopup to handle objects too. - // Or we hack it by putting it in a special property. - // Let's stick to the 'share-target' schema but adapt the payload. - - // Hack: Store it in a custom format that we'll parse in App.tsx / CreationWizard await set('share-target', { - items: [virtualItem], // prefer 'items' over 'files' moving forward - files: [], // Keep empty to avoid confusion if we used 'files' for real File objects + items: [virtualItem], + files: [], title: virtualItem.title, text: virtualItem.description, url: url, @@ -162,50 +118,25 @@ const GlobalDragDrop = () => { } } - // File Fallback (existing logic) - if (supportedFiles.length > 0) { - await set('share-target', { - files: supportedFiles, - title: '', - text: '', - url: isUrl ? url : '', - timestamp: Date.now() - }); - navigate('/new?shared=true'); - } - } catch (error) { console.error("Error processing dropped files/links:", error); toast.error("Failed to process drop."); } }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - setIsDragging(false); - dragCounter.current = 0; - } - }; - - window.addEventListener('dragenter', handleDragEnter); - window.addEventListener('dragleave', handleDragLeave); - window.addEventListener('dragover', handleDragOver); window.addEventListener('drop', handleDrop); - window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('drop', handleDrop); + }, [navigate]); - return () => { - window.removeEventListener('dragenter', handleDragEnter); - window.removeEventListener('dragleave', handleDragLeave); - window.removeEventListener('dragover', handleDragOver); - window.removeEventListener('drop', handleDrop); - window.removeEventListener('keydown', handleKeyDown); - }; - }, [navigate, isDragging]); + console.log('isDragging', isDragging) + console.log('isLocalZoneActive', isLocalZoneActive) - if (!isDragging) return null; + if (!isDragging || isLocalZoneActive) return null; return ( -
+
diff --git a/packages/ui/src/components/ImageWizard.tsx b/packages/ui/src/components/ImageWizard.tsx index 10f8c170..1fcfbda7 100644 --- a/packages/ui/src/components/ImageWizard.tsx +++ b/packages/ui/src/components/ImageWizard.tsx @@ -183,6 +183,52 @@ const ImageWizard: React.FC = ({ } = useImageWizardState(initialImages, initialPostTitle, initialPostDescription, editingPostId); const [settings, setSettings] = React.useState({ visibility: 'public' }); // Post settings + const [lastError, setLastError] = React.useState(null); + + // Auto-retry state for 503 "high demand" errors + const retryTimerRef = React.useRef | null>(null); + const retryCountdownRef = React.useRef | 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); @@ -753,18 +799,36 @@ const ImageWizard: React.FC = ({ }, [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, - undefined, + apiKey, aspectRatio, resolution, searchGrounding @@ -781,7 +845,7 @@ const ImageWizard: React.FC = ({ promptText, [file], selectedModel, - undefined, + apiKey, aspectRatio, resolution, searchGrounding @@ -832,9 +896,20 @@ const ImageWizard: React.FC = ({ reader.readAsDataURL(blob); } - } catch (error) { + } catch (error: any) { console.error('Error generating image:', error); - toast.error(translate('Failed to generate image. Please check your Google API key.')); + const errMsg = error?.message || String(error); + // Extract meaningful message from GoogleGenerativeAI errors + // Pattern: "[GoogleGenerativeAI Error]: Error fetching from : [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); } @@ -873,18 +948,23 @@ const ImageWizard: React.FC = ({ // 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/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iI2YwZjBmMCIvPjwvc3ZnPg==', // Gray placeholder + src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', // Transparent pixel — spinner overlay provides visual title: 'Generating...', selected: false, - isGenerated: true, + 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 { @@ -895,15 +975,22 @@ const ImageWizard: React.FC = ({ return; } - // Get API Key - let googleApiKey: string | undefined = undefined; + // 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 && secrets.google_api_key) { - googleApiKey = secrets.google_api_key; + 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; @@ -921,7 +1008,7 @@ const ImageWizard: React.FC = ({ fullPrompt, files, selectedModel, - googleApiKey, + apiKey, aspectRatio, resolution, searchGrounding @@ -947,7 +1034,7 @@ const ImageWizard: React.FC = ({ fullPrompt, convertedFiles, selectedModel, - googleApiKey, + apiKey, aspectRatio, resolution, searchGrounding @@ -962,7 +1049,7 @@ const ImageWizard: React.FC = ({ result = await createImage( fullPrompt, selectedModel, - googleApiKey, + apiKey, aspectRatio, resolution, searchGrounding @@ -1022,12 +1109,26 @@ const ImageWizard: React.FC = ({ reader.readAsDataURL(blob); } - } catch (error) { + } catch (error: any) { 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)); + 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 : [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); } @@ -1549,7 +1650,7 @@ const ImageWizard: React.FC = ({ onGenerateSplit={generateImageSplit} onAgentGenerate={handleAgentGeneration} onVoiceGenerate={() => setShowVoicePopup(true)} - onAbort={() => abortGeneration(abortControllerRef, setIsGenerating, setIsAgentMode, setImages, logger)} + onAbort={() => { cancelRetry(); abortGeneration(abortControllerRef, setIsGenerating, setIsAgentMode, setImages, logger); }} images={images} generatedImage={generatedImage} postTitle={postTitle} @@ -1564,6 +1665,9 @@ const ImageWizard: React.FC = ({ if (currentImage) handleAddToPost(currentImage.src, postTitle || prompt, postDescription) }} editingPostId={currentEditingPostId} + lastError={lastError} + retryInfo={retryInfo} + onDismissError={() => { cancelRetry(); setLastError(null); }} > = ({ /> {/* Loading spinner overlay */} {(image.title.includes('Generating') || image.title.includes('Agent working') || image.title.includes('Voice Agent working')) ? ( -
+
- - {image.title} + + {image.title}
) : null} @@ -217,16 +217,21 @@ export const ImageGalleryPanel: React.FC = ({ )}
-

{image.title}

- {image.isGenerated && ( - - Generated - - )} - {image.aiText && ( -

- "{image.aiText}" -

+ {/* Hide title + badge for loading placeholders — spinner overlay is enough */} + {!(image.title.includes('Generating') || image.title.includes('Agent working') || image.title.includes('Voice Agent working')) && ( + <> +

{image.title}

+ {image.isGenerated && ( + + Generated + + )} + {image.aiText && ( +

+ "{image.aiText}" +

+ )} + )}
diff --git a/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx b/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx index af606807..09e932aa 100644 --- a/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx +++ b/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Loader2, Upload, Save, Wand2, X, Brain, Mic, Plus, ChevronDown } from "lucide-react"; +import { Loader2, Upload, Save, Wand2, X, Brain, Mic, Plus, ChevronDown, AlertTriangle } from "lucide-react"; import { T } from "@/i18n"; import { PromptPreset } from "@/components/PresetManager"; import PresetManager from "@/components/PresetManager"; @@ -75,6 +75,9 @@ interface WizardSidebarProps { onAppendToPost?: () => void; onAddToPost?: () => void; editingPostId?: string | null; + lastError?: string | null; + retryInfo?: { attempt: number; secondsLeft: number } | null; + onDismissError?: () => void; } export const WizardSidebar: React.FC = ({ @@ -121,6 +124,9 @@ export const WizardSidebar: React.FC = ({ onAppendToPost, onAddToPost, editingPostId, + lastError, + retryInfo, + onDismissError, }) => { return (
@@ -272,6 +278,26 @@ export const WizardSidebar: React.FC = ({ {/* Step 3: Prompt Section - Render children */} {children} + {/* Inline Error */} + {lastError && ( +
+ + {lastError} + {retryInfo && ( + + + Retrying in {Math.floor(retryInfo.secondsLeft / 60)}:{String(retryInfo.secondsLeft % 60).padStart(2, '0')} (attempt {retryInfo.attempt}/5) + + )} + + {onDismissError && ( + + )} +
+ )} + {/* Step 4: Generate Buttons */}