mono/packages/ui/src/components/EditImageModal.tsx
2026-02-25 10:11:54 +01:00

564 lines
20 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent } from '@/components/ui/card';
import { useForm } from 'react-hook-form';
import { supabase } from '@/integrations/supabase/client';
import { updatePicture } from '@/modules/posts/client-pictures';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { Edit3, GitBranch, Sparkles, Mic, MicOff, Loader2, Bookmark } from 'lucide-react';
import MarkdownEditor from '@/components/MarkdownEditor';
import VersionSelector from '@/components/VersionSelector';
import { analyzeImages, transcribeAudio } from '@/lib/openai';
import { AI_IMAGE_ANALYSIS_PROMPT } from '@/constants';
import { T, translate } from '@/i18n';
interface Collection {
id: string;
name: string;
description: string | null;
slug: string;
is_public: boolean;
}
interface EditFormData {
title?: string;
description?: string;
visible: boolean;
}
interface EditImageModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pictureId: string;
currentTitle: string;
currentDescription: string | null;
currentVisible: boolean;
imageUrl?: string;
onUpdateSuccess: () => void;
}
const EditImageModal = ({
open,
onOpenChange,
pictureId,
currentTitle,
currentDescription,
currentVisible,
imageUrl,
onUpdateSuccess
}: EditImageModalProps) => {
const [updating, setUpdating] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const { user } = useAuth();
// Microphone recording state
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
// Collections state
const [collections, setCollections] = useState<Collection[]>([]);
const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
const [loadingCollections, setLoadingCollections] = useState(false);
const form = useForm<EditFormData>({
defaultValues: {
title: currentTitle,
description: currentDescription || '',
visible: currentVisible,
},
});
// Load collections when modal opens
useEffect(() => {
if (open && user) {
fetchCollections();
fetchPictureCollections();
}
}, [open, user, pictureId]);
const fetchCollections = async () => {
if (!user) return;
setLoadingCollections(true);
try {
const { data, error } = await supabase
.from('collections')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) throw error;
setCollections(data || []);
} catch (error) {
console.error('Error fetching collections:', error);
toast.error(translate('Failed to load collections'));
} finally {
setLoadingCollections(false);
}
};
const fetchPictureCollections = async () => {
if (!user) return;
try {
const { data, error } = await supabase
.from('collection_pictures')
.select('collection_id')
.eq('picture_id', pictureId);
if (error) throw error;
const collectionIds = new Set(data.map(item => item.collection_id));
setSelectedCollections(collectionIds);
} catch (error) {
console.error('Error fetching picture collections:', error);
}
};
const handleToggleCollection = async (collectionId: string) => {
if (!user) return;
const isSelected = selectedCollections.has(collectionId);
try {
if (isSelected) {
// Remove from collection
const { error } = await supabase
.from('collection_pictures')
.delete()
.eq('collection_id', collectionId)
.eq('picture_id', pictureId);
if (error) throw error;
setSelectedCollections(prev => {
const newSet = new Set(prev);
newSet.delete(collectionId);
return newSet;
});
toast.success(translate('Removed from collection'));
} else {
// Add to collection
const { error } = await supabase
.from('collection_pictures')
.insert({
collection_id: collectionId,
picture_id: pictureId
});
if (error) throw error;
setSelectedCollections(prev => new Set([...prev, collectionId]));
toast.success(translate('Added to collection'));
}
} catch (error) {
console.error('Error toggling collection:', error);
toast.error(translate('Failed to update collection'));
}
};
const onSubmit = async (data: EditFormData) => {
if (!user) return;
setUpdating(true);
try {
await updatePicture(pictureId, {
title: data.title?.trim() || null,
description: data.description || null,
visible: data.visible,
updated_at: new Date().toISOString(),
});
toast.success(translate('Picture updated successfully!'));
onUpdateSuccess();
} catch (error: any) {
console.error('Error updating picture:', error);
toast.error(translate('Failed to update picture'));
} finally {
setUpdating(false);
}
};
const handleClose = () => {
form.reset();
onOpenChange(false);
};
const handleMagicGenerate = async () => {
if (!imageUrl) {
toast.error(translate('No image URL available'));
return;
}
setIsGenerating(true);
try {
// Fetch the image and convert to File
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], 'image.png', { type: blob.type || 'image/png' });
// Call OpenAI to analyze the image
const result = await analyzeImages([file], AI_IMAGE_ANALYSIS_PROMPT);
if (!result) {
toast.error(translate('OpenAI API key not configured. Please add your OpenAI API key in your profile settings.'));
return;
}
// Update form fields with generated content
form.setValue('title', result.title);
form.setValue('description', result.description);
toast.success(translate('Title and description generated!'));
} catch (error: any) {
console.error('Error generating metadata:', error);
toast.error(error.message || translate('Failed to generate metadata. Please check your OpenAI API key.'));
} finally {
setIsGenerating(false);
}
};
const handleMicrophone = async () => {
if (isRecording) {
// Stop recording
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
} else {
// Start recording
try {
// Check if MediaRecorder is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
toast.error(translate('Audio recording is not supported in your browser'));
return;
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Create MediaRecorder with common audio format
const options = { mimeType: 'audio/webm' };
let mediaRecorder: MediaRecorder;
try {
mediaRecorder = new MediaRecorder(stream, options);
} catch (e) {
// Fallback without options if the format is not supported
mediaRecorder = new MediaRecorder(stream);
}
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
setIsTranscribing(true);
try {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
toast.info(translate('Transcribing audio...'));
const transcribedText = await transcribeAudio(audioFile);
if (transcribedText) {
// Append transcribed text to description field
const currentDescription = form.getValues('description') || '';
const trimmed = currentDescription.trim();
const newDescription = trimmed ? `${trimmed}\n\n${transcribedText}` : transcribedText;
form.setValue('description', newDescription);
toast.success(translate('Audio transcribed successfully!'));
} else {
toast.error(translate('Failed to transcribe audio. Please check your OpenAI API key.'));
}
} catch (error: any) {
console.error('Error transcribing audio:', error);
toast.error(error.message || translate('Failed to transcribe audio'));
} finally {
setIsTranscribing(false);
audioChunksRef.current = [];
// Stop all tracks
stream.getTracks().forEach(track => track.stop());
}
};
mediaRecorder.start();
setIsRecording(true);
toast.info(translate('Recording started... Click again to stop'));
} catch (error: any) {
console.error('Error accessing microphone:', error);
if (error.name === 'NotAllowedError') {
toast.error(translate('Microphone access denied. Please allow microphone access in your browser settings.'));
} else {
toast.error(translate('Failed to access microphone') + ': ' + error.message);
}
}
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Edit3 className="h-5 w-5" />
<T>Edit Picture</T>
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="edit" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="edit" className="flex items-center gap-2">
<Edit3 className="h-4 w-4" />
<T>Edit Details</T>
</TabsTrigger>
<TabsTrigger value="collections" className="flex items-center gap-2">
<Bookmark className="h-4 w-4" />
<T>Collections</T>
</TabsTrigger>
<TabsTrigger value="versions" className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
<T>Versions</T>
</TabsTrigger>
</TabsList>
<TabsContent value="edit" className="mt-4">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Magic Generate Button */}
{imageUrl && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleMagicGenerate}
disabled={isGenerating || updating}
>
{isGenerating ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-primary border-t-transparent mr-2"></div>
<T>Generating...</T>
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
<T>Generate Title & Description with AI</T>
</>
)}
</Button>
)}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel><T>Title (Optional)</T></FormLabel>
<FormControl>
<Input
placeholder={translate("Enter a title...")}
{...field}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between mb-2">
<FormLabel><T>Description (Optional)</T></FormLabel>
<button
type="button"
onClick={handleMicrophone}
disabled={isTranscribing || updating}
className={`p-1.5 rounded-md transition-colors ${isRecording
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={translate(isRecording ? 'Stop recording' : 'Record audio')}
>
{isTranscribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isRecording ? (
<MicOff className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
</button>
</div>
<FormControl>
<MarkdownEditor
value={field.value || ''}
onChange={field.onChange}
placeholder={translate("Describe your photo... You can use **markdown** formatting!")}
className="min-h-[120px]"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="visible"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
<T>Visible</T>
</FormLabel>
<div className="text-sm text-muted-foreground">
<T>Make this picture visible to others</T>
</div>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={handleClose}
disabled={updating}
>
<T>Cancel</T>
</Button>
<Button
type="submit"
className="flex-1"
disabled={updating}
>
<T>{updating ? 'Updating...' : 'Update'}</T>
</Button>
</div>
</form>
</Form>
</TabsContent>
<TabsContent value="collections" className="mt-4">
<div className="space-y-4">
{loadingCollections ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : collections.length === 0 ? (
<div className="text-center py-8">
<Bookmark className="h-12 w-12 mx-auto mb-3 text-muted-foreground opacity-50" />
<p className="text-muted-foreground mb-4">
<T>No collections yet</T>
</p>
<Button
variant="outline"
onClick={() => {
handleClose();
// Navigate to create collection - could be improved with proper navigation
window.location.href = '/collections/new';
}}
>
<T>Create Collection</T>
</Button>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{collections.map((collection) => (
<Card
key={collection.id}
className={`cursor-pointer transition-colors ${selectedCollections.has(collection.id)
? 'bg-primary/10 border-primary'
: 'hover:bg-muted/50'
}`}
onClick={() => handleToggleCollection(collection.id)}
>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex-1 mr-3">
<div className="flex items-center gap-2">
<h4 className="font-medium">{collection.name}</h4>
{!collection.is_public && (
<span className="text-xs text-muted-foreground">(Private)</span>
)}
</div>
{collection.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{collection.description}
</p>
)}
</div>
<Checkbox
checked={selectedCollections.has(collection.id)}
onCheckedChange={() => handleToggleCollection(collection.id)}
onClick={(e) => e.stopPropagation()}
/>
</div>
</CardContent>
</Card>
))}
</div>
)}
<div className="pt-2 text-sm text-muted-foreground text-center">
<T>
{selectedCollections.size === 0
? 'Not in any collections'
: selectedCollections.size === 1
? 'In 1 collection'
: `In ${selectedCollections.size} collections`}
</T>
</div>
</div>
</TabsContent>
<TabsContent value="versions" className="mt-4">
<VersionSelector
currentPictureId={pictureId}
onVersionSelect={onUpdateSuccess}
/>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};
export default EditImageModal;