https://altly.madebydanny.uk
at main 263 lines 7.8 kB view raw
1import { useState, useCallback, useEffect } from "react"; 2import { Upload, Image as ImageIcon, Loader2, Copy, Check } from "lucide-react"; 3import { Button } from "@/components/ui/button"; 4import { Card } from "@/components/ui/card"; 5import { useToast } from "@/hooks/use-toast"; 6import { supabase } from "@/integrations/supabase/client"; 7import { User } from "@supabase/supabase-js"; 8 9interface ImageUploaderProps { 10 user: User | null; 11} 12 13export const ImageUploader = ({ user }: ImageUploaderProps) => { 14 const [isDragging, setIsDragging] = useState(false); 15 const [imageFile, setImageFile] = useState<File | null>(null); 16 const [imagePreview, setImagePreview] = useState<string | null>(null); 17 const [isGenerating, setIsGenerating] = useState(false); 18 const [altText, setAltText] = useState<string | null>(null); 19 const [isCopied, setIsCopied] = useState(false); 20 const { toast } = useToast(); 21 22 const handleDragOver = useCallback((e: React.DragEvent) => { 23 e.preventDefault(); 24 setIsDragging(true); 25 }, []); 26 27 const handleDragLeave = useCallback((e: React.DragEvent) => { 28 e.preventDefault(); 29 setIsDragging(false); 30 }, []); 31 32 const handleDrop = useCallback((e: React.DragEvent) => { 33 e.preventDefault(); 34 setIsDragging(false); 35 36 const file = e.dataTransfer.files[0]; 37 if (file && file.type.startsWith('image/')) { 38 handleImageFile(file); 39 } else { 40 toast({ 41 title: "Invalid file type", 42 description: "Please upload an image file", 43 variant: "destructive", 44 }); 45 } 46 }, [toast]); 47 48 const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => { 49 const file = e.target.files?.[0]; 50 if (file) { 51 handleImageFile(file); 52 } 53 }; 54 55 const handleImageFile = (file: File) => { 56 setImageFile(file); 57 setAltText(null); 58 setIsCopied(false); 59 60 const reader = new FileReader(); 61 reader.onloadend = () => { 62 setImagePreview(reader.result as string); 63 }; 64 reader.readAsDataURL(file); 65 }; 66 67 const generateAltText = async () => { 68 if (!imageFile || !user) { 69 toast({ 70 title: "Authentication required", 71 description: "Please sign in to generate alt text", 72 variant: "destructive", 73 }); 74 return; 75 } 76 77 setIsGenerating(true); 78 setAltText(null); 79 const startTime = Date.now(); 80 81 try { 82 // Upload image to custom CDN 83 const formData = new FormData(); 84 formData.append('file', imageFile); 85 86 const uploadResponse = await fetch('https://cdn.madebydanny.uk/upload', { 87 method: 'POST', 88 body: formData, 89 }); 90 91 if (!uploadResponse.ok) { 92 throw new Error('Failed to upload image to CDN'); 93 } 94 95 const uploadData = await uploadResponse.json(); 96 const imageUrl = uploadData.url; 97 98 // Get auth token for authenticated request 99 const { data: { session } } = await supabase.auth.getSession(); 100 if (!session) { 101 throw new Error('Not authenticated'); 102 } 103 104 // Call edge function to generate alt text (authenticated) 105 const { data, error } = await supabase.functions.invoke('generate-alt-text', { 106 body: { 107 imageUrl, 108 generationTimeStart: startTime 109 } 110 }); 111 112 if (error) throw error; 113 114 setAltText(data.altText); 115 toast({ 116 title: "Success!", 117 description: "Alt text generated successfully", 118 }); 119 120 } catch (error: any) { 121 console.error('Error generating alt text:', error); 122 123 let errorMessage = "Failed to generate alt text"; 124 if (error?.message?.includes('Rate limit')) { 125 errorMessage = "Daily limit reached (20 per day). Try again tomorrow."; 126 } else if (error?.message?.includes('Authentication')) { 127 errorMessage = "Please sign in to continue"; 128 } 129 130 toast({ 131 title: "Error", 132 description: errorMessage, 133 variant: "destructive", 134 }); 135 } finally { 136 setIsGenerating(false); 137 } 138 }; 139 140 const copyToClipboard = async () => { 141 if (!altText) return; 142 143 try { 144 await navigator.clipboard.writeText(altText); 145 setIsCopied(true); 146 toast({ 147 title: "Copied!", 148 description: "Alt text copied to clipboard", 149 }); 150 setTimeout(() => setIsCopied(false), 2000); 151 } catch (error) { 152 toast({ 153 title: "Error", 154 description: "Failed to copy to clipboard", 155 variant: "destructive", 156 }); 157 } 158 }; 159 160 return ( 161 <Card className="bg-card border-border/50 p-8"> 162 <div className="flex items-center gap-3 mb-6"> 163 <ImageIcon className="w-6 h-6 text-primary" /> 164 <h2 className="text-xl font-semibold text-foreground">Upload Image</h2> 165 </div> 166 167 {!imagePreview ? ( 168 <div 169 onDragOver={handleDragOver} 170 onDragLeave={handleDragLeave} 171 onDrop={handleDrop} 172 className={` 173 border-2 border-dashed rounded-lg p-12 text-center transition-all 174 ${isDragging 175 ? 'border-primary bg-primary/5' 176 : 'border-border/50 bg-upload-area hover:border-primary/50' 177 } 178 `} 179 > 180 <Upload className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> 181 <p className="text-muted-foreground mb-4"> 182 Drag & drop your image here, or click to select 183 </p> 184 <input 185 type="file" 186 accept="image/*" 187 onChange={handleFileInput} 188 className="hidden" 189 id="file-upload" 190 /> 191 <label htmlFor="file-upload"> 192 <Button variant="secondary" className="cursor-pointer" asChild> 193 <span>Choose Image</span> 194 </Button> 195 </label> 196 </div> 197 ) : ( 198 <div className="space-y-6"> 199 <div className="relative rounded-lg overflow-hidden bg-upload-area"> 200 <img 201 src={imagePreview} 202 alt="Preview" 203 className="w-full h-auto max-h-96 object-contain" 204 /> 205 </div> 206 207 <Button 208 onClick={generateAltText} 209 disabled={isGenerating} 210 className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium" 211 size="lg" 212 > 213 {isGenerating ? ( 214 <> 215 <Loader2 className="w-5 h-5 mr-2 animate-spin" /> 216 Generating... 217 </> 218 ) : ( 219 <> 220 <ImageIcon className="w-5 h-5 mr-2" /> 221 Generate Alt Text 222 </> 223 )} 224 </Button> 225 226 {altText && ( 227 <Card className="bg-upload-area border-primary/20 p-6"> 228 <div className="flex items-start justify-between gap-4 mb-3"> 229 <h3 className="text-sm font-medium text-muted-foreground">Generated Alt Text</h3> 230 <Button 231 variant="ghost" 232 size="sm" 233 onClick={copyToClipboard} 234 className="h-8 px-3" 235 > 236 {isCopied ? ( 237 <Check className="w-4 h-4 text-primary" /> 238 ) : ( 239 <Copy className="w-4 h-4" /> 240 )} 241 </Button> 242 </div> 243 <p className="text-foreground leading-relaxed">{altText}</p> 244 </Card> 245 )} 246 247 <Button 248 variant="outline" 249 onClick={() => { 250 setImageFile(null); 251 setImagePreview(null); 252 setAltText(null); 253 setIsCopied(false); 254 }} 255 className="w-full" 256 > 257 Upload Another Image 258 </Button> 259 </div> 260 )} 261 </Card> 262 ); 263};