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

676 lines
23 KiB
TypeScript

import React, { useState, useRef } from 'react';
import { uploadImage } from '@/lib/uploadUtils';
import { useNavigate } from 'react-router-dom';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { T, translate } from '@/i18n';
import { Image, FilePlus, Zap, Mic, Loader2, Upload, Video, Layers, BookPlus } from 'lucide-react';
import { useImageWizard } from '@/hooks/useImageWizard';
import { usePageGenerator } from '@/hooks/usePageGenerator';
import VoiceRecordingPopup from './VoiceRecordingPopup';
import AIPageGenerator from './AIPageGenerator';
import PostPicker from './PostPicker';
import { useWizardContext } from '@/hooks/useWizardContext';
import { usePromptHistory } from '@/hooks/usePromptHistory';
import { useAuth } from '@/hooks/useAuth';
import { useOrganization } from '@/contexts/OrganizationContext';
import { useMediaRefresh } from '@/contexts/MediaRefreshContext';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
interface CreationWizardPopupProps {
isOpen: boolean;
onClose: () => void;
preloadedImages?: any[]; // ImageFile[]
initialMode?: 'default' | 'page';
}
export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({
isOpen,
onClose,
preloadedImages = [],
initialMode = 'default'
}) => {
const navigate = useNavigate();
const { user } = useAuth();
const { orgSlug, isOrgContext } = useOrganization();
const { clearWizardImage } = useWizardContext();
const { openWizard } = useImageWizard();
const { generatePageFromVoice, generatePageFromText, isGenerating, status, cancelGeneration } = usePageGenerator();
const { triggerRefresh } = useMediaRefresh();
const [showVoicePopup, setShowVoicePopup] = useState(false);
const [showTextGenerator, setShowTextGenerator] = useState(initialMode === 'page');
const [showPostPicker, setShowPostPicker] = useState(false);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [isUploadingVideo, setIsUploadingVideo] = useState(false);
const [videoUploadProgress, setVideoUploadProgress] = useState(0);
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const {
prompt: textPrompt,
setPrompt: setTextPrompt,
promptHistory,
historyIndex,
navigateHistory,
addPromptToHistory,
setHistoryIndex
} = usePromptHistory();
// Reset/Sync state when dialog opens
React.useEffect(() => {
if (isOpen && initialMode === 'page') {
setShowTextGenerator(true);
}
}, [isOpen, initialMode]);
const handleImageWizard = (mode: 'direct' | 'agent' | 'voice') => {
onClose();
if (mode === 'direct') {
clearWizardImage();
// If we have preloaded images, pass them to the wizard
if (preloadedImages.length > 0) {
navigate('/wizard', { state: { initialImages: preloadedImages } });
} else {
navigate('/wizard');
}
} else {
console.log(`Opening Image Wizard in ${mode} mode`);
}
};
const handleCreatePost = () => {
onClose();
clearWizardImage();
// Check for link metadata to auto-populate post
let postTitle = '';
let postDescription = '';
// Use the first external page's metadata for the post
const externalPage = preloadedImages.find(img => img.type === 'page-external');
if (externalPage) {
postTitle = externalPage.title || '';
postDescription = externalPage.description || '';
}
// If we have preloaded images, pass them to the wizard
if (preloadedImages.length > 0) {
navigate('/wizard', {
state: {
mode: 'post',
initialImages: preloadedImages,
postTitle,
postDescription
}
});
} else {
navigate('/wizard', { state: { mode: 'post' } });
}
};
const handleAppendToPost = () => {
if (!user) {
toast.error(translate('Please sign in to manage posts'));
return;
}
setShowPostPicker(true);
};
const handlePostSelected = async (postId: string) => {
setShowPostPicker(false);
try {
const toastId = toast.loading(translate('Loading post...'));
// Fetch full post with all pictures
const { data: post, error } = await supabase
.from('posts')
.select(`*, pictures (*)`)
.eq('id', postId)
.single();
if (error) {
toast.dismiss(toastId);
toast.error(translate('Failed to load post'));
console.error('Error fetching post:', error);
return;
}
// Transform existing pictures
const existingImages = (post.pictures || [])
.sort((a: any, b: any) => (a.position - b.position))
.map((p: any) => ({
id: p.id,
path: p.id,
src: p.image_url,
title: p.title,
description: p.description || '',
selected: false,
realDatabaseId: p.id,
type: p.type || 'image',
isGenerated: false
}));
// Merge with preloaded images
// Mark preloaded images as NEW (no realDatabaseId) so they can be added
const newImages = (preloadedImages || []).map(img => ({
...img,
selected: true // Select new images by default
}));
const combinedImages = [...existingImages, ...newImages];
toast.dismiss(toastId);
onClose();
clearWizardImage();
// Navigate to wizard with combined images and post details
navigate('/wizard', {
state: {
mode: 'post',
editingPostId: postId,
initialImages: combinedImages,
postTitle: post.title,
postDescription: post.description
}
});
} catch (err) {
console.error('Error in handlePostSelected:', err);
toast.error(translate('An error occurred'));
}
};
const handlePageFromVoice = () => {
setShowVoicePopup(true);
};
const handleGeneratePageFromText = async (options: { useImageTools: boolean; model: string; imageModel?: string; referenceImages?: string[] }) => {
if (!textPrompt.trim()) return;
addPromptToHistory(textPrompt);
setHistoryIndex(-1);
const result = await generatePageFromText(textPrompt, options);
// Only close on success
if (result) {
setShowTextGenerator(false);
onClose();
}
};
const handleGeneratePageFromVoice = async (transcribedText: string) => {
setShowVoicePopup(false);
// For voice, we assume the user wants the full experience, including images, as per the button's text "Voice + AI"
const result = await generatePageFromText(transcribedText, { useImageTools: true });
if (result) onClose();
};
const handleImageUpload = async () => {
// If we have preloaded images, upload them directly
if (preloadedImages.length > 0) {
if (!user) {
toast.error(translate('Please sign in to upload images'));
return;
}
setIsUploadingImage(true);
try {
let organizationId = null;
if (isOrgContext && orgSlug) {
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('slug', orgSlug)
.single();
organizationId = org?.id || null;
}
for (const img of preloadedImages) {
// Handle External Pages (Links)
if (img.type === 'page-external') {
console.log('Skipping upload for external page:', img.title);
const { error: dbError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
title: img.title || 'Untitled Link',
description: img.description || null,
image_url: img.src, // Use the preview image as main URL? Or should we store the link URL?
// image_url: img.path, // The generic URL
thumbnail_url: img.src, // The preview image
organization_id: organizationId,
type: 'page-external',
meta: img.meta
});
if (dbError) throw dbError;
continue; // Skip the rest of loop
}
const file = img.file;
if (!file) continue;
const { publicUrl } = await uploadImage(file, user.id);
console.log('image uploaded, url:', publicUrl);
let dbData = {
user_id: user.id,
title: file.name.replace(/\.[^/.]+$/, ''),
description: null,
image_url: publicUrl,
organization_id: organizationId,
// type: default (image)
};
const { error: dbError } = await supabase
.from('pictures')
.insert(dbData);
if (dbError) throw dbError;
}
toast.success(translate(`${preloadedImages.length} image(s) uploaded successfully!`));
triggerRefresh();
onClose();
navigate('/');
} catch (error) {
console.error('Error uploading images:', error);
toast.error(translate('Failed to upload images'));
} finally {
setIsUploadingImage(false);
}
return;
}
imageInputRef.current?.click();
};
// Helper function to upload internal video
const uploadInternalVideo = async (file: File): Promise<void> => {
return new Promise((resolve, reject) => {
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://192.168.1.11:3333';
const formData = new FormData();
formData.append('file', file);
const title = file.name.replace(/\.[^/.]+$/, '');
const xhr = new XMLHttpRequest();
xhr.open('POST', `${serverUrl}/api/videos/upload?userId=${user?.id}&title=${encodeURIComponent(title)}&preset=original`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
setVideoUploadProgress(percent);
}
};
xhr.onload = () => {
if (xhr.status === 200 || xhr.status === 202) {
resolve();
} else {
try {
const err = JSON.parse(xhr.responseText);
reject(new Error(err.error || 'Upload failed'));
} catch {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
}
};
xhr.onerror = () => {
reject(new Error('Network error'));
};
xhr.send(formData);
});
};
const handleVideoUpload = async () => {
// If we have preloaded images (which might be videos), upload them directly
if (preloadedImages.length > 0) {
if (!user) {
toast.error(translate('Please sign in to upload videos'));
return;
}
const videos = preloadedImages.filter(img => img.file?.type.startsWith('video/'));
if (videos.length === 0) {
toast.error(translate('No videos found in selection. Please drop video files.'));
return;
}
setIsUploadingVideo(true);
setVideoUploadProgress(0);
try {
let successCount = 0;
const total = videos.length;
for (const [index, img] of videos.entries()) {
const file = img.file;
if (!file) continue;
toast.info(translate(`Uploading video ${index + 1}/${total}...`));
await uploadInternalVideo(file);
successCount++;
}
toast.success(translate(`${successCount} video(s) uploaded successfully!`));
triggerRefresh();
onClose();
navigate('/');
} catch (error: any) {
console.error('Error uploading videos:', error);
toast.error(translate(`Failed to upload video(s): ${error.message}`));
} finally {
setIsUploadingVideo(false);
setVideoUploadProgress(0);
}
return;
}
videoInputRef.current?.click();
};
const handleImageFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error(translate('Please select a valid image file'));
return;
}
if (!user) {
toast.error(translate('Please sign in to upload images'));
return;
}
setIsUploadingImage(true);
try {
// Upload file to storage
// Upload file to storage (direct or via proxy)
const { publicUrl } = await uploadImage(file, user.id);
// Get organization ID if in org context
let organizationId = null;
if (isOrgContext && orgSlug) {
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('slug', orgSlug)
.single();
organizationId = org?.id || null;
}
// Save picture metadata to database
const imageTitle = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
const { error: dbError, data: dbData } = await supabase
.from('pictures')
.insert({
user_id: user.id,
title: imageTitle,
description: null,
image_url: publicUrl,
organization_id: organizationId,
});
if (dbError) throw dbError;
toast.success(translate('Image uploaded successfully!'));
console.log(dbData);
// Trigger PhotoGrid refresh
triggerRefresh();
onClose();
// Navigate to home to see the uploaded image
navigate('/');
} catch (error) {
console.error('Error uploading image:', error);
toast.error(translate('Failed to upload image'));
} finally {
setIsUploadingImage(false);
// Clear the input so the same file can be selected again
if (imageInputRef.current) {
imageInputRef.current.value = '';
}
}
};
const handleVideoFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('video/')) {
toast.error(translate('Please select a valid video file'));
return;
}
if (!user) {
toast.error(translate('Please sign in to upload videos'));
return;
}
setIsUploadingVideo(true);
setVideoUploadProgress(0);
try {
toast.info(translate('Uploading video...'));
await uploadInternalVideo(file);
toast.success(translate('Video upload started! Processing in background.'));
// Trigger PhotoGrid refresh
triggerRefresh();
onClose();
navigate('/');
} catch (error: any) {
console.error('Error uploading video:', error);
toast.error(translate(`Failed to upload video: ${error.message}`));
} finally {
setIsUploadingVideo(false);
setVideoUploadProgress(0);
// Clear the input
if (videoInputRef.current) {
videoInputRef.current.value = '';
}
}
};
return (
<>
<Dialog open={isOpen && !showTextGenerator} onOpenChange={(open) => {
if (!open) {
onClose();
setShowTextGenerator(false);
}
}}>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold text-center">
<T>What would you like to create?</T>
</DialogTitle>
<DialogDescription className="text-center">
<T>Choose from the options below to start generating or uploading content.</T>
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 py-6">
{/* Column 1: Images */}
<div className="flex flex-col gap-4 p-6 bg-muted/30 rounded-lg border">
<div className="flex items-center gap-3">
<Image className="h-6 w-6 text-primary" />
<h3 className="text-lg font-semibold"><T>Images</T></h3>
</div>
<p className="text-sm text-muted-foreground">
<T>Generate AI images or upload your own photos to share.</T>
</p>
<div className="flex flex-col gap-2 mt-auto">
<Button variant="outline" onClick={() => handleImageWizard('direct')}>
<Zap className="h-4 w-4 mr-2" />
<T>Generate AI Image</T>
</Button>
<Button
variant="default"
onClick={handleImageUpload}
disabled={isUploadingImage || !user}
>
{isUploadingImage ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<T>Uploading...</T>
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
<T>Upload Image</T>
</>
)}
</Button>
<Button variant="outline" onClick={handleCreatePost}>
<Layers className="h-4 w-4 mr-2" />
<T>Create Post</T>
</Button>
{preloadedImages.length > 0 && (
<Button variant="outline" onClick={handleAppendToPost}>
<BookPlus className="h-4 w-4 mr-2" />
<T>Append to Existing Post</T>
</Button>
)}
</div>
</div>
{/* Column 2: Videos */}
<div className="flex flex-col gap-4 p-6 bg-muted/30 rounded-lg border">
<div className="flex items-center gap-3">
<Video className="h-6 w-6 text-primary" />
<h3 className="text-lg font-semibold"><T>Videos</T></h3>
</div>
<p className="text-sm text-muted-foreground">
<T>Upload videos to share with the community. Automatic processing and optimization.</T>
</p>
<div className="flex flex-col gap-2 mt-auto">
<Button
variant="default"
onClick={handleVideoUpload}
disabled={isUploadingVideo || !user}
>
{isUploadingVideo ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<T>Uploading</T> {videoUploadProgress}%
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
<T>Upload Video</T>
</>
)}
</Button>
{isUploadingVideo && (
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${videoUploadProgress}%` }}
/>
</div>
)}
</div>
</div>
{/* Column 3: Pages */}
<div className="flex flex-col gap-4 p-6 bg-muted/30 rounded-lg border">
<div className="flex items-center gap-3">
<FilePlus className="h-6 w-6 text-primary" />
<h3 className="text-lg font-semibold"><T>Pages</T></h3>
</div>
<p className="text-sm text-muted-foreground">
<T>Generate complete pages with AI-powered text and images.</T>
</p>
<div className="flex flex-col gap-2 mt-auto">
<Button variant="outline" onClick={() => setShowTextGenerator(true)}>
<Zap className="h-4 w-4 mr-2" />
<T>Generate with AI</T>
</Button>
<Button variant="default" onClick={handlePageFromVoice}>
<Mic className="h-4 w-4 mr-2" />
<T>Voice + AI</T>
</Button>
</div>
</div>
</div>
{/* Hidden file inputs */}
<input
ref={imageInputRef}
type="file"
accept="image/*"
onChange={handleImageFileSelected}
className="hidden"
/>
<input
ref={videoInputRef}
type="file"
accept="video/*"
onChange={handleVideoFileSelected}
className="hidden"
/>
</DialogContent>
</Dialog>
<Dialog open={showPostPicker} onOpenChange={setShowPostPicker}>
<DialogContent className="sm:max-w-[800px] h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle><T>Select a Post to Append To</T></DialogTitle>
<DialogDescription>
<T>Choose one of your existing posts to add these {preloadedImages.length} file(s) to.</T>
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden min-h-0">
<PostPicker onSelect={handlePostSelected} />
</div>
</DialogContent>
</Dialog>
<Dialog open={showTextGenerator} onOpenChange={setShowTextGenerator}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle><T>Create a Page From Scratch</T></DialogTitle>
<DialogDescription>
<T>Describe the page you want the AI to create. It can generate text and images for you.</T>
</DialogDescription>
</DialogHeader>
<div className="py-4">
<AIPageGenerator
prompt={textPrompt}
onPromptChange={setTextPrompt}
onGenerate={handleGeneratePageFromText}
isGenerating={isGenerating}
generationStatus={status}
onCancel={cancelGeneration}
promptHistory={promptHistory}
historyIndex={historyIndex}
onNavigateHistory={navigateHistory}
initialReferenceImages={preloadedImages.map(img => img.src || img.image_url).filter(Boolean)}
/>
</div>
</DialogContent>
</Dialog>
{showVoicePopup && (
<VoiceRecordingPopup
isOpen={showVoicePopup}
onClose={() => setShowVoicePopup(false)}
onTranscriptionComplete={handleGeneratePageFromVoice}
/>
)}
</>
);
};