more ui shit

This commit is contained in:
lovebird 2026-02-19 17:07:46 +01:00
parent 362e0fe83c
commit e71e6f7e13
17 changed files with 475 additions and 203 deletions

View File

@ -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 >
);
};

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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">

View File

@ -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

View File

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

View File

@ -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/'));

View File

@ -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}>

View File

@ -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);

View 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>
);
};

View File

@ -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);

View File

@ -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,

View File

@ -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);

View File

@ -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');

View File

@ -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';

View File

@ -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 && (