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 */}