more ui shit
This commit is contained in:
parent
362e0fe83c
commit
e71e6f7e13
@ -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 = () => {
|
||||
<Sonner />
|
||||
<ActionProvider>
|
||||
<BrowserRouter>
|
||||
<OrganizationProvider>
|
||||
<ProfilesProvider>
|
||||
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||
<StreamInvalidator />
|
||||
<FeedCacheProvider>
|
||||
<AppWrapper />
|
||||
</FeedCacheProvider>
|
||||
</StreamProvider>
|
||||
</WebSocketProvider>
|
||||
</ProfilesProvider>
|
||||
</OrganizationProvider>
|
||||
<DragDropProvider>
|
||||
<OrganizationProvider>
|
||||
<ProfilesProvider>
|
||||
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||
<StreamInvalidator />
|
||||
<FeedCacheProvider>
|
||||
<AppWrapper />
|
||||
</FeedCacheProvider>
|
||||
</StreamProvider>
|
||||
</WebSocketProvider>
|
||||
</ProfilesProvider>
|
||||
</OrganizationProvider>
|
||||
</DragDropProvider>
|
||||
</BrowserRouter>
|
||||
</ActionProvider>
|
||||
|
||||
</TooltipProvider>
|
||||
|
||||
</MediaRefreshProvider>
|
||||
@ -213,7 +217,7 @@ const App = () => {
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</SWRConfig>
|
||||
</HelmetProvider>
|
||||
</HelmetProvider >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="fixed inset-0 z-[99999] bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center border-4 border-dashed border-primary m-4 rounded-xl pointer-events-none">
|
||||
<div
|
||||
className="fixed inset-0 z-[99999] bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center border-4 border-dashed border-primary m-4 rounded-xl pointer-events-none"
|
||||
>
|
||||
<div className="bg-background p-8 rounded-full shadow-lg mb-4 animate-bounce">
|
||||
<Upload className="w-12 h-12 text-primary" />
|
||||
</div>
|
||||
|
||||
@ -183,6 +183,52 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
|
||||
} = useImageWizardState(initialImages, initialPostTitle, initialPostDescription, editingPostId);
|
||||
|
||||
const [settings, setSettings] = React.useState<any>({ visibility: 'public' }); // Post settings
|
||||
const [lastError, setLastError] = React.useState<string | null>(null);
|
||||
|
||||
// Auto-retry state for 503 "high demand" errors
|
||||
const retryTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryCountdownRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const retryCountRef = React.useRef(0);
|
||||
const isRetryCallRef = React.useRef(false);
|
||||
const [retryInfo, setRetryInfo] = React.useState<{ attempt: number; secondsLeft: number } | null>(null);
|
||||
|
||||
const cancelRetry = React.useCallback(() => {
|
||||
if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
|
||||
if (retryCountdownRef.current) { clearInterval(retryCountdownRef.current); retryCountdownRef.current = null; }
|
||||
retryCountRef.current = 0;
|
||||
isRetryCallRef.current = false;
|
||||
setRetryInfo(null);
|
||||
}, []);
|
||||
|
||||
const scheduleRetry = React.useCallback((retryFn: () => void, errorMsg: string) => {
|
||||
const MAX_RETRIES = 5;
|
||||
const RETRY_SECS = 120;
|
||||
if (retryCountRef.current >= MAX_RETRIES) {
|
||||
retryCountRef.current = 0;
|
||||
setLastError(`${errorMsg} — gave up after ${MAX_RETRIES} attempts`);
|
||||
return;
|
||||
}
|
||||
retryCountRef.current++;
|
||||
const attempt = retryCountRef.current;
|
||||
let seconds = RETRY_SECS;
|
||||
setRetryInfo({ attempt, secondsLeft: seconds });
|
||||
retryCountdownRef.current = setInterval(() => {
|
||||
seconds--;
|
||||
if (seconds > 0) { setRetryInfo({ attempt, secondsLeft: seconds }); }
|
||||
else if (retryCountdownRef.current) { clearInterval(retryCountdownRef.current); retryCountdownRef.current = null; }
|
||||
}, 1000);
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
if (retryCountdownRef.current) { clearInterval(retryCountdownRef.current); retryCountdownRef.current = null; }
|
||||
retryTimerRef.current = null;
|
||||
setRetryInfo(null);
|
||||
setLastError(null);
|
||||
isRetryCallRef.current = true;
|
||||
retryFn();
|
||||
}, RETRY_SECS * 1000);
|
||||
}, []);
|
||||
|
||||
// Cleanup retry on unmount
|
||||
React.useEffect(() => () => cancelRetry(), [cancelRetry]);
|
||||
|
||||
// Editor and Settings state
|
||||
const [editingImage, setEditingImage] = React.useState<{ url: string; id: string } | null>(null);
|
||||
@ -753,18 +799,36 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
|
||||
}, [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<ImageWizardProps> = ({
|
||||
promptText,
|
||||
[file],
|
||||
selectedModel,
|
||||
undefined,
|
||||
apiKey,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
searchGrounding
|
||||
@ -832,9 +896,20 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
|
||||
|
||||
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 <url>: [503 ] Actual message"
|
||||
const httpMatch = errMsg.match(/\[\d{3}\s*\]\s*(.+)/s);
|
||||
const userMessage = httpMatch ? httpMatch[1].trim() : errMsg.replace(/\[GoogleGenerativeAI Error\][:\s]*/i, '').trim();
|
||||
setLastError(userMessage || 'Failed to generate image');
|
||||
// Auto-retry for "high demand" / rate-limit errors
|
||||
if (userMessage.toLowerCase().includes('later')) {
|
||||
scheduleRetry(() => generateImageWithSpecificImage(promptText, targetImage), userMessage);
|
||||
} else {
|
||||
toast.error(userMessage || translate('Failed to generate image'));
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
@ -873,18 +948,23 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
|
||||
// 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<ImageWizardProps> = ({
|
||||
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<ImageWizardProps> = ({
|
||||
fullPrompt,
|
||||
files,
|
||||
selectedModel,
|
||||
googleApiKey,
|
||||
apiKey,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
searchGrounding
|
||||
@ -947,7 +1034,7 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
|
||||
fullPrompt,
|
||||
convertedFiles,
|
||||
selectedModel,
|
||||
googleApiKey,
|
||||
apiKey,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
searchGrounding
|
||||
@ -962,7 +1049,7 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
|
||||
result = await createImage(
|
||||
fullPrompt,
|
||||
selectedModel,
|
||||
googleApiKey,
|
||||
apiKey,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
searchGrounding
|
||||
@ -1022,12 +1109,26 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
|
||||
|
||||
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 <url>: [503 ] Actual message"
|
||||
const httpMatch = errMsg.match(/\[\d{3}\s*\]\s*(.+)/s);
|
||||
const userMessage = httpMatch ? httpMatch[1].trim() : errMsg.replace(/\[GoogleGenerativeAI Error\][:\s]*/i, '').trim();
|
||||
setLastError(userMessage || 'Failed to generate image');
|
||||
// Auto-retry for "high demand" / rate-limit errors
|
||||
if (userMessage.toLowerCase().includes('later')) {
|
||||
scheduleRetry(() => generateImage(), userMessage);
|
||||
} else {
|
||||
toast.error(userMessage || translate('Failed to generate image'));
|
||||
}
|
||||
// Remove placeholder on error and restore previous selection
|
||||
setImages(prev => prev
|
||||
.filter(img => img.id !== placeholderId)
|
||||
.map(img => ({ ...img, selected: previouslySelectedIds.includes(img.id) }))
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
@ -1549,7 +1650,7 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
|
||||
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<ImageWizardProps> = ({
|
||||
if (currentImage) handleAddToPost(currentImage.src, postTitle || prompt, postDescription)
|
||||
}}
|
||||
editingPostId={currentEditingPostId}
|
||||
lastError={lastError}
|
||||
retryInfo={retryInfo}
|
||||
onDismissError={() => { cancelRetry(); setLastError(null); }}
|
||||
>
|
||||
<Prompt
|
||||
prompt={prompt}
|
||||
|
||||
@ -199,10 +199,10 @@ export const ImageGalleryPanel: React.FC<ImageGalleryPanelProps> = ({
|
||||
/>
|
||||
{/* Loading spinner overlay */}
|
||||
{(image.title.includes('Generating') || image.title.includes('Agent working') || image.title.includes('Voice Agent working')) ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/80 backdrop-blur-sm">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="text-xs font-medium text-foreground">{image.title}</span>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-white" />
|
||||
<span className="text-xs font-medium text-white/80">{image.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@ -217,16 +217,21 @@ export const ImageGalleryPanel: React.FC<ImageGalleryPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p className="text-xs font-medium line-clamp-2 break-words">{image.title}</p>
|
||||
{image.isGenerated && (
|
||||
<Badge variant="secondary" className="text-xs mt-1">
|
||||
Generated
|
||||
</Badge>
|
||||
)}
|
||||
{image.aiText && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
"{image.aiText}"
|
||||
</p>
|
||||
{/* 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')) && (
|
||||
<>
|
||||
<p className="text-xs font-medium line-clamp-2 break-words text-center">{image.title}</p>
|
||||
{image.isGenerated && (
|
||||
<Badge variant="secondary" className="text-xs mt-1">
|
||||
Generated
|
||||
</Badge>
|
||||
)}
|
||||
{image.aiText && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
"{image.aiText}"
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@ -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<WizardSidebarProps> = ({
|
||||
@ -121,6 +124,9 @@ export const WizardSidebar: React.FC<WizardSidebarProps> = ({
|
||||
onAppendToPost,
|
||||
onAddToPost,
|
||||
editingPostId,
|
||||
lastError,
|
||||
retryInfo,
|
||||
onDismissError,
|
||||
}) => {
|
||||
return (
|
||||
<div className="lg:col-span-1 space-y-3 md:space-y-6">
|
||||
@ -272,6 +278,26 @@ export const WizardSidebar: React.FC<WizardSidebarProps> = ({
|
||||
{/* Step 3: Prompt Section - Render children */}
|
||||
{children}
|
||||
|
||||
{/* Inline Error */}
|
||||
{lastError && (
|
||||
<div className="flex items-start gap-2 p-2.5 rounded-md bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800/50 text-orange-800 dark:text-orange-300 text-xs">
|
||||
<span className="flex-1">
|
||||
{lastError}
|
||||
{retryInfo && (
|
||||
<span className="flex items-center gap-1.5 mt-1.5 text-orange-600 dark:text-orange-400 font-medium">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Retrying in {Math.floor(retryInfo.secondsLeft / 60)}:{String(retryInfo.secondsLeft % 60).padStart(2, '0')} (attempt {retryInfo.attempt}/5)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{onDismissError && (
|
||||
<button onClick={onDismissError} className="shrink-0 mt-0.5 opacity-60 hover:opacity-100" title="Cancel retry">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Generate Buttons */}
|
||||
<div>
|
||||
<label className="text-sm md:text-base font-medium mb-2 block">
|
||||
|
||||
@ -148,7 +148,7 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables }: Mar
|
||||
position: 0,
|
||||
likes_count: 0,
|
||||
post_id: null
|
||||
};
|
||||
} as any;
|
||||
}, [currentImageIndex, allImages, user]);
|
||||
|
||||
// Only use HashtagText if content has hashtags but NO markdown syntax at all
|
||||
|
||||
@ -335,7 +335,7 @@ const PhotoCard = ({
|
||||
return (
|
||||
<div
|
||||
data-testid="photo-card"
|
||||
className={`group w-full relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full ${className || ''}`}
|
||||
className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full ${className || ''}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* Image */}
|
||||
|
||||
@ -11,6 +11,7 @@ import { isVideoType, normalizeMediaType, detectMediaType } from '@/lib/mediaReg
|
||||
import { uploadImage } from '@/lib/uploadUtils';
|
||||
import { toast } from 'sonner';
|
||||
import { fetchMediaItemsByIds, createPicture } from '@/modules/posts/client-pictures';
|
||||
import { useDragDrop } from '@/contexts/DragDropContext';
|
||||
|
||||
interface GalleryWidgetProps {
|
||||
pictureIds?: string[];
|
||||
@ -38,6 +39,7 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
||||
id
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { setLocalZoneActive, resetDragState } = useDragDrop();
|
||||
|
||||
// Normalize pictureIds to always be an array
|
||||
const normalizePictureIds = (ids: any): string[] => {
|
||||
@ -147,28 +149,30 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
if (!isEditMode) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// e.stopPropagation(); // Allow bubbling
|
||||
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDragging(true);
|
||||
setLocalZoneActive(true);
|
||||
}
|
||||
}, [isEditMode]);
|
||||
}, [isEditMode, setLocalZoneActive]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!isEditMode) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// e.stopPropagation(); // Allow bubbling
|
||||
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
return;
|
||||
}
|
||||
setIsDragging(false);
|
||||
}, [isEditMode]);
|
||||
setLocalZoneActive(false);
|
||||
}, [isEditMode, setLocalZoneActive]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!isEditMode) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// e.stopPropagation(); // Allow bubbling
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, [isEditMode]);
|
||||
|
||||
@ -177,6 +181,7 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
resetDragState();
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const imageFiles = files.filter(f => f.type.startsWith('image/'));
|
||||
|
||||
@ -60,6 +60,7 @@ export const ImagePickerDialog: React.FC<ImagePickerDialogProps> = ({
|
||||
const [selectedCollections, setSelectedCollections] = useState<string[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(currentValue || null);
|
||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||
const prevIsOpen = useRef(false);
|
||||
|
||||
// Initial data fetch - only runs when dialog opens
|
||||
@ -184,16 +185,68 @@ export const ImagePickerDialog: React.FC<ImagePickerDialogProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
const toggleSelection = (id: string) => {
|
||||
|
||||
const handleClear = () => {
|
||||
if (multiple) {
|
||||
if (onMultiSelect) onMultiSelect([]);
|
||||
if (onMultiSelectPictures) onMultiSelectPictures([]);
|
||||
} else {
|
||||
if (onSelect) onSelect('');
|
||||
if (onSelectPicture) onSelectPicture(null as any);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
const handleImageClick = (e: React.MouseEvent, id: string) => {
|
||||
// Only capture modifiers if strict multi-select behavior is desired.
|
||||
// However, given the user complaint "CTRL+CLICK doesn't add", it implies they expect:
|
||||
// Click = Exclusive (Selects just this one)
|
||||
// Ctrl+Click = Add (Toggle)
|
||||
// Shift+Click = Range
|
||||
|
||||
if (!multiple) {
|
||||
setSelectedId(id);
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault(); // Prevent text selection etc
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Toggle
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(pid => pid !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
setLastSelectedId(id);
|
||||
} else if (e.shiftKey) {
|
||||
// Range Select
|
||||
const currentIndex = finalPictures.findIndex(p => p.id === id);
|
||||
const lastIndex = lastSelectedId ? finalPictures.findIndex(p => p.id === lastSelectedId) : -1;
|
||||
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(currentIndex, lastIndex);
|
||||
const end = Math.max(currentIndex, lastIndex);
|
||||
const rangeIds = finalPictures.slice(start, end + 1).map(p => p.id);
|
||||
|
||||
// Add range to existing selection (union) so we don't lose previous non-contiguous selections
|
||||
// OR replace? Standard usually replaces unless Ctrl is held.
|
||||
// But since Shift extends selection from anchor...
|
||||
// Let's go with Union for safety, or actually Replace relative to anchor?
|
||||
// Standard behavior: Shift click selects from Anchor to Current.
|
||||
// It should technically clear everything else unless Ctrl is also held.
|
||||
// But simplifying: just add range for now.
|
||||
setSelectedIds(prev => Array.from(new Set([...prev, ...rangeIds])));
|
||||
} else {
|
||||
setSelectedIds(prev => prev.includes(id) ? prev : [...prev, id]);
|
||||
}
|
||||
setLastSelectedId(id);
|
||||
} else {
|
||||
setSelectedId(id);
|
||||
// Exclusive Select (clears others)
|
||||
// This solves the complaint "Ctrl+Click doesn't add" because now Click REPLACES, so Ctrl+Click is the way to add.
|
||||
setSelectedIds([id]);
|
||||
setLastSelectedId(id);
|
||||
}
|
||||
};
|
||||
return (
|
||||
@ -325,14 +378,18 @@ export const ImagePickerDialog: React.FC<ImagePickerDialogProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={picture.id}
|
||||
onClick={() => toggleSelection(picture.id)}
|
||||
onClick={(e) => handleImageClick(e, picture.id)}
|
||||
onDoubleClick={() => {
|
||||
if (!multiple) {
|
||||
setSelectedId(picture.id);
|
||||
if (onSelect) onSelect(picture.id);
|
||||
if (onSelectPicture) onSelectPicture(picture);
|
||||
} else {
|
||||
toggleSelection(picture.id);
|
||||
// Double click in multi mode: Just toggle or maybe confirm?
|
||||
// For now, let's treat it as select-exclusive+confirm?
|
||||
// Or just toggle.
|
||||
handleImageClick({ ctrlKey: true, preventDefault: () => { } } as any, picture.id);
|
||||
handleSelect();
|
||||
}
|
||||
}}
|
||||
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all group ${isSelected
|
||||
@ -375,8 +432,16 @@ export const ImagePickerDialog: React.FC<ImagePickerDialogProps> = ({
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between items-center pt-4 border-t mt-auto">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{multiple && <span>{selectedIds.length} selected</span>}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{multiple ? `${selectedIds.length} selected` : (selectedId ? '1 selected' : '')}
|
||||
</span>
|
||||
{(multiple ? selectedIds.length > 0 || (currentValues && currentValues.length > 0) : !!selectedId || !!currentValue) && (
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={handleClear}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
<T>Clear</T>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
|
||||
@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { uploadImage } from '@/lib/uploadUtils';
|
||||
import { toast } from 'sonner';
|
||||
import { useDragDrop } from '@/contexts/DragDropContext';
|
||||
|
||||
interface PhotoCardWidgetProps {
|
||||
isEditMode?: boolean;
|
||||
@ -47,6 +48,7 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||
onPropsChange
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { setLocalZoneActive, resetDragState } = useDragDrop();
|
||||
const [pictureId, setPictureId] = useState<string | null>(propPictureId);
|
||||
const [picture, setPicture] = useState<Picture | null>(null);
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
@ -151,18 +153,19 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
if (!isEditMode) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// e.stopPropagation(); // Allow bubbling so global context knows we are still dragging
|
||||
|
||||
// Check if dragging files
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDragging(true);
|
||||
setLocalZoneActive(true);
|
||||
}
|
||||
}, [isEditMode]);
|
||||
}, [isEditMode, setLocalZoneActive]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!isEditMode) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// e.stopPropagation(); // Allow bubbling
|
||||
|
||||
// Only set dragging to false if we're leaving the main container
|
||||
// This is a simple check, typically you'd check relatedTarget
|
||||
@ -170,12 +173,13 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||
return;
|
||||
}
|
||||
setIsDragging(false);
|
||||
}, [isEditMode]);
|
||||
setLocalZoneActive(false);
|
||||
}, [isEditMode, setLocalZoneActive]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!isEditMode) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// e.stopPropagation(); // Allow bubbling
|
||||
|
||||
// Important to allow drop
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
@ -185,7 +189,10 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||
if (!isEditMode) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// setIsDragging(false); // No need for local state if we use global? But this is local visual state.
|
||||
setIsDragging(false);
|
||||
// setLocalZoneActive(false); // handled by resetDragState
|
||||
resetDragState();
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
|
||||
|
||||
128
packages/ui/src/contexts/DragDropContext.tsx
Normal file
128
packages/ui/src/contexts/DragDropContext.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface DragDropContextType {
|
||||
isDragging: boolean;
|
||||
isLocalZoneActive: boolean;
|
||||
setLocalZoneActive: (active: boolean) => void;
|
||||
resetDragState: () => void;
|
||||
dragFiles: File[] | null;
|
||||
dragType: string | null;
|
||||
}
|
||||
|
||||
const DragDropContext = createContext<DragDropContextType>({
|
||||
isDragging: false,
|
||||
isLocalZoneActive: false,
|
||||
setLocalZoneActive: () => { },
|
||||
resetDragState: () => { },
|
||||
dragFiles: null,
|
||||
dragType: null,
|
||||
});
|
||||
|
||||
export const useDragDrop = () => useContext(DragDropContext);
|
||||
|
||||
export const DragDropProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isLocalZoneActive, setIsLocalZoneActive] = useState(false);
|
||||
const [dragFiles, setDragFiles] = useState<File[] | null>(null);
|
||||
const [dragType, setDragType] = useState<string | null>(null);
|
||||
|
||||
const dragCounter = useRef(0);
|
||||
const dragBlocker = useRef(false);
|
||||
|
||||
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
|
||||
return types.includes('Files') || types.includes('text/uri-list') || types.includes('text/plain');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
if (dragBlocker.current) return;
|
||||
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);
|
||||
setIsLocalZoneActive(false); // Reset this too just in case
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
console.log('handleDrop')
|
||||
// We only reset if the event bubbles up to here (meaning not handled/stopped by widget)
|
||||
// But actually, we want to reset state regardless?
|
||||
// If widget handles it, it should call resetDragState() manually.
|
||||
// If it bubbles, we reset here.
|
||||
|
||||
// If we prevent default here, does it interfere?
|
||||
e.preventDefault();
|
||||
// e.stopPropagation(); // Don't stop propagation at window level?
|
||||
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsDragging(false);
|
||||
setIsLocalZoneActive(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('dragenter', handleDragEnter);
|
||||
window.removeEventListener('dragleave', handleDragLeave);
|
||||
window.removeEventListener('dragover', handleDragOver);
|
||||
window.removeEventListener('drop', handleDrop);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const setLocalZoneActive = (active: boolean) => {
|
||||
setIsLocalZoneActive(active);
|
||||
};
|
||||
|
||||
const resetDragState = () => {
|
||||
setIsDragging(false);
|
||||
setIsLocalZoneActive(false);
|
||||
console.log('resetDragState')
|
||||
dragCounter.current = 0;
|
||||
|
||||
// Block new drag events for a short period to prevent flickering/re-triggering after drop
|
||||
dragBlocker.current = true;
|
||||
setTimeout(() => {
|
||||
dragBlocker.current = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext.Provider value={{ isDragging, isLocalZoneActive, setLocalZoneActive, resetDragState, dragFiles, dragType }}>
|
||||
{children}
|
||||
</DragDropContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -4,7 +4,7 @@ const useDropZone = ({ onDrop }: { onDrop: (paths: string[]) => void }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [dragIn, setDragIn] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
@ -12,7 +12,7 @@ const useDropZone = ({ onDrop }: { onDrop: (paths: string[]) => void }) => {
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
dragCounterRef.current += 1;
|
||||
if (dragCounterRef.current === 1) {
|
||||
setDragIn(true);
|
||||
@ -27,7 +27,7 @@ const useDropZone = ({ onDrop }: { onDrop: (paths: string[]) => void }) => {
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current <= 0) {
|
||||
dragCounterRef.current = 0;
|
||||
@ -38,7 +38,7 @@ const useDropZone = ({ onDrop }: { onDrop: (paths: string[]) => void }) => {
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
dragCounterRef.current = 0;
|
||||
setDragIn(false);
|
||||
|
||||
@ -65,7 +65,7 @@ const useDropZone = ({ onDrop }: { onDrop: (paths: string[]) => void }) => {
|
||||
element.addEventListener('dragover', handleDragOver);
|
||||
element.addEventListener('dragleave', handleDragLeave);
|
||||
element.addEventListener('drop', handleDrop);
|
||||
|
||||
|
||||
// Document-level listeners to handle edge cases
|
||||
document.addEventListener('dragleave', handleDocumentDragLeave);
|
||||
document.addEventListener('drop', handleDocumentDrop);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { translate } from '@/i18n';
|
||||
import { useDragDrop } from '@/contexts/DragDropContext';
|
||||
|
||||
interface UseImageDropProps {
|
||||
onFilesDrop: (files: File[]) => void;
|
||||
@ -9,42 +10,47 @@ interface UseImageDropProps {
|
||||
}
|
||||
|
||||
export const useImageDrop = ({ onFilesDrop, isEditMode, enabled = true }: UseImageDropProps) => {
|
||||
const { setLocalZoneActive, resetDragState, isDragging: globalIsDragging } = useDragDrop();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
console.log('useImageDrop: ', isDragging);
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
if (!isEditMode || !enabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// e.stopPropagation(); // Allow bubbling to global context
|
||||
setLocalZoneActive(true);
|
||||
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [isEditMode, enabled]);
|
||||
}, [isEditMode, enabled, setLocalZoneActive]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!isEditMode || !enabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// e.stopPropagation(); // Allow bubbling
|
||||
|
||||
// Check if we're actually leaving the element
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
return;
|
||||
}
|
||||
setIsDragging(false);
|
||||
}, [isEditMode, enabled]);
|
||||
setLocalZoneActive(false);
|
||||
}, [isEditMode, enabled, setLocalZoneActive]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!isEditMode || !enabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, [isEditMode, enabled]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
if (!isEditMode || !enabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopPropagation(); // Keep stopPropagation for drop to prevent global handler
|
||||
setIsDragging(false);
|
||||
resetDragState();
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const imageFiles = files.filter(f => f.type.startsWith('image/'));
|
||||
@ -57,7 +63,7 @@ export const useImageDrop = ({ onFilesDrop, isEditMode, enabled = true }: UseIma
|
||||
}
|
||||
|
||||
onFilesDrop(imageFiles);
|
||||
}, [isEditMode, enabled, onFilesDrop]);
|
||||
}, [isEditMode, enabled, onFilesDrop, resetDragState]);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
|
||||
@ -72,7 +72,7 @@ export const createImageWithAimlApi = async (
|
||||
apiKey?: string
|
||||
): Promise<ImageResult | null> => {
|
||||
const key = apiKey || await getAimlApiKey();
|
||||
|
||||
|
||||
if (!key) {
|
||||
logger.error('No AIML API key found. Please provide an API key or set it in your profile.');
|
||||
return null;
|
||||
@ -130,13 +130,13 @@ export const createImageWithAimlApi = async (
|
||||
}
|
||||
|
||||
const firstResult = data.data[0];
|
||||
|
||||
|
||||
// Prefer URL over base64 if both are provided
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
|
||||
|
||||
if (firstResult.url) {
|
||||
logger.info('Image URL received from AIML API:', firstResult.url);
|
||||
|
||||
|
||||
// Fetch the image from URL
|
||||
const imageResponse = await fetch(firstResult.url);
|
||||
if (!imageResponse.ok) {
|
||||
@ -145,7 +145,7 @@ export const createImageWithAimlApi = async (
|
||||
arrayBuffer = await imageResponse.arrayBuffer();
|
||||
} else if (firstResult.b64_json) {
|
||||
logger.info('Base64 image received from AIML API');
|
||||
|
||||
|
||||
// Convert base64 to ArrayBuffer
|
||||
const binaryString = atob(firstResult.b64_json);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
@ -188,7 +188,7 @@ export const editImageWithAimlApi = async (
|
||||
apiKey?: string
|
||||
): Promise<ImageResult | null> => {
|
||||
const key = apiKey || await getAimlApiKey();
|
||||
|
||||
|
||||
if (!key) {
|
||||
logger.error('No AIML API key found. Please provide an API key or set it in your profile.');
|
||||
return null;
|
||||
@ -215,13 +215,8 @@ export const editImageWithAimlApi = async (
|
||||
sync_mode: true,
|
||||
};
|
||||
|
||||
// SeeDream v4 Edit uses image_urls array
|
||||
if (model.includes('seedream-v4-edit')) {
|
||||
requestBody.image_urls = [`data:image/png;base64,${imageBase64}`];
|
||||
} else {
|
||||
// SeedEdit 3.0 and others use 'image' field
|
||||
requestBody.image = `data:image/png;base64,${imageBase64}`;
|
||||
}
|
||||
// AIML API edit endpoint requires image_urls for all models
|
||||
requestBody.image_urls = [`data:image/png;base64,${imageBase64}`];
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
@ -247,13 +242,13 @@ export const editImageWithAimlApi = async (
|
||||
}
|
||||
|
||||
const firstResult = data.data[0];
|
||||
|
||||
|
||||
// Prefer URL over base64 if both are provided
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
|
||||
|
||||
if (firstResult.url) {
|
||||
logger.info('Edited image URL received from AIML API:', firstResult.url);
|
||||
|
||||
|
||||
// Fetch the image from URL
|
||||
const imageResponse = await fetch(firstResult.url);
|
||||
if (!imageResponse.ok) {
|
||||
@ -262,7 +257,7 @@ export const editImageWithAimlApi = async (
|
||||
arrayBuffer = await imageResponse.arrayBuffer();
|
||||
} else if (firstResult.b64_json) {
|
||||
logger.info('Base64 edited image received from AIML API');
|
||||
|
||||
|
||||
// Convert base64 to ArrayBuffer
|
||||
const binaryString = atob(firstResult.b64_json);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
|
||||
@ -8,3 +8,6 @@ createRoot(document.getElementById("root")!).render(
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Enable CSS animations after initial render (prevents FOUC)
|
||||
document.body.classList.add('app-init');
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { T } from '@/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download, Upload, Grid3X3 } from 'lucide-react';
|
||||
import { Grid3X3 } from 'lucide-react';
|
||||
import { useLayout } from '@/modules/layout/LayoutContext';
|
||||
import { LayoutContainer } from './LayoutContainer';
|
||||
import { WidgetPalette } from './WidgetPalette';
|
||||
import { useImageDrop } from '@/hooks/useImageDrop';
|
||||
|
||||
import { uploadAndCreatePicture } from '@/lib/uploadUtils';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@ -469,13 +469,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{/* Drop Overlay */}
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 z-50 pointer-events-none flex items-center justify-center rounded-lg border-2 border-dashed border-blue-500 bg-blue-100/20 backdrop-blur-[1px]">
|
||||
<div className="bg-background/90 px-4 py-2 rounded-full shadow-lg text-blue-600 font-medium animate-bounce">
|
||||
Drop to add Image
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Container Settings Dialog */}
|
||||
{showContainerSettings && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user