https://altly.madebydanny.uk

Connect to Lovable Cloud

Connect to Lovable Cloud and set up the project. This includes updating the design system, creating UI components, setting up a storage bucket for images, and creating an edge function for the Claude API.

+484 -96
+5 -5
index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>cloud-captioner</title> 7 - <meta name="description" content="Lovable Generated Project" /> 8 - <meta name="author" content="Lovable" /> 6 + <title>ALTly - AI Alt Text Generator</title> 7 + <meta name="description" content="Free AI-powered alt text generator using Claude Vision API. Generate accessible image descriptions for social media and web content." /> 8 + <meta name="author" content="Danny UK" /> 9 9 10 - <meta property="og:title" content="cloud-captioner" /> 11 - <meta property="og:description" content="Lovable Generated Project" /> 10 + <meta property="og:title" content="ALTly - AI Alt Text Generator" /> 11 + <meta property="og:description" content="Free AI-powered alt text generator using Claude Vision API" /> 12 12 <meta property="og:type" content="website" /> 13 13 <meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> 14 14
+26
src/components/Header.tsx
··· 1 + import { Eye } from "lucide-react"; 2 + 3 + export const Header = () => { 4 + return ( 5 + <header className="border-b border-border/50 backdrop-blur-sm"> 6 + <div className="container mx-auto px-4 py-4"> 7 + <div className="flex items-center justify-between"> 8 + <div className="flex items-center gap-3"> 9 + <div className="w-10 h-10 rounded-lg bg-primary/20 flex items-center justify-center"> 10 + <Eye className="w-6 h-6 text-primary" /> 11 + </div> 12 + <h1 className="text-2xl font-bold text-foreground">ALTly</h1> 13 + </div> 14 + <nav className="flex gap-6"> 15 + <a href="/" className="text-sm font-medium text-foreground hover:text-primary transition-colors"> 16 + Home 17 + </a> 18 + <a href="#" className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors"> 19 + Docs 20 + </a> 21 + </nav> 22 + </div> 23 + </div> 24 + </header> 25 + ); 26 + };
+240
src/components/ImageUploader.tsx
··· 1 + import { useState, useCallback } from "react"; 2 + import { Upload, Image as ImageIcon, Loader2, Copy, Check } from "lucide-react"; 3 + import { Button } from "@/components/ui/button"; 4 + import { Card } from "@/components/ui/card"; 5 + import { useToast } from "@/hooks/use-toast"; 6 + import { supabase } from "@/integrations/supabase/client"; 7 + 8 + export const ImageUploader = () => { 9 + const [isDragging, setIsDragging] = useState(false); 10 + const [imageFile, setImageFile] = useState<File | null>(null); 11 + const [imagePreview, setImagePreview] = useState<string | null>(null); 12 + const [isGenerating, setIsGenerating] = useState(false); 13 + const [altText, setAltText] = useState<string | null>(null); 14 + const [isCopied, setIsCopied] = useState(false); 15 + const { toast } = useToast(); 16 + 17 + const handleDragOver = useCallback((e: React.DragEvent) => { 18 + e.preventDefault(); 19 + setIsDragging(true); 20 + }, []); 21 + 22 + const handleDragLeave = useCallback((e: React.DragEvent) => { 23 + e.preventDefault(); 24 + setIsDragging(false); 25 + }, []); 26 + 27 + const handleDrop = useCallback((e: React.DragEvent) => { 28 + e.preventDefault(); 29 + setIsDragging(false); 30 + 31 + const file = e.dataTransfer.files[0]; 32 + if (file && file.type.startsWith('image/')) { 33 + handleImageFile(file); 34 + } else { 35 + toast({ 36 + title: "Invalid file type", 37 + description: "Please upload an image file", 38 + variant: "destructive", 39 + }); 40 + } 41 + }, [toast]); 42 + 43 + const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => { 44 + const file = e.target.files?.[0]; 45 + if (file) { 46 + handleImageFile(file); 47 + } 48 + }; 49 + 50 + const handleImageFile = (file: File) => { 51 + setImageFile(file); 52 + setAltText(null); 53 + setIsCopied(false); 54 + 55 + const reader = new FileReader(); 56 + reader.onloadend = () => { 57 + setImagePreview(reader.result as string); 58 + }; 59 + reader.readAsDataURL(file); 60 + }; 61 + 62 + const generateAltText = async () => { 63 + if (!imageFile) return; 64 + 65 + setIsGenerating(true); 66 + setAltText(null); 67 + 68 + try { 69 + // Upload image to Supabase storage 70 + const fileExt = imageFile.name.split('.').pop(); 71 + const fileName = `${Date.now()}.${fileExt}`; 72 + 73 + const { data: uploadData, error: uploadError } = await supabase.storage 74 + .from('alt-images') 75 + .upload(fileName, imageFile, { 76 + cacheControl: '3600', 77 + upsert: false 78 + }); 79 + 80 + if (uploadError) throw uploadError; 81 + 82 + // Get public URL 83 + const { data: { publicUrl } } = supabase.storage 84 + .from('alt-images') 85 + .getPublicUrl(fileName); 86 + 87 + // Call edge function to generate alt text 88 + const { data, error } = await supabase.functions.invoke('generate-alt-text', { 89 + body: { imageUrl: publicUrl } 90 + }); 91 + 92 + if (error) throw error; 93 + 94 + setAltText(data.altText); 95 + toast({ 96 + title: "Success!", 97 + description: "Alt text generated successfully", 98 + }); 99 + 100 + // Optional: Clean up uploaded image after a delay 101 + setTimeout(async () => { 102 + await supabase.storage.from('alt-images').remove([fileName]); 103 + }, 60000); // Clean up after 1 minute 104 + 105 + } catch (error) { 106 + console.error('Error generating alt text:', error); 107 + toast({ 108 + title: "Error", 109 + description: error instanceof Error ? error.message : "Failed to generate alt text", 110 + variant: "destructive", 111 + }); 112 + } finally { 113 + setIsGenerating(false); 114 + } 115 + }; 116 + 117 + const copyToClipboard = async () => { 118 + if (!altText) return; 119 + 120 + try { 121 + await navigator.clipboard.writeText(altText); 122 + setIsCopied(true); 123 + toast({ 124 + title: "Copied!", 125 + description: "Alt text copied to clipboard", 126 + }); 127 + setTimeout(() => setIsCopied(false), 2000); 128 + } catch (error) { 129 + toast({ 130 + title: "Error", 131 + description: "Failed to copy to clipboard", 132 + variant: "destructive", 133 + }); 134 + } 135 + }; 136 + 137 + return ( 138 + <Card className="bg-card border-border/50 p-8"> 139 + <div className="flex items-center gap-3 mb-6"> 140 + <ImageIcon className="w-6 h-6 text-primary" /> 141 + <h2 className="text-xl font-semibold text-foreground">Upload Image</h2> 142 + </div> 143 + 144 + {!imagePreview ? ( 145 + <div 146 + onDragOver={handleDragOver} 147 + onDragLeave={handleDragLeave} 148 + onDrop={handleDrop} 149 + className={` 150 + border-2 border-dashed rounded-lg p-12 text-center transition-all 151 + ${isDragging 152 + ? 'border-primary bg-primary/5' 153 + : 'border-border/50 bg-upload-area hover:border-primary/50' 154 + } 155 + `} 156 + > 157 + <Upload className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> 158 + <p className="text-muted-foreground mb-4"> 159 + Drag & drop your image here, or click to select 160 + </p> 161 + <input 162 + type="file" 163 + accept="image/*" 164 + onChange={handleFileInput} 165 + className="hidden" 166 + id="file-upload" 167 + /> 168 + <label htmlFor="file-upload"> 169 + <Button variant="secondary" className="cursor-pointer" asChild> 170 + <span>Choose Image</span> 171 + </Button> 172 + </label> 173 + </div> 174 + ) : ( 175 + <div className="space-y-6"> 176 + <div className="relative rounded-lg overflow-hidden bg-upload-area"> 177 + <img 178 + src={imagePreview} 179 + alt="Preview" 180 + className="w-full h-auto max-h-96 object-contain" 181 + /> 182 + </div> 183 + 184 + <Button 185 + onClick={generateAltText} 186 + disabled={isGenerating} 187 + className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium" 188 + size="lg" 189 + > 190 + {isGenerating ? ( 191 + <> 192 + <Loader2 className="w-5 h-5 mr-2 animate-spin" /> 193 + Generating... 194 + </> 195 + ) : ( 196 + <> 197 + <ImageIcon className="w-5 h-5 mr-2" /> 198 + Generate Alt Text 199 + </> 200 + )} 201 + </Button> 202 + 203 + {altText && ( 204 + <Card className="bg-upload-area border-primary/20 p-6"> 205 + <div className="flex items-start justify-between gap-4 mb-3"> 206 + <h3 className="text-sm font-medium text-muted-foreground">Generated Alt Text</h3> 207 + <Button 208 + variant="ghost" 209 + size="sm" 210 + onClick={copyToClipboard} 211 + className="h-8 px-3" 212 + > 213 + {isCopied ? ( 214 + <Check className="w-4 h-4 text-primary" /> 215 + ) : ( 216 + <Copy className="w-4 h-4" /> 217 + )} 218 + </Button> 219 + </div> 220 + <p className="text-foreground leading-relaxed">{altText}</p> 221 + </Card> 222 + )} 223 + 224 + <Button 225 + variant="outline" 226 + onClick={() => { 227 + setImageFile(null); 228 + setImagePreview(null); 229 + setAltText(null); 230 + setIsCopied(false); 231 + }} 232 + className="w-full" 233 + > 234 + Upload Another Image 235 + </Button> 236 + </div> 237 + )} 238 + </Card> 239 + ); 240 + };
+15
src/components/StatsCard.tsx
··· 1 + import { Card } from "@/components/ui/card"; 2 + 3 + interface StatsCardProps { 4 + label: string; 5 + value: string | number; 6 + } 7 + 8 + export const StatsCard = ({ label, value }: StatsCardProps) => { 9 + return ( 10 + <Card className="bg-stat-card border-border/50 p-6 text-center"> 11 + <div className="text-sm text-muted-foreground mb-2">{label}</div> 12 + <div className="text-3xl font-bold text-primary">{value}</div> 13 + </Card> 14 + ); 15 + };
+23 -74
src/index.css
··· 8 8 9 9 @layer base { 10 10 :root { 11 - --background: 0 0% 100%; 12 - --foreground: 222.2 84% 4.9%; 11 + --background: 220 18% 8%; 12 + --foreground: 0 0% 95%; 13 13 14 - --card: 0 0% 100%; 15 - --card-foreground: 222.2 84% 4.9%; 14 + --card: 220 15% 12%; 15 + --card-foreground: 0 0% 95%; 16 16 17 - --popover: 0 0% 100%; 18 - --popover-foreground: 222.2 84% 4.9%; 17 + --popover: 220 15% 12%; 18 + --popover-foreground: 0 0% 95%; 19 19 20 - --primary: 222.2 47.4% 11.2%; 21 - --primary-foreground: 210 40% 98%; 20 + --primary: 174 72% 56%; 21 + --primary-foreground: 220 18% 8%; 22 22 23 - --secondary: 210 40% 96.1%; 24 - --secondary-foreground: 222.2 47.4% 11.2%; 23 + --secondary: 220 15% 18%; 24 + --secondary-foreground: 0 0% 95%; 25 25 26 - --muted: 210 40% 96.1%; 27 - --muted-foreground: 215.4 16.3% 46.9%; 26 + --muted: 220 15% 18%; 27 + --muted-foreground: 0 0% 65%; 28 28 29 - --accent: 210 40% 96.1%; 30 - --accent-foreground: 222.2 47.4% 11.2%; 31 - 32 - --destructive: 0 84.2% 60.2%; 33 - --destructive-foreground: 210 40% 98%; 34 - 35 - --border: 214.3 31.8% 91.4%; 36 - --input: 214.3 31.8% 91.4%; 37 - --ring: 222.2 84% 4.9%; 38 - 39 - --radius: 0.5rem; 29 + --accent: 174 72% 56%; 30 + --accent-foreground: 220 18% 8%; 40 31 41 - --sidebar-background: 0 0% 98%; 32 + --destructive: 0 72% 51%; 33 + --destructive-foreground: 0 0% 98%; 42 34 43 - --sidebar-foreground: 240 5.3% 26.1%; 35 + --border: 220 15% 18%; 36 + --input: 220 15% 18%; 37 + --ring: 174 72% 56%; 44 38 45 - --sidebar-primary: 240 5.9% 10%; 46 - 47 - --sidebar-primary-foreground: 0 0% 98%; 48 - 49 - --sidebar-accent: 240 4.8% 95.9%; 50 - 51 - --sidebar-accent-foreground: 240 5.9% 10%; 52 - 53 - --sidebar-border: 220 13% 91%; 54 - 55 - --sidebar-ring: 217.2 91.2% 59.8%; 56 - } 57 - 58 - .dark { 59 - --background: 222.2 84% 4.9%; 60 - --foreground: 210 40% 98%; 61 - 62 - --card: 222.2 84% 4.9%; 63 - --card-foreground: 210 40% 98%; 64 - 65 - --popover: 222.2 84% 4.9%; 66 - --popover-foreground: 210 40% 98%; 67 - 68 - --primary: 210 40% 98%; 69 - --primary-foreground: 222.2 47.4% 11.2%; 70 - 71 - --secondary: 217.2 32.6% 17.5%; 72 - --secondary-foreground: 210 40% 98%; 73 - 74 - --muted: 217.2 32.6% 17.5%; 75 - --muted-foreground: 215 20.2% 65.1%; 76 - 77 - --accent: 217.2 32.6% 17.5%; 78 - --accent-foreground: 210 40% 98%; 79 - 80 - --destructive: 0 62.8% 30.6%; 81 - --destructive-foreground: 210 40% 98%; 82 - 83 - --border: 217.2 32.6% 17.5%; 84 - --input: 217.2 32.6% 17.5%; 85 - --ring: 212.7 26.8% 83.9%; 86 - --sidebar-background: 240 5.9% 10%; 87 - --sidebar-foreground: 240 4.8% 95.9%; 88 - --sidebar-primary: 224.3 76.3% 48%; 89 - --sidebar-primary-foreground: 0 0% 100%; 90 - --sidebar-accent: 240 3.7% 15.9%; 91 - --sidebar-accent-foreground: 240 4.8% 95.9%; 92 - --sidebar-border: 240 3.7% 15.9%; 93 - --sidebar-ring: 217.2 91.2% 59.8%; 39 + --radius: 0.75rem; 40 + 41 + --stat-card: 220 15% 15%; 42 + --upload-area: 220 15% 10%; 94 43 } 95 44 } 96 45
+46 -6
src/pages/Index.tsx
··· 1 - // Update this page (the content is just a fallback if you fail to update the page) 1 + import { Header } from "@/components/Header"; 2 + import { StatsCard } from "@/components/StatsCard"; 3 + import { ImageUploader } from "@/components/ImageUploader"; 4 + import { Eye } from "lucide-react"; 2 5 3 6 const Index = () => { 4 7 return ( 5 - <div className="flex min-h-screen items-center justify-center bg-background"> 6 - <div className="text-center"> 7 - <h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1> 8 - <p className="text-xl text-muted-foreground">Start building your amazing project here!</p> 9 - </div> 8 + <div className="min-h-screen bg-background"> 9 + <Header /> 10 + 11 + <main className="container mx-auto px-4 py-12"> 12 + {/* Hero Section */} 13 + <div className="text-center mb-12"> 14 + <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/20 mb-6"> 15 + <Eye className="w-8 h-8 text-primary" /> 16 + </div> 17 + <h1 className="text-4xl md:text-5xl font-bold text-foreground mb-4"> 18 + ALTly 19 + </h1> 20 + <p className="text-lg text-muted-foreground max-w-2xl mx-auto"> 21 + A free-for-life ALT text generator powered by Claude AI with a free public API. 22 + </p> 23 + </div> 24 + 25 + {/* Stats Section */} 26 + <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-12 max-w-4xl mx-auto"> 27 + <StatsCard label="Images Uploaded" value="25" /> 28 + <StatsCard label="Happy Users" value="4" /> 29 + <StatsCard label="Avg Response (ms)" value="6,161" /> 30 + </div> 31 + 32 + {/* Upload Section */} 33 + <div className="max-w-3xl mx-auto mb-12"> 34 + <ImageUploader /> 35 + </div> 36 + 37 + {/* Footer */} 38 + <footer className="text-center text-sm text-muted-foreground py-8"> 39 + © 2024-2025 Made with ❤️ by{" "} 40 + <a 41 + href="https://github.com/dannyuk" 42 + target="_blank" 43 + rel="noopener noreferrer" 44 + className="text-primary hover:underline" 45 + > 46 + Danny UK 47 + </a> 48 + </footer> 49 + </main> 10 50 </div> 11 51 ); 12 52 };
+4 -1
supabase/config.toml
··· 1 - project_id = "fcjluprfltmmrclfzxmr" 1 + project_id = "fcjluprfltmmrclfzxmr" 2 + 3 + [functions.generate-alt-text] 4 + verify_jwt = false
+105
supabase/functions/generate-alt-text/index.ts
··· 1 + import "https://deno.land/x/xhr@0.1.0/mod.ts"; 2 + import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; 3 + 4 + const corsHeaders = { 5 + 'Access-Control-Allow-Origin': '*', 6 + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 7 + }; 8 + 9 + serve(async (req) => { 10 + if (req.method === 'OPTIONS') { 11 + return new Response(null, { headers: corsHeaders }); 12 + } 13 + 14 + try { 15 + const { imageUrl } = await req.json(); 16 + console.log('Generating alt text for image:', imageUrl); 17 + 18 + if (!imageUrl) { 19 + return new Response( 20 + JSON.stringify({ error: 'Image URL is required' }), 21 + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 22 + ); 23 + } 24 + 25 + const anthropicApiKey = Deno.env.get('ANTHROPIC_API_KEY'); 26 + if (!anthropicApiKey) { 27 + console.error('ANTHROPIC_API_KEY is not set'); 28 + return new Response( 29 + JSON.stringify({ error: 'API key not configured' }), 30 + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 31 + ); 32 + } 33 + 34 + // Fetch the image as base64 35 + const imageResponse = await fetch(imageUrl); 36 + if (!imageResponse.ok) { 37 + console.error('Failed to fetch image:', imageResponse.status); 38 + return new Response( 39 + JSON.stringify({ error: 'Failed to fetch image' }), 40 + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 41 + ); 42 + } 43 + 44 + const imageBuffer = await imageResponse.arrayBuffer(); 45 + const base64Image = btoa(String.fromCharCode(...new Uint8Array(imageBuffer))); 46 + const contentType = imageResponse.headers.get('content-type') || 'image/jpeg'; 47 + 48 + console.log('Calling Claude API...'); 49 + const response = await fetch('https://api.anthropic.com/v1/messages', { 50 + method: 'POST', 51 + headers: { 52 + 'Content-Type': 'application/json', 53 + 'x-api-key': anthropicApiKey, 54 + 'anthropic-version': '2023-06-01', 55 + }, 56 + body: JSON.stringify({ 57 + model: 'claude-sonnet-4-5', 58 + max_tokens: 1024, 59 + messages: [ 60 + { 61 + role: 'user', 62 + content: [ 63 + { 64 + type: 'image', 65 + source: { 66 + type: 'base64', 67 + media_type: contentType, 68 + data: base64Image, 69 + }, 70 + }, 71 + { 72 + type: 'text', 73 + text: 'Generate a concise, descriptive alt text for this image suitable for social media. Focus on the main subject and important details. Keep it under 125 characters for optimal accessibility.', 74 + }, 75 + ], 76 + }, 77 + ], 78 + }), 79 + }); 80 + 81 + if (!response.ok) { 82 + const errorData = await response.text(); 83 + console.error('Claude API error:', response.status, errorData); 84 + return new Response( 85 + JSON.stringify({ error: 'Failed to generate alt text', details: errorData }), 86 + { status: response.status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 87 + ); 88 + } 89 + 90 + const data = await response.json(); 91 + const altText = data.content[0].text; 92 + console.log('Generated alt text:', altText); 93 + 94 + return new Response( 95 + JSON.stringify({ altText }), 96 + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 97 + ); 98 + } catch (error) { 99 + console.error('Error in generate-alt-text function:', error); 100 + return new Response( 101 + JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }), 102 + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 103 + ); 104 + } 105 + });
+18
supabase/migrations/20251107154446_55fa5db6-4d3f-4999-9da4-debb6ff1b183.sql
··· 1 + -- Create storage bucket for uploaded images 2 + INSERT INTO storage.buckets (id, name, public) 3 + VALUES ('alt-images', 'alt-images', true); 4 + 5 + -- Create policy to allow anyone to upload images 6 + CREATE POLICY "Anyone can upload images" 7 + ON storage.objects FOR INSERT 8 + WITH CHECK (bucket_id = 'alt-images'); 9 + 10 + -- Create policy to allow anyone to read images 11 + CREATE POLICY "Anyone can view images" 12 + ON storage.objects FOR SELECT 13 + USING (bucket_id = 'alt-images'); 14 + 15 + -- Create policy to allow anyone to delete their uploads (optional cleanup) 16 + CREATE POLICY "Anyone can delete images" 17 + ON storage.objects FOR DELETE 18 + USING (bucket_id = 'alt-images');
+2 -10
tailwind.config.ts
··· 47 47 DEFAULT: "hsl(var(--card))", 48 48 foreground: "hsl(var(--card-foreground))", 49 49 }, 50 - sidebar: { 51 - DEFAULT: "hsl(var(--sidebar-background))", 52 - foreground: "hsl(var(--sidebar-foreground))", 53 - primary: "hsl(var(--sidebar-primary))", 54 - "primary-foreground": "hsl(var(--sidebar-primary-foreground))", 55 - accent: "hsl(var(--sidebar-accent))", 56 - "accent-foreground": "hsl(var(--sidebar-accent-foreground))", 57 - border: "hsl(var(--sidebar-border))", 58 - ring: "hsl(var(--sidebar-ring))", 59 - }, 50 + "stat-card": "hsl(var(--stat-card))", 51 + "upload-area": "hsl(var(--upload-area))", 60 52 }, 61 53 borderRadius: { 62 54 lg: "var(--radius)",