https://altly.madebydanny.uk
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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)",