676 lines
23 KiB
TypeScript
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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|