├── public ├── bg.mp4 ├── favicon.ico ├── og-image.jpg ├── browserconfig.xml ├── site.webmanifest └── README.md ├── postcss.config.js ├── .gitignore ├── .vercelignore ├── src ├── lib │ ├── utils.ts │ ├── theme-context.tsx │ ├── supabase.ts │ ├── auth-context.tsx │ └── database.ts ├── components │ ├── ui │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── slider.tsx │ │ ├── badge.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── tabs.tsx │ │ ├── card.tsx │ │ ├── table.tsx │ │ ├── dialog.tsx │ │ └── dropdown-menu.tsx │ ├── theme-toggle.tsx │ ├── user-menu.tsx │ ├── image-viewer.tsx │ ├── auth-modal.tsx │ └── queue-view.tsx ├── app │ ├── api │ │ ├── gemini │ │ │ ├── parse │ │ │ │ └── route.ts │ │ │ └── edit │ │ │ │ └── route.ts │ │ ├── edit │ │ │ └── route.ts │ │ └── reddit │ │ │ └── posts │ │ │ └── route.ts │ ├── layout.tsx │ ├── globals.css │ ├── app │ │ └── page.tsx │ └── page.tsx └── types │ └── index.ts ├── vercel.json ├── next-env.d.ts ├── next.config.js ├── tsconfig.json ├── package.json ├── tailwind.config.ts ├── README.md └── supabase-schema.sql /public/bg.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farizanjum/fixtral/HEAD/public/bg.mp4 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farizanjum/fixtral/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farizanjum/fixtral/HEAD/public/og-image.jpg -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | out 4 | .env* 5 | .vscode 6 | .idea 7 | .DS_Store 8 | Thumbs.db 9 | *.log 10 | .env.local* -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | out 4 | .env* 5 | .vscode 6 | .idea 7 | .DS_Store 8 | Thumbs.db 9 | .git 10 | *.log 11 | .env.local* -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "npm run build", 3 | "framework": "nextjs", 4 | "functions": { 5 | "src/app/api/**/*.ts": { 6 | "maxDuration": 30 7 | } 8 | }, 9 | "regions": ["fra1"] 10 | } -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #000000 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Fixtral - AI Photoshop Assistant", 3 | "short_name": "Fixtral", 4 | "description": "Automate image edits from Reddit's r/PhotoshopRequest using AI", 5 | "start_url": "/", 6 | "display": "standalone", 7 | "background_color": "#000000", 8 | "theme_color": "#ffffff", 9 | "icons": [ 10 | { 11 | "src": "/favicon.ico", 12 | "sizes": "any", 13 | "type": "image/x-icon" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: [ 5 | 'i.redd.it', 6 | 'preview.redd.it', 7 | 'external-preview.redd.it', 8 | 'b.thumbs.redditmedia.com', 9 | 'a.thumbs.redditmedia.com', 10 | 'imgur.com', 11 | 'i.imgur.com' 12 | ], 13 | }, 14 | // experimental: { 15 | // outputFileTracingExcludes: [ 16 | // "node_modules/.cache/**", 17 | // "node_modules/.bin/**", 18 | // ".next/cache/**", 19 | // ".git/**" 20 | // ] 21 | // } 22 | } 23 | 24 | module.exports = nextConfig 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "es6"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "baseUrl": ".", 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React from 'react' 4 | import { Moon, Sun } from 'lucide-react' 5 | import { Button } from '@/components/ui/button' 6 | import { useTheme } from '@/lib/theme-context' 7 | 8 | export const ThemeToggle: React.FC = () => { 9 | const { theme, toggleTheme } = useTheme() 10 | 11 | return ( 12 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SliderPrimitive from "@radix-ui/react-slider" 5 | import { cn } from "@/lib/utils" 6 | 7 | const Slider = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 20 | 21 | 22 | 23 | 24 | )) 25 | Slider.displayName = SliderPrimitive.Root.displayName 26 | 27 | export { Slider } 28 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | import { cn } from "@/lib/utils" 4 | 5 | const badgeVariants = cva( 6 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 7 | { 8 | variants: { 9 | variant: { 10 | default: 11 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 12 | secondary: 13 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 14 | destructive: 15 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 16 | outline: "text-foreground", 17 | }, 18 | }, 19 | defaultVariants: { 20 | variant: "default", 21 | }, 22 | } 23 | ) 24 | 25 | export interface BadgeProps 26 | extends React.HTMLAttributes, 27 | VariantProps {} 28 | 29 | function Badge({ className, variant, ...props }: BadgeProps) { 30 | return ( 31 |
32 | ) 33 | } 34 | 35 | export { Badge, badgeVariants } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fixtral", 3 | "version": "0.2.0", 4 | "description": "AI-powered Photoshop assistant for Reddit's r/PhotoshopRequest", 5 | "private": true, 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@google/generative-ai": "^0.15.0", 14 | "@radix-ui/react-avatar": "^1.1.10", 15 | "@radix-ui/react-dialog": "^1.1.15", 16 | "@radix-ui/react-dropdown-menu": "^2.1.16", 17 | "@radix-ui/react-label": "^2.1.7", 18 | "@radix-ui/react-slider": "^1.1.2", 19 | "@radix-ui/react-slot": "^1.0.2", 20 | "@radix-ui/react-tabs": "^1.0.4", 21 | "@supabase/supabase-js": "^2.56.1", 22 | "@vercel/analytics": "^1.5.0", 23 | "class-variance-authority": "^0.7.1", 24 | "clsx": "^2.1.1", 25 | "lucide-react": "^0.294.0", 26 | "next": "^15.5.2", 27 | "openai": "^4.28.0", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "sharp": "^0.34.3", 31 | "tailwind-merge": "^2.6.0", 32 | "tailwindcss-animate": "^1.0.7" 33 | }, 34 | "devDependencies": { 35 | "@types/node": "^20.0.0", 36 | "@types/react": "^18.2.0", 37 | "@types/react-dom": "^18.2.0", 38 | "autoprefixer": "^10.4.0", 39 | "eslint": "^8.0.0", 40 | "eslint-config-next": "14.0.0", 41 | "postcss": "^8.4.0", 42 | "tailwindcss": "^3.3.0", 43 | "typescript": "^5.0.0" 44 | }, 45 | "resolutions": { 46 | "micromatch": "4.0.5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )) 19 | Avatar.displayName = AvatarPrimitive.Root.displayName 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )) 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )) 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 47 | 48 | export { Avatar, AvatarImage, AvatarFallback } 49 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | import { cn } from "@/lib/utils" 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: 15 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 16 | secondary: 17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "text-primary underline-offset-4 hover:underline", 20 | }, 21 | size: { 22 | default: "h-10 px-4 py-2", 23 | sm: "h-9 rounded-md px-3", 24 | lg: "h-11 rounded-md px-8", 25 | icon: "h-10 w-10", 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: "default", 30 | size: "default", 31 | }, 32 | } 33 | ) 34 | 35 | export interface ButtonProps 36 | extends React.ButtonHTMLAttributes, 37 | VariantProps { 38 | asChild?: boolean 39 | } 40 | 41 | const Button = React.forwardRef( 42 | ({ className, variant, size, asChild = false, ...props }, ref) => { 43 | const Comp = asChild ? Slot : "button" 44 | return ( 45 | 50 | ) 51 | } 52 | ) 53 | Button.displayName = "Button" 54 | 55 | export { Button, buttonVariants } 56 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | import { cn } from "@/lib/utils" 6 | 7 | const Tabs = TabsPrimitive.Root 8 | 9 | const TabsList = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | )) 22 | TabsList.displayName = TabsPrimitive.List.displayName 23 | 24 | const TabsTrigger = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, ...props }, ref) => ( 28 | 36 | )) 37 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 38 | 39 | const TabsContent = React.forwardRef< 40 | React.ElementRef, 41 | React.ComponentPropsWithoutRef 42 | >(({ className, ...props }, ref) => ( 43 | 51 | )) 52 | TabsContent.displayName = TabsPrimitive.Content.displayName 53 | 54 | export { Tabs, TabsList, TabsTrigger, TabsContent } 55 | -------------------------------------------------------------------------------- /src/lib/theme-context.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { createContext, useContext, useEffect, useState } from 'react' 4 | import { Theme, ThemeContextType } from '@/types' 5 | 6 | const ThemeContext = createContext({ 7 | theme: 'dark', 8 | toggleTheme: () => { 9 | // Fallback function - will be replaced by actual provider 10 | console.warn('ThemeProvider not found, using fallback') 11 | } 12 | }) 13 | 14 | export const useTheme = () => { 15 | const context = useContext(ThemeContext) 16 | if (!context) { 17 | throw new Error('useTheme must be used within a ThemeProvider') 18 | } 19 | return context 20 | } 21 | 22 | interface ThemeProviderProps { 23 | children: React.ReactNode 24 | } 25 | 26 | export const ThemeProvider: React.FC = ({ children }) => { 27 | const [theme, setTheme] = useState('dark') // Default to dark mode 28 | const [mounted, setMounted] = useState(false) 29 | 30 | // Load theme from localStorage on mount 31 | useEffect(() => { 32 | const savedTheme = localStorage.getItem('fixtral-theme') as Theme 33 | if (savedTheme) { 34 | setTheme(savedTheme) 35 | } else { 36 | // Default to dark mode if no saved preference 37 | setTheme('dark') 38 | localStorage.setItem('fixtral-theme', 'dark') 39 | } 40 | setMounted(true) 41 | }, []) 42 | 43 | // Update document class and localStorage when theme changes 44 | useEffect(() => { 45 | if (mounted) { 46 | const root = document.documentElement 47 | root.classList.remove('light', 'dark') 48 | root.classList.add(theme) 49 | localStorage.setItem('fixtral-theme', theme) 50 | } 51 | }, [theme, mounted]) 52 | 53 | const toggleTheme = () => { 54 | setTheme(prevTheme => prevTheme === 'dark' ? 'light' : 'dark') 55 | } 56 | 57 | // Prevent hydration mismatch 58 | if (!mounted) { 59 | return
{children}
60 | } 61 | 62 | return ( 63 | 64 | {children} 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cn } from "@/lib/utils" 3 | 4 | const Card = React.forwardRef< 5 | HTMLDivElement, 6 | React.HTMLAttributes 7 | >(({ className, ...props }, ref) => ( 8 |
16 | )) 17 | Card.displayName = "Card" 18 | 19 | const CardHeader = React.forwardRef< 20 | HTMLDivElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 |
28 | )) 29 | CardHeader.displayName = "CardHeader" 30 | 31 | const CardTitle = React.forwardRef< 32 | HTMLParagraphElement, 33 | React.HTMLAttributes 34 | >(({ className, ...props }, ref) => ( 35 |

43 | )) 44 | CardTitle.displayName = "CardTitle" 45 | 46 | const CardDescription = React.forwardRef< 47 | HTMLParagraphElement, 48 | React.HTMLAttributes 49 | >(({ className, ...props }, ref) => ( 50 |

55 | )) 56 | CardDescription.displayName = "CardDescription" 57 | 58 | const CardContent = React.forwardRef< 59 | HTMLDivElement, 60 | React.HTMLAttributes 61 | >(({ className, ...props }, ref) => ( 62 |

63 | )) 64 | CardContent.displayName = "CardContent" 65 | 66 | const CardFooter = React.forwardRef< 67 | HTMLDivElement, 68 | React.HTMLAttributes 69 | >(({ className, ...props }, ref) => ( 70 |
75 | )) 76 | CardFooter.displayName = "CardFooter" 77 | 78 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 79 | -------------------------------------------------------------------------------- /src/app/api/gemini/parse/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server' 2 | import { GoogleGenerativeAI } from '@google/generative-ai' 3 | 4 | interface ParseRequest { 5 | title: string 6 | body?: string 7 | } 8 | 9 | interface EditForm { 10 | task_type: string 11 | instructions: string 12 | objects_to_remove: string[] 13 | objects_to_add: string[] 14 | style: string 15 | mask_needed: boolean 16 | nsfw_flag: boolean 17 | additional_instructions?: string 18 | } 19 | 20 | export async function POST(request: NextRequest) { 21 | try { 22 | const { title, body }: ParseRequest = await request.json() 23 | 24 | if (!title) { 25 | return NextResponse.json( 26 | { error: 'Title is required' }, 27 | { status: 400 } 28 | ) 29 | } 30 | 31 | const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!) 32 | const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' }) 33 | 34 | const prompt = ` 35 | You convert Photoshop requests into strict JSON with keys: 36 | task_type, instructions, objects_to_remove, objects_to_add, style, mask_needed, nsfw_flag. 37 | 38 | Text: 39 | "${title}${body ? `\n\n${body}` : ''}" 40 | Return ONLY JSON. 41 | ` 42 | 43 | const { response } = await model.generateContent(prompt) 44 | const brief = JSON.parse(response.text()) 45 | 46 | return NextResponse.json(brief) 47 | 48 | } catch (error) { 49 | console.error('Error parsing request with Gemini:', error) 50 | return NextResponse.json( 51 | { 52 | task_type: 'other', 53 | instructions: 'Error parsing request', 54 | objects_to_remove: [], 55 | objects_to_add: [], 56 | style: 'realistic', 57 | mask_needed: false, 58 | nsfw_flag: false 59 | }, 60 | { status: 500 } 61 | ) 62 | } 63 | } 64 | 65 | // Optional: GET endpoint for testing 66 | export async function GET() { 67 | const testForm: EditForm = { 68 | task_type: 'object_removal', 69 | instructions: 'Remove the background clutter', 70 | objects_to_remove: ['clutter'], 71 | objects_to_add: [], 72 | style: 'realistic', 73 | mask_needed: true, 74 | nsfw_flag: false 75 | } 76 | 77 | return NextResponse.json(testForm) 78 | } 79 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | darkMode: ['class'], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | prefix: '', 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: '2rem', 16 | screens: { 17 | '2xl': '1400px', 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: 'hsl(var(--border))', 23 | input: 'hsl(var(--input))', 24 | ring: 'hsl(var(--ring))', 25 | background: 'hsl(var(--background))', 26 | foreground: 'hsl(var(--foreground))', 27 | primary: { 28 | DEFAULT: 'hsl(var(--primary))', 29 | foreground: 'hsl(var(--primary-foreground))', 30 | }, 31 | secondary: { 32 | DEFAULT: 'hsl(var(--secondary))', 33 | foreground: 'hsl(var(--secondary-foreground))', 34 | }, 35 | destructive: { 36 | DEFAULT: 'hsl(var(--destructive))', 37 | foreground: 'hsl(var(--destructive-foreground))', 38 | }, 39 | muted: { 40 | DEFAULT: 'hsl(var(--muted))', 41 | foreground: 'hsl(var(--muted-foreground))', 42 | }, 43 | accent: { 44 | DEFAULT: 'hsl(var(--accent))', 45 | foreground: 'hsl(var(--accent-foreground))', 46 | }, 47 | popover: { 48 | DEFAULT: 'hsl(var(--popover))', 49 | foreground: 'hsl(var(--popover-foreground))', 50 | }, 51 | card: { 52 | DEFAULT: 'hsl(var(--card))', 53 | foreground: 'hsl(var(--card-foreground))', 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: 'var(--radius)', 58 | md: 'calc(var(--radius) - 2px)', 59 | sm: 'calc(var(--radius) - 4px)', 60 | }, 61 | keyframes: { 62 | 'accordion-down': { 63 | from: { height: '0' }, 64 | to: { height: 'var(--radix-accordion-content-height)' }, 65 | }, 66 | 'accordion-up': { 67 | from: { height: 'var(--radix-accordion-content-height)' }, 68 | to: { height: '0' }, 69 | }, 70 | }, 71 | animation: { 72 | 'accordion-down': 'accordion-down 0.2s ease-out', 73 | 'accordion-up': 'accordion-up 0.2s ease-out', 74 | }, 75 | }, 76 | }, 77 | plugins: [require('tailwindcss-animate')], 78 | } 79 | 80 | export default config 81 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | // Reddit API Types 2 | export interface RedditPost { 3 | id: string 4 | title: string 5 | selftext: string 6 | url: string 7 | author: string 8 | created_utc: number 9 | thumbnail?: string 10 | num_comments?: number 11 | score?: number 12 | permalink?: string 13 | } 14 | 15 | // Edit Form Types (Gemini Parse Output) 16 | export interface EditForm { 17 | task_type: 'object_removal' | 'object_addition' | 'color_enhancement' | 'background_removal' | 'text_addition' | 'style_transfer' | 'other' 18 | instructions: string 19 | objects_to_remove: string[] 20 | objects_to_add: string[] 21 | style: 'realistic' | 'artistic' | 'vintage' | 'modern' | 'other' 22 | mask_needed: boolean 23 | additional_instructions?: string 24 | } 25 | 26 | // Processing Request Types 27 | export interface EditRequest { 28 | id: string 29 | post: RedditPost 30 | status: 'pending' | 'processing' | 'completed' | 'failed' 31 | editForm?: EditForm 32 | editedImageUrl?: string 33 | error?: string 34 | processingTime?: number 35 | timestamp: number 36 | } 37 | 38 | // History Item Types 39 | export interface HistoryItem { 40 | id: string 41 | postId: string 42 | postTitle: string 43 | requestText: string 44 | status: 'completed' | 'failed' 45 | originalImageUrl: string 46 | editedImageUrl?: string 47 | editForm: EditForm 48 | timestamp: number 49 | processingTime?: number 50 | } 51 | 52 | // API Response Types 53 | export interface RedditApiResponse { 54 | posts: RedditPost[] 55 | total: number 56 | timestamp: string 57 | note?: string 58 | } 59 | 60 | export interface ParseApiResponse extends EditForm {} 61 | 62 | export interface EditApiResponse { 63 | success: boolean 64 | imageUrl?: string 65 | error?: string 66 | processingTime: number 67 | } 68 | 69 | // Component Props Types 70 | export interface QueueViewProps { 71 | onProcessEdit?: (post: RedditPost) => Promise 72 | refreshTrigger?: number 73 | } 74 | 75 | export interface EditorViewProps { 76 | currentRequest?: EditRequest 77 | onDownload?: (url: string, filename: string) => void 78 | } 79 | 80 | export interface HistoryViewProps { 81 | items?: HistoryItem[] 82 | onViewDetails?: (item: HistoryItem) => void 83 | onDownload?: (url: string, filename: string) => void 84 | } 85 | 86 | // Dashboard State Types 87 | export interface DashboardState { 88 | activeTab: 'queue' | 'editor' | 'history' 89 | posts: RedditPost[] 90 | editRequests: EditRequest[] 91 | history: HistoryItem[] 92 | selectedResult?: EditRequest 93 | loading: boolean 94 | } 95 | 96 | // Error Types 97 | export interface ApiError { 98 | message: string 99 | code?: string 100 | status?: number 101 | } 102 | 103 | // Utility Types 104 | export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed' 105 | export type TaskType = EditForm['task_type'] 106 | export type StyleType = EditForm['style'] 107 | 108 | // Theme Types 109 | export type Theme = 'dark' | 'light' 110 | 111 | export interface ThemeContextType { 112 | theme: Theme 113 | toggleTheme: () => void 114 | } -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cn } from "@/lib/utils" 3 | 4 | const Table = React.forwardRef< 5 | HTMLTableElement, 6 | React.HTMLAttributes 7 | >(({ className, ...props }, ref) => ( 8 |
9 | 14 | 15 | )) 16 | Table.displayName = "Table" 17 | 18 | const TableHeader = React.forwardRef< 19 | HTMLTableSectionElement, 20 | React.HTMLAttributes 21 | >(({ className, ...props }, ref) => ( 22 | 23 | )) 24 | TableHeader.displayName = "TableHeader" 25 | 26 | const TableBody = React.forwardRef< 27 | HTMLTableSectionElement, 28 | React.HTMLAttributes 29 | >(({ className, ...props }, ref) => ( 30 | 35 | )) 36 | TableBody.displayName = "TableBody" 37 | 38 | const TableFooter = React.forwardRef< 39 | HTMLTableSectionElement, 40 | React.HTMLAttributes 41 | >(({ className, ...props }, ref) => ( 42 | tr]:last:border-b-0", 46 | className 47 | )} 48 | {...props} 49 | /> 50 | )) 51 | TableFooter.displayName = "TableFooter" 52 | 53 | const TableRow = React.forwardRef< 54 | HTMLTableRowElement, 55 | React.HTMLAttributes 56 | >(({ className, ...props }, ref) => ( 57 | 65 | )) 66 | TableRow.displayName = "TableRow" 67 | 68 | const TableHead = React.forwardRef< 69 | HTMLTableCellElement, 70 | React.ThHTMLAttributes 71 | >(({ className, ...props }, ref) => ( 72 |
80 | )) 81 | TableHead.displayName = "TableHead" 82 | 83 | const TableCell = React.forwardRef< 84 | HTMLTableCellElement, 85 | React.TdHTMLAttributes 86 | >(({ className, ...props }, ref) => ( 87 | 92 | )) 93 | TableCell.displayName = "TableCell" 94 | 95 | const TableCaption = React.forwardRef< 96 | HTMLTableCaptionElement, 97 | React.HTMLAttributes 98 | >(({ className, ...props }, ref) => ( 99 |
104 | )) 105 | TableCaption.displayName = "TableCaption" 106 | 107 | export { 108 | Table, 109 | TableHeader, 110 | TableBody, 111 | TableFooter, 112 | TableHead, 113 | TableRow, 114 | TableCell, 115 | TableCaption, 116 | } 117 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Inter } from 'next/font/google' 3 | import './globals.css' 4 | import { ThemeProvider } from '@/lib/theme-context' 5 | import { AuthProvider } from '@/lib/auth-context' 6 | import { Analytics } from '@vercel/analytics/next' 7 | 8 | const inter = Inter({ subsets: ['latin'] }) 9 | 10 | export const metadata: Metadata = { 11 | title: 'Fixtral - AI Photoshop Assistant', 12 | description: 'Automate image edits from Reddit\'s r/PhotoshopRequest using Google Gemini AI. Transform your images with AI-powered editing tools.', 13 | keywords: ['AI image editing', 'Photoshop automation', 'Reddit PhotoshopRequest', 'Google Gemini AI', 'image transformation', 'AI art generation'], 14 | authors: [{ name: 'Fariz Anjum' }], 15 | creator: 'Fariz Anjum', 16 | publisher: 'Fixtral', 17 | formatDetection: { 18 | email: false, 19 | address: false, 20 | telephone: false, 21 | }, 22 | 23 | // Basic meta tags 24 | metadataBase: new URL('https://fixtral.com'), // Replace with your actual domain 25 | alternates: { 26 | canonical: '/', 27 | }, 28 | 29 | // Open Graph / Facebook 30 | openGraph: { 31 | type: 'website', 32 | url: '/', 33 | title: 'Fixtral - AI Photoshop Assistant', 34 | description: 'Transform your images with AI-powered editing. Automate edits from Reddit\'s r/PhotoshopRequest using Google Gemini AI.', 35 | siteName: 'Fixtral', 36 | images: [ 37 | { 38 | url: '/og-image.jpg', 39 | width: 1200, 40 | height: 630, 41 | alt: 'Fixtral - AI Photoshop Assistant', 42 | }, 43 | ], 44 | locale: 'en_US', 45 | }, 46 | 47 | // Twitter 48 | twitter: { 49 | card: 'summary_large_image', 50 | title: 'Fixtral - AI Photoshop Assistant', 51 | description: 'Transform your images with AI-powered editing. Automate edits from Reddit\'s r/PhotoshopRequest using Google Gemini AI.', 52 | images: ['/og-image.jpg'], 53 | creator: '@anjumfariz', 54 | }, 55 | 56 | // Favicons and icons 57 | icons: { 58 | icon: '/favicon.ico', 59 | }, 60 | 61 | // Additional meta tags 62 | other: { 63 | 'theme-color': '#000000', 64 | 'msapplication-TileColor': '#000000', 65 | 'msapplication-config': '/browserconfig.xml', 66 | }, 67 | 68 | // Robots 69 | robots: { 70 | index: true, 71 | follow: true, 72 | googleBot: { 73 | index: true, 74 | follow: true, 75 | 'max-video-preview': -1, 76 | 'max-image-preview': 'large', 77 | 'max-snippet': -1, 78 | }, 79 | }, 80 | 81 | // Verification (add your actual verification codes) 82 | verification: { 83 | google: 'your-google-site-verification-code', 84 | yandex: 'your-yandex-verification-code', 85 | // Note: Bing verification is handled through other means 86 | }, 87 | } 88 | 89 | export default function RootLayout({ 90 | children, 91 | }: { 92 | children: React.ReactNode 93 | }) { 94 | return ( 95 | 96 | 97 | 98 | 99 | {children} 100 | 101 | 102 | 103 | 104 | 105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /src/app/api/gemini/edit/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server' 2 | import OpenAI from 'openai' 3 | 4 | interface EditRequest { 5 | imageUrl: string 6 | brief: { 7 | task_type: string 8 | instructions: string 9 | objects_to_remove: string[] 10 | objects_to_add: string[] 11 | style: string 12 | mask_needed: boolean 13 | nsfw_flag: boolean 14 | additional_instructions?: string 15 | } 16 | } 17 | 18 | interface EditResult { 19 | success: boolean 20 | content?: string 21 | error?: string 22 | processingTime: number 23 | } 24 | 25 | export async function POST(request: NextRequest) { 26 | const startTime = Date.now() 27 | 28 | try { 29 | const { imageUrl, brief }: EditRequest = await request.json() 30 | 31 | if (!imageUrl || !brief) { 32 | return NextResponse.json( 33 | { error: 'Image URL and brief are required' }, 34 | { status: 400 } 35 | ) 36 | } 37 | 38 | const openrouter = new OpenAI({ 39 | baseURL: 'https://openrouter.ai/api/v1', 40 | apiKey: process.env.OPENROUTER_API_KEY!, 41 | }) 42 | 43 | // For Gemini 2.5 Flash Image, we need to fetch the image and convert to base64 44 | console.log('Fetching image for Gemini 2.5 Flash Image processing...') 45 | const imageResponse = await fetch(imageUrl) 46 | if (!imageResponse.ok) { 47 | throw new Error(`Failed to fetch image: ${imageResponse.status}`) 48 | } 49 | 50 | const imageBuffer = await imageResponse.arrayBuffer() 51 | const imageBase64 = Buffer.from(imageBuffer).toString('base64') 52 | 53 | // Determine MIME type 54 | const mimeType = imageUrl.includes('.png') ? 'image/png' : 'image/jpeg' 55 | 56 | // Create the prompt from brief 57 | const prompt = [ 58 | brief.instructions, 59 | brief.style ? `Style: ${brief.style}` : '', 60 | Array.isArray(brief.objects_to_remove) && brief.objects_to_remove.length 61 | ? `Remove: ${brief.objects_to_remove.join(', ')}` 62 | : '', 63 | Array.isArray(brief.objects_to_add) && brief.objects_to_add.length 64 | ? `Add: ${brief.objects_to_add.join(', ')}` 65 | : '', 66 | brief.additional_instructions || '' 67 | ].filter(Boolean).join('\n\n') 68 | 69 | console.log('Sending request to Gemini 2.5 Flash Image via OpenRouter...') 70 | 71 | const completion = await openrouter.chat.completions.create({ 72 | model: 'google/gemini-2.5-flash-image-preview:free', 73 | messages: [ 74 | { 75 | role: 'user', 76 | content: [ 77 | { type: 'text', text: prompt }, 78 | { 79 | type: 'image_url', 80 | image_url: { 81 | url: `data:${mimeType};base64,${imageBase64}` 82 | } 83 | } 84 | ] 85 | } 86 | ], 87 | }) 88 | 89 | const content = completion.choices[0]?.message?.content ?? '' 90 | 91 | console.log('Gemini 2.5 Flash Image processing completed') 92 | 93 | return NextResponse.json({ 94 | success: true, 95 | content, 96 | processingTime: Date.now() - startTime 97 | }) 98 | 99 | } catch (error) { 100 | console.error('Error processing image edit:', error) 101 | return NextResponse.json( 102 | { 103 | success: false, 104 | error: error instanceof Error ? error.message : 'Failed to process image edit', 105 | processingTime: Date.now() - startTime 106 | }, 107 | { status: 500 } 108 | ) 109 | } 110 | } 111 | 112 | // Optional: GET endpoint for testing 113 | export async function GET() { 114 | const testResult: EditResult = { 115 | success: true, 116 | content: 'Test edited image content', 117 | processingTime: 2500 118 | } 119 | 120 | return NextResponse.json(testResult) 121 | } 122 | -------------------------------------------------------------------------------- /public/README.md: -------------------------------------------------------------------------------- 1 | # Public Assets Folder 2 | 3 | This folder contains essential static assets for the Fixtral application. 4 | 5 | ## 📁 File Structure 6 | 7 | ``` 8 | public/ 9 | ├── bg.mp4 # Background video for hero section 10 | ├── og-image.jpg # Open Graph preview image (1200x630) 11 | ├── favicon.ico # Main favicon for browser tabs 12 | ├── site.webmanifest # PWA manifest 13 | ├── browserconfig.xml # Windows tile configuration 14 | └── README.md # This file 15 | ``` 16 | 17 | ## 🎨 Favicon Setup 18 | 19 | ### Required Favicon File: 20 | 1. **`favicon.ico`** - Main favicon (works for all browsers) 21 | 22 | ### How to Create Favicon: 23 | 1. Start with a high-quality square image (at least 32x32px, ideally 256x256px) 24 | 2. Use online favicon generators like: 25 | - [RealFaviconGenerator](https://realfavicongenerator.net/) 26 | - [Favicon.io](https://favicon.io/) 27 | 3. Generate favicon.ico format 28 | 4. Replace the placeholder favicon.ico in this folder 29 | 30 | ## 🖼️ Open Graph Image (Preview Image) 31 | 32 | ### File: `og-image.jpg` 33 | - **Dimensions**: 1200x630px (1.91:1 aspect ratio) 34 | - **Format**: JPG or PNG 35 | - **File Size**: Under 1MB recommended 36 | - **Content**: Should showcase Fixtral branding and features 37 | 38 | ### When it's used: 39 | - Facebook shares 40 | - LinkedIn posts 41 | - Twitter cards 42 | - Discord embeds 43 | - Other social media platforms 44 | 45 | ### Design Tips: 46 | - Use high-contrast text for readability 47 | - Include Fixtral logo prominently 48 | - Show key features or benefits 49 | - Use brand colors (black background, white text) 50 | - Keep it clean and professional 51 | 52 | ## 🎬 Background Video Setup 53 | 54 | ### File: `bg.mp4` 55 | - **Format**: MP4 (H.264 codec recommended) 56 | - **Resolution**: 1920x1080 (Full HD) or higher 57 | - **Duration**: 10-30 seconds (videos loop automatically) 58 | - **File Size**: Keep under 10MB for optimal loading 59 | - **Content**: Should complement Fixtral branding 60 | 61 | ### Video Requirements: 62 | - **Codec**: H.264 for maximum compatibility 63 | - **Frame Rate**: 24-30fps 64 | - **Audio**: Optional (will be muted anyway) 65 | - **Aspect Ratio**: 16:9 recommended 66 | 67 | ### Optimization Tips: 68 | 1. **Compress** using HandBrake or Adobe Media Encoder 69 | 2. **Test on mobile** devices for smooth playback 70 | 3. **Use short loops** if original is too long 71 | 4. **Consider creating multiple versions** for different devices 72 | 73 | ## 📱 PWA & Mobile Setup 74 | 75 | ### `site.webmanifest` 76 | - Configures Progressive Web App behavior 77 | - Defines app icons for mobile devices 78 | - Sets theme colors and display mode 79 | 80 | ### `browserconfig.xml` 81 | - Windows tile configuration 82 | - Sets tile colors and icons for Windows devices 83 | 84 | ## 🔧 Next Steps 85 | 86 | 1. **Replace placeholder files** with your actual favicon and images 87 | 2. **Update domain** in `layout.tsx` metadata (currently set to `https://fixtral.com`) 88 | 3. **Add verification codes** for Google Search Console, Bing Webmaster Tools, etc. 89 | 4. **Test social sharing** to ensure Open Graph image displays correctly 90 | 5. **Verify favicon** appears in browser tabs and bookmarks 91 | 92 | ## 📊 SEO & Social Media Impact 93 | 94 | With proper setup, your site will: 95 | - ✅ Display correct favicon in browser tabs 96 | - ✅ Show attractive preview image when shared on social media 97 | - ✅ Have proper mobile app-like experience 98 | - ✅ Pass all SEO best practices 99 | - ✅ Look professional across all platforms 100 | 101 | ## 🚀 Performance Tips 102 | 103 | - **Compress images** before uploading 104 | - **Use WebP format** for og-image if supported 105 | - **Optimize video** for web delivery 106 | - **Test loading times** on mobile connections 107 | - **Verify file sizes** are reasonable 108 | 109 | --- 110 | 111 | **Remember**: These files are automatically served from your domain root, so `/favicon.ico` becomes `https://yourdomain.com/favicon.ico` 112 | -------------------------------------------------------------------------------- /src/components/user-menu.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useState, useEffect } from 'react' 4 | import { Button } from '@/components/ui/button' 5 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' 6 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' 7 | import { useAuth } from '@/lib/auth-context' 8 | import { User, LogOut, History, Zap } from 'lucide-react' 9 | 10 | interface UserMenuProps { 11 | onShowHistory?: () => void 12 | } 13 | 14 | export const UserMenu: React.FC = ({ onShowHistory }) => { 15 | const { user, signOut } = useAuth() 16 | const [isSigningOut, setIsSigningOut] = useState(false) 17 | const [userCredits, setUserCredits] = useState<{ dailyGenerations: number; remainingCredits: number; isAdmin: boolean } | null>(null) 18 | 19 | const handleSignOut = async () => { 20 | setIsSigningOut(true) 21 | await signOut() 22 | setIsSigningOut(false) 23 | } 24 | 25 | const getInitials = (email: string) => { 26 | return email.substring(0, 2).toUpperCase() 27 | } 28 | 29 | // Load user credits on component mount 30 | useEffect(() => { 31 | const loadUserCredits = async () => { 32 | const userId = localStorage.getItem('user_id') 33 | if (userId) { 34 | try { 35 | const { DatabaseService } = await import('@/lib/database') 36 | const { canGenerate, remainingCredits, credits, isAdmin } = await DatabaseService.checkCreditLimit(userId) 37 | setUserCredits({ 38 | dailyGenerations: credits.dailyGenerations, 39 | remainingCredits, 40 | isAdmin 41 | }) 42 | } catch (error) { 43 | console.error('Error loading user credits in menu:', error) 44 | } 45 | } 46 | } 47 | 48 | if (user) { 49 | loadUserCredits() 50 | } 51 | }, [user]) 52 | 53 | if (!user) return null 54 | 55 | return ( 56 | 57 | 58 | 66 | 67 | 68 | 69 |
70 |

71 | {user.user_metadata?.name || 'User'} 72 |

73 |

74 | {user.email} 75 |

76 | {userCredits && ( 77 |
78 | 79 | 80 | {userCredits.isAdmin ? ( 81 | Admin: Unlimited 82 | ) : ( 83 | 84 | {userCredits.remainingCredits} generations left 85 | 86 | )} 87 | 88 |
89 | )} 90 |
91 |
92 | 93 | 94 | 95 | Edit History 96 | 97 | 98 | 99 | 100 | {isSigningOut ? 'Signing out...' : 'Sign out'} 101 | 102 |
103 |
104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { X } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogClose, 114 | DialogTrigger, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/supabase.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js' 2 | 3 | // Supabase configuration with validation 4 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL 5 | const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY 6 | 7 | if (!supabaseUrl || supabaseUrl === 'https://your-project-id.supabase.co') { 8 | console.warn('⚠️ Supabase URL not configured. Authentication features will be disabled.') 9 | } 10 | 11 | if (!supabaseAnonKey || supabaseAnonKey === 'your-anon-key-here') { 12 | console.warn('⚠️ Supabase Anon Key not configured. Authentication features will be disabled.') 13 | } 14 | 15 | // Create Supabase client (only if properly configured) 16 | export const supabase = supabaseUrl && supabaseAnonKey && 17 | supabaseUrl !== 'https://your-project-id.supabase.co' && 18 | supabaseAnonKey !== 'your-anon-key-here' 19 | ? createClient(supabaseUrl, supabaseAnonKey) 20 | : null as any 21 | 22 | // Database types 23 | export interface Database { 24 | public: { 25 | Tables: { 26 | users: { 27 | Row: { 28 | id: string 29 | email: string 30 | name: string 31 | avatar_url: string | null 32 | created_at: string 33 | updated_at: string 34 | } 35 | Insert: { 36 | id?: string 37 | email: string 38 | name: string 39 | avatar_url?: string | null 40 | created_at?: string 41 | updated_at?: string 42 | } 43 | Update: { 44 | id?: string 45 | email?: string 46 | name?: string 47 | avatar_url?: string | null 48 | updated_at?: string 49 | } 50 | } 51 | reddit_posts: { 52 | Row: { 53 | id: string 54 | post_id: string 55 | title: string 56 | description: string | null 57 | image_url: string 58 | author: string 59 | subreddit: string 60 | score: number 61 | num_comments: number 62 | created_utc: number 63 | permalink: string | null 64 | created_at: string 65 | } 66 | Insert: { 67 | id?: string 68 | post_id: string 69 | title: string 70 | description?: string | null 71 | image_url: string 72 | author: string 73 | subreddit: string 74 | score: number 75 | num_comments: number 76 | created_utc: number 77 | permalink?: string | null 78 | created_at?: string 79 | } 80 | Update: { 81 | id?: string 82 | post_id?: string 83 | title?: string 84 | description?: string | null 85 | image_url?: string 86 | author?: string 87 | subreddit?: string 88 | score?: number 89 | num_comments?: number 90 | created_utc?: number 91 | permalink?: string | null 92 | created_at?: string 93 | } 94 | } 95 | edit_history: { 96 | Row: { 97 | id: string 98 | user_id: string 99 | post_id: string 100 | post_title: string 101 | request_text: string 102 | analysis: string 103 | edit_prompt: string 104 | original_image_url: string 105 | edited_image_url: string | null 106 | method: string 107 | status: 'completed' | 'failed' 108 | processing_time: number | null 109 | created_at: string 110 | updated_at: string 111 | } 112 | Insert: { 113 | id?: string 114 | user_id: string 115 | post_id: string 116 | post_title: string 117 | request_text: string 118 | analysis: string 119 | edit_prompt: string 120 | original_image_url: string 121 | edited_image_url?: string | null 122 | method: string 123 | status?: 'completed' | 'failed' 124 | processing_time?: number | null 125 | created_at?: string 126 | updated_at?: string 127 | } 128 | Update: { 129 | id?: string 130 | user_id?: string 131 | post_id?: string 132 | post_title?: string 133 | request_text?: string 134 | analysis?: string 135 | edit_prompt?: string 136 | original_image_url?: string 137 | edited_image_url?: string | null 138 | method?: string 139 | status?: 'completed' | 'failed' 140 | processing_time?: number | null 141 | updated_at?: string 142 | } 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/lib/auth-context.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { createContext, useContext, useEffect, useState } from 'react' 4 | import { User, Session, AuthChangeEvent } from '@supabase/supabase-js' 5 | import { supabase } from './supabase' 6 | 7 | // Check if Supabase is properly configured 8 | const isSupabaseConfigured = supabase && typeof supabase.auth?.getSession === 'function' 9 | 10 | interface AuthContextType { 11 | user: User | null 12 | session: Session | null 13 | loading: boolean 14 | signIn: (email: string, password: string) => Promise<{ error: any }> 15 | signUp: (email: string, password: string) => Promise<{ error: any }> 16 | signOut: () => Promise 17 | signInWithGoogle: () => Promise<{ error: any }> 18 | } 19 | 20 | const AuthContext = createContext(undefined) 21 | 22 | export const useAuth = () => { 23 | const context = useContext(AuthContext) 24 | if (!context) { 25 | throw new Error('useAuth must be used within an AuthProvider') 26 | } 27 | return context 28 | } 29 | 30 | interface AuthProviderProps { 31 | children: React.ReactNode 32 | } 33 | 34 | export const AuthProvider: React.FC = ({ children }) => { 35 | const [user, setUser] = useState(null) 36 | const [session, setSession] = useState(null) 37 | const [loading, setLoading] = useState(true) 38 | 39 | useEffect(() => { 40 | // Only run auth logic if Supabase is configured 41 | if (!isSupabaseConfigured) { 42 | console.log('🔄 Supabase not configured, running in localStorage-only mode') 43 | setLoading(false) 44 | return 45 | } 46 | 47 | // Get initial session 48 | const getInitialSession = async () => { 49 | try { 50 | const { data: { session } } = await supabase.auth.getSession() 51 | setSession(session) 52 | setUser(session?.user ?? null) 53 | 54 | // Store user ID and email in localStorage for database service 55 | if (session?.user?.id) { 56 | localStorage.setItem('user_id', session.user.id) 57 | localStorage.setItem('user_email', session.user.email || '') 58 | } else { 59 | localStorage.removeItem('user_id') 60 | localStorage.removeItem('user_email') 61 | } 62 | } catch (error) { 63 | console.warn('Failed to get initial session:', error) 64 | } 65 | 66 | setLoading(false) 67 | } 68 | 69 | getInitialSession() 70 | 71 | // Listen for auth changes 72 | const { data: { subscription } } = supabase.auth.onAuthStateChange( 73 | async (event: AuthChangeEvent, session: Session | null) => { 74 | setSession(session) 75 | setUser(session?.user ?? null) 76 | 77 | // Update user ID and email in localStorage 78 | if (session?.user?.id) { 79 | localStorage.setItem('user_id', session.user.id) 80 | localStorage.setItem('user_email', session.user.email || '') 81 | } else { 82 | localStorage.removeItem('user_id') 83 | localStorage.removeItem('user_email') 84 | } 85 | 86 | setLoading(false) 87 | } 88 | ) 89 | 90 | return () => subscription.unsubscribe() 91 | }, []) 92 | 93 | const signIn = async (email: string, password: string) => { 94 | if (!isSupabaseConfigured) { 95 | return { error: new Error('Authentication not configured') } 96 | } 97 | const { error } = await supabase.auth.signInWithPassword({ 98 | email, 99 | password 100 | }) 101 | return { error } 102 | } 103 | 104 | const signUp = async (email: string, password: string) => { 105 | if (!isSupabaseConfigured) { 106 | return { error: new Error('Authentication not configured') } 107 | } 108 | const { error } = await supabase.auth.signUp({ 109 | email, 110 | password 111 | }) 112 | return { error } 113 | } 114 | 115 | const signOut = async () => { 116 | if (!isSupabaseConfigured) { 117 | localStorage.removeItem('user_id') 118 | setUser(null) 119 | setSession(null) 120 | return 121 | } 122 | await supabase.auth.signOut() 123 | } 124 | 125 | const signInWithGoogle = async () => { 126 | if (!isSupabaseConfigured) { 127 | return { error: new Error('Authentication not configured') } 128 | } 129 | const { error } = await supabase.auth.signInWithOAuth({ 130 | provider: 'google', 131 | options: { 132 | redirectTo: `${window.location.origin}/app` 133 | } 134 | }) 135 | return { error } 136 | } 137 | 138 | const value = { 139 | user, 140 | session, 141 | loading, 142 | signIn, 143 | signUp, 144 | signOut, 145 | signInWithGoogle 146 | } 147 | 148 | return ( 149 | 150 | {children} 151 | 152 | ) 153 | } 154 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 240 5.9% 10%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 240 10% 3.9%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 240 10% 3.9%; 31 | --foreground: 0 0% 98%; 32 | --card: 240 10% 3.9%; 33 | --card-foreground: 0 0% 98%; 34 | --popover: 240 10% 3.9%; 35 | --popover-foreground: 0 0% 98%; 36 | --primary: 0 0% 98%; 37 | --primary-foreground: 240 5.9% 10%; 38 | --secondary: 240 3.7% 15.9%; 39 | --secondary-foreground: 0 0% 98%; 40 | --muted: 240 3.7% 15.9%; 41 | --muted-foreground: 240 5% 64.9%; 42 | --accent: 240 3.7% 15.9%; 43 | --accent-foreground: 0 0% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 0 0% 98%; 46 | --border: 240 3.7% 15.9%; 47 | --input: 240 3.7% 15.9%; 48 | --ring: 240 4.9% 83.9%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | } 59 | } 60 | 61 | @layer utilities { 62 | .line-clamp-1 { 63 | overflow: hidden; 64 | display: -webkit-box; 65 | -webkit-box-orient: vertical; 66 | -webkit-line-clamp: 1; 67 | } 68 | 69 | .line-clamp-2 { 70 | overflow: hidden; 71 | display: -webkit-box; 72 | -webkit-box-orient: vertical; 73 | -webkit-line-clamp: 2; 74 | } 75 | 76 | .line-clamp-3 { 77 | overflow: hidden; 78 | display: -webkit-box; 79 | -webkit-box-orient: vertical; 80 | -webkit-line-clamp: 3; 81 | } 82 | 83 | /* Mobile optimizations */ 84 | .mobile-optimized { 85 | -webkit-tap-highlight-color: transparent; 86 | -webkit-touch-callout: none; 87 | -webkit-user-select: none; 88 | -khtml-user-select: none; 89 | -moz-user-select: none; 90 | -ms-user-select: none; 91 | user-select: none; 92 | touch-action: manipulation; 93 | } 94 | 95 | .mobile-text { 96 | font-size: clamp(0.875rem, 2.5vw, 1rem); 97 | } 98 | 99 | .mobile-heading { 100 | font-size: clamp(1.5rem, 5vw, 2.5rem); 101 | } 102 | 103 | .mobile-large-heading { 104 | font-size: clamp(2rem, 8vw, 4rem); 105 | } 106 | 107 | .mobile-button { 108 | min-height: 44px; 109 | min-width: 44px; 110 | padding: 0.75rem 1.5rem; 111 | font-size: 1rem; 112 | } 113 | 114 | .mobile-card { 115 | padding: 1rem; 116 | margin-bottom: 1rem; 117 | } 118 | 119 | .mobile-container { 120 | padding-left: 1rem; 121 | padding-right: 1rem; 122 | } 123 | 124 | .mobile-sticky { 125 | position: sticky; 126 | top: 0; 127 | z-index: 50; 128 | backdrop-filter: blur(10px); 129 | -webkit-backdrop-filter: blur(10px); 130 | } 131 | 132 | .mobile-tabs { 133 | overflow-x: auto; 134 | -webkit-overflow-scrolling: touch; 135 | scrollbar-width: none; 136 | -ms-overflow-style: none; 137 | } 138 | 139 | .mobile-tabs::-webkit-scrollbar { 140 | display: none; 141 | } 142 | 143 | .mobile-grid { 144 | grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); 145 | gap: 1rem; 146 | } 147 | 148 | .mobile-flex { 149 | flex-direction: column; 150 | gap: 1rem; 151 | } 152 | 153 | /* Safe area support for modern mobile devices */ 154 | .mobile-safe-top { 155 | padding-top: env(safe-area-inset-top); 156 | } 157 | 158 | .mobile-safe-bottom { 159 | padding-bottom: env(safe-area-inset-bottom); 160 | } 161 | 162 | .mobile-safe-left { 163 | padding-left: env(safe-area-inset-left); 164 | } 165 | 166 | .mobile-safe-right { 167 | padding-right: env(safe-area-inset-right); 168 | } 169 | 170 | /* Touch-friendly interactions */ 171 | .touch-target { 172 | min-height: 44px; 173 | min-width: 44px; 174 | } 175 | 176 | /* Improved mobile scrolling */ 177 | .mobile-scroll { 178 | -webkit-overflow-scrolling: touch; 179 | scroll-behavior: smooth; 180 | } 181 | 182 | /* Mobile-first responsive text */ 183 | @media (max-width: 640px) { 184 | .mobile-responsive-text { 185 | font-size: 0.875rem; 186 | line-height: 1.25rem; 187 | } 188 | 189 | .mobile-responsive-heading { 190 | font-size: 1.5rem; 191 | line-height: 2rem; 192 | } 193 | 194 | .mobile-responsive-large { 195 | font-size: 2rem; 196 | line-height: 2.5rem; 197 | } 198 | } 199 | 200 | @media (max-width: 768px) { 201 | .mobile-responsive-text { 202 | font-size: 0.875rem; 203 | line-height: 1.25rem; 204 | } 205 | 206 | .mobile-responsive-heading { 207 | font-size: 1.875rem; 208 | line-height: 2.25rem; 209 | } 210 | 211 | .mobile-responsive-large { 212 | font-size: 2.25rem; 213 | line-height: 2.5rem; 214 | } 215 | } 216 | } 217 | 218 | @layer components { 219 | .animate-pulse-subtle { 220 | animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 221 | } 222 | 223 | .gradient-bg { 224 | background: linear-gradient(135deg, hsl(var(--background)) 0%, hsl(var(--muted)) 100%); 225 | } 226 | 227 | .card-hover { 228 | @apply transition-all duration-200 hover:shadow-lg hover:-translate-y-1; 229 | } 230 | 231 | .theme-transition { 232 | @apply transition-colors duration-300 ease-in-out; 233 | } 234 | 235 | .glass-effect { 236 | @apply bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60; 237 | } 238 | 239 | .gradient-text { 240 | @apply bg-gradient-to-r from-primary via-primary to-primary/70 bg-clip-text text-transparent; 241 | } 242 | 243 | .modern-shadow { 244 | box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 245 | } 246 | 247 | .hover-lift { 248 | @apply transition-all duration-300 hover:shadow-xl hover:-translate-y-1; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 3 | import { Check, ChevronRight, Circle } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef & { 22 | inset?: boolean 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | 34 | {children} 35 | 36 | 37 | )) 38 | DropdownMenuSubTrigger.displayName = 39 | DropdownMenuPrimitive.SubTrigger.displayName 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )) 54 | DropdownMenuSubContent.displayName = 55 | DropdownMenuPrimitive.SubContent.displayName 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | 62 | 71 | 72 | )) 73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 74 | 75 | const DropdownMenuItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef & { 78 | inset?: boolean 79 | } 80 | >(({ className, inset, ...props }, ref) => ( 81 | 90 | )) 91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 92 | 93 | const DropdownMenuCheckboxItem = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, children, checked, ...props }, ref) => ( 97 | 106 | 107 | 108 | 109 | 110 | 111 | {children} 112 | 113 | )) 114 | DropdownMenuCheckboxItem.displayName = 115 | DropdownMenuPrimitive.CheckboxItem.displayName 116 | 117 | const DropdownMenuRadioItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )) 137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 138 | 139 | const DropdownMenuLabel = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef & { 142 | inset?: boolean 143 | } 144 | >(({ className, inset, ...props }, ref) => ( 145 | 154 | )) 155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 156 | 157 | const DropdownMenuSeparator = React.forwardRef< 158 | React.ElementRef, 159 | React.ComponentPropsWithoutRef 160 | >(({ className, ...props }, ref) => ( 161 | 166 | )) 167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 168 | 169 | const DropdownMenuShortcut = ({ 170 | className, 171 | ...props 172 | }: React.HTMLAttributes) => { 173 | return ( 174 | 178 | ) 179 | } 180 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 181 | 182 | export { 183 | DropdownMenu, 184 | DropdownMenuTrigger, 185 | DropdownMenuContent, 186 | DropdownMenuItem, 187 | DropdownMenuCheckboxItem, 188 | DropdownMenuRadioItem, 189 | DropdownMenuLabel, 190 | DropdownMenuSeparator, 191 | DropdownMenuShortcut, 192 | DropdownMenuGroup, 193 | DropdownMenuPortal, 194 | DropdownMenuSub, 195 | DropdownMenuSubContent, 196 | DropdownMenuSubTrigger, 197 | DropdownMenuRadioGroup, 198 | } 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fixtral - AI Photoshop Assistant 2 | 3 | *Version 0.2.0 - August 2025* 4 | 5 | Fixtral is an AI-powered Photoshop assistant that automates image edits requested on Reddit's r/PhotoshopRequest using Google's Gemini AI models. 6 | 7 | https://github.com/user-attachments/assets/32db6bad-13d9-461d-9158-35c78d1ea89c 8 | 9 | ## 🚀 Features 10 | 11 | - **Automated Reddit Integration**: Fetches new posts from r/PhotoshopRequest with image attachments 12 | - **AI-Powered Request Parsing**: Uses Gemini 2.5 Flash to parse natural language requests into structured edit forms 13 | - **Intelligent Image Editing**: Leverages Fixtral (Gemini 2.5 Flash Image Preview) for automated image processing 14 | - **Modern Dashboard UI**: Clean, responsive interface built with Next.js and shadcn/ui 15 | - **Before/After Comparison**: Side-by-side or slider view for comparing edits 16 | - **Download & Export**: Easy download of processed images 17 | - **Processing History**: Track all completed and failed requests 18 | 19 | ## 🏗️ Architecture 20 | 21 | ### Tech Stack 22 | 23 | - **Frontend**: Next.js 14 (App Router) + TypeScript + TailwindCSS 24 | - **UI Components**: shadcn/ui + Radix UI 25 | - **Backend**: Next.js API Routes (Edge Runtime) 26 | - **Reddit API**: snoowrap (JavaScript Reddit API client) 27 | - **AI Models**: 28 | - **Gemini 2.5 Flash** → Text parsing and request understanding 29 | - **Fixtral (Gemini 2.5 Flash Image Preview)** → Image editing and generation 30 | - **Icons**: Lucide React 31 | 32 | ### Core Workflow 33 | 34 | 1. **Fetch** → Reddit posts with images from r/PhotoshopRequest 35 | 2. **Parse** → Extract request text and convert to structured edit form using Gemini 36 | 3. **Edit** → Process images using Fixtral with the generated edit form 37 | 4. **Display** → Show before/after results in the dashboard 38 | 39 | ## 📋 Prerequisites 40 | 41 | - Node.js 18+ 42 | - npm or yarn 43 | - Google AI API key (for Gemini models) 44 | - Reddit API credentials (optional - uses mock data if not configured) 45 | 46 | ## 🛠️ Installation 47 | 48 | 1. **Clone and install dependencies:** 49 | ```bash 50 | git clone 51 | cd fixtral 52 | npm install 53 | ``` 54 | 55 | 2. **Configure environment variables:** 56 | 57 | Create a `.env.local` file in the root directory: 58 | 59 | ```env 60 | # Google AI (Gemini) Configuration 61 | GOOGLE_AI_API_KEY=your_google_ai_api_key_here 62 | 63 | # Reddit API Configuration (optional - uses mock data if not set) 64 | REDDIT_CLIENT_ID=your_reddit_client_id 65 | REDDIT_CLIENT_SECRET=your_reddit_client_secret 66 | REDDIT_USERNAME=your_reddit_username 67 | REDDIT_PASSWORD=your_reddit_password 68 | 69 | # Optional: Storage Configuration (for later) 70 | AWS_ACCESS_KEY_ID=your_aws_access_key 71 | AWS_SECRET_ACCESS_KEY=your_aws_secret_key 72 | AWS_S3_BUCKET=your_s3_bucket_name 73 | ``` 74 | 75 | 3. **Start the development server:** 76 | ```bash 77 | npm run dev 78 | ``` 79 | 80 | 4. **Open your browser:** 81 | Navigate to [http://localhost:3000](http://localhost:3000) 82 | 83 | ## 🔧 Configuration 84 | 85 | ### Google AI Setup 86 | 87 | 1. Visit [Google AI Studio](https://makersuite.google.com/app/apikey) 88 | 2. Create a new API key 89 | 3. Add it to your `.env.local` file 90 | 91 | ### Reddit API Setup (Optional) 92 | 93 | 1. Go to [Reddit Apps](https://www.reddit.com/prefs/apps) 94 | 2. Create a new application (type: script) 95 | 3. Note your Client ID and Client Secret 96 | 4. Add Reddit credentials to `.env.local` 97 | 98 | *Note: If Reddit credentials are not configured, the app will use mock data for demonstration.* 99 | 100 | ## 🎯 Usage 101 | 102 | ### Dashboard Overview 103 | 104 | The app has three main sections: 105 | 106 | 1. **Queue** - View and process new Reddit requests 107 | 2. **Editor** - Review before/after comparisons 108 | 3. **History** - Browse processed requests 109 | 110 | ### Processing a Request 111 | 112 | 1. **Fetch Posts**: Click "Refresh Posts" in the Queue tab 113 | 2. **Select Request**: Browse available posts with images 114 | 3. **Parse & Edit**: Click the "Parse & Edit" button on any post 115 | 4. **Review Results**: Switch to Editor tab to see the processed image 116 | 5. **Download**: Save the edited image to your device 117 | 118 | ### Edit Form Structure 119 | 120 | The AI parses requests into this structured format: 121 | 122 | ```json 123 | { 124 | "task_type": "object_removal", 125 | "instructions": "Remove the man in the background", 126 | "objects_to_remove": ["man in background"], 127 | "objects_to_add": [], 128 | "style": "realistic", 129 | "mask_needed": true 130 | } 131 | ``` 132 | 133 | ## 🏗️ Project Structure 134 | 135 | ``` 136 | fixtral/ 137 | ├── src/ 138 | │ ├── app/ # Next.js app router 139 | │ │ ├── api/ # API routes 140 | │ │ │ ├── reddit/ # Reddit integration 141 | │ │ │ └── gemini/ # AI processing 142 | │ │ ├── globals.css # Global styles 143 | │ │ ├── layout.tsx # Root layout 144 | │ │ └── page.tsx # Landing page 145 | │ │ └── app/ # Dashboard pages 146 | │ ├── components/ # React components 147 | │ │ ├── ui/ # shadcn/ui components 148 | │ │ ├── queue-view.tsx # Reddit posts queue 149 | │ │ ├── editor-view.tsx # Image editor/comparison 150 | │ │ └── history-view.tsx # Processing history 151 | │ ├── lib/ # Utilities and services 152 | │ │ ├── database.ts # Supabase integration 153 | │ │ ├── auth-context.tsx # Authentication 154 | │ │ └── utils.ts # Helper functions 155 | │ └── types/ # TypeScript definitions 156 | │ └── index.ts # Type definitions 157 | ├── vercel.json # Vercel deployment config 158 | ├── .vercelignore # Files to exclude from Vercel build 159 | ├── supabase-schema.sql # Database schema (reference) 160 | └── README.md # Project documentation 161 | ``` 162 | 163 | ## 🔌 API Endpoints 164 | 165 | ### Reddit Integration 166 | - `GET /api/reddit/posts` - Fetch posts from r/PhotoshopRequest 167 | 168 | ### AI Processing 169 | - `POST /api/gemini/parse` - Parse request text into edit form 170 | - `POST /api/gemini/edit` - Process image with edit form 171 | 172 | ## 🎨 UI Components 173 | 174 | Built with shadcn/ui components: 175 | - `Card` - Post/request containers 176 | - `Button` - Actions and interactions 177 | - `Tabs` - Navigation between views 178 | - `Table` - History and data display 179 | - `Slider` - Before/after image comparison 180 | - `Badge` - Status indicators 181 | 182 | ## 🚀 Deployment 183 | 184 | ### Vercel (Recommended) 185 | 186 | 1. Connect your GitHub repository to Vercel 187 | 2. Add environment variables in Vercel dashboard 188 | 3. Deploy automatically on push 189 | 190 | ### Other Platforms 191 | 192 | The app can be deployed to any platform supporting Next.js: 193 | - Netlify 194 | - Railway 195 | - DigitalOcean App Platform 196 | 197 | ## 🔮 Future Enhancements 198 | 199 | - **Persistent Storage**: S3/Supabase for image storage 200 | - **Database Integration**: Store processing history and user preferences 201 | - **Batch Processing**: Process multiple requests simultaneously 202 | - **Advanced Editing**: More sophisticated edit types and styles 203 | - **User Authentication**: Multi-user support with access controls 204 | - **Webhooks**: Real-time Reddit post notifications 205 | - **Analytics**: Processing metrics and performance insights 206 | 207 | ## 📝 Development 208 | 209 | ### Available Scripts 210 | 211 | ```bash 212 | npm run dev # Start development server 213 | npm run build # Build for production 214 | npm run start # Start production server 215 | npm run lint # Run ESLint 216 | ``` 217 | 218 | ### Code Quality 219 | 220 | - **TypeScript**: Full type safety 221 | - **ESLint**: Code linting and formatting 222 | - **Prettier**: Code formatting (via ESLint) 223 | 224 | ## 🤝 Contributing 225 | 226 | 1. Fork the repository 227 | 2. Create a feature branch 228 | 3. Make your changes 229 | 4. Add tests if applicable 230 | 5. Submit a pull request 231 | 232 | ## 📄 License 233 | 234 | MIT License - see LICENSE file for details. 235 | 236 | ## 🆘 Support 237 | 238 | For issues and questions: 239 | 1. Check the [Issues](https://github.com/your-repo/issues) page 240 | 2. Create a new issue with detailed information 241 | 3. Include error messages and steps to reproduce 242 | 243 | ## 🙏 Acknowledgments 244 | 245 | - [Google Gemini AI](https://ai.google.dev/) for powerful AI models 246 | - [Reddit](https://www.reddit.com/) for the PhotoshopRequest community 247 | - [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components 248 | - [Next.js](https://nextjs.org/) for the React framework 249 | 250 | --- 251 | 252 | *Built with ❤️ for the creative community* 253 | -------------------------------------------------------------------------------- /src/app/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useEffect } from 'react' 4 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 5 | import { Button } from '@/components/ui/button' 6 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' 7 | import { QueueView } from '@/components/queue-view' 8 | import { EditorView } from '@/components/editor-view' 9 | import { HistoryView } from '@/components/history-view' 10 | import { ImageViewerProvider } from '@/components/image-viewer' 11 | import { ThemeToggle } from '@/components/theme-toggle' 12 | import { UserMenu } from '@/components/user-menu' 13 | import { AuthModal } from '@/components/auth-modal' 14 | import { useAuth } from '@/lib/auth-context' 15 | import { Wand2, Image, History, Sparkles, ArrowLeft, User } from 'lucide-react' 16 | import Link from 'next/link' 17 | 18 | export default function Dashboard() { 19 | const [activeTab, setActiveTab] = useState('queue') 20 | const [pendingEditorItems, setPendingEditorItems] = useState(0) 21 | const [showAuthModal, setShowAuthModal] = useState(false) 22 | const { user, loading } = useAuth() 23 | 24 | // Listen for new editor items and switch to editor tab 25 | useEffect(() => { 26 | const handleStorageChange = (e: StorageEvent) => { 27 | console.log('Dashboard: Storage event received:', { 28 | key: e.key, 29 | newValue: e.newValue ? e.newValue.substring(0, 100) + '...' : null, 30 | oldValue: e.oldValue 31 | }) 32 | 33 | if (e.key === 'pendingEditorItem' && e.newValue) { 34 | console.log('Dashboard: Switching to editor tab due to pending item') 35 | setActiveTab('editor') 36 | updatePendingCount() 37 | } else if (e.key === 'editHistory') { 38 | updatePendingCount() 39 | } 40 | } 41 | 42 | const updatePendingCount = () => { 43 | const pendingItem = localStorage.getItem('pendingEditorItem') 44 | const count = pendingItem ? 1 : 0 45 | console.log('Dashboard: Updating pending count:', { pendingItem: !!pendingItem, count }) 46 | setPendingEditorItems(count) 47 | } 48 | 49 | // Initial count 50 | updatePendingCount() 51 | 52 | window.addEventListener('storage', handleStorageChange) 53 | 54 | // Also check for pending items on mount 55 | const pendingItem = localStorage.getItem('pendingEditorItem') 56 | console.log('Dashboard: Initial pending item check:', { pendingItem: !!pendingItem, data: pendingItem ? JSON.parse(pendingItem).post?.title : null }) 57 | if (pendingItem) { 58 | console.log('Dashboard: Setting initial tab to editor due to existing pending item') 59 | setActiveTab('editor') 60 | } 61 | 62 | return () => window.removeEventListener('storage', handleStorageChange) 63 | }, []) 64 | 65 | // Handle tab change with confirmation if there are pending items 66 | const handleTabChange = (tab: string) => { 67 | if (tab !== 'editor' && pendingEditorItems > 0) { 68 | const confirmChange = confirm( 69 | `You have ${pendingEditorItems} item(s) ready for editing in the Editor tab. Switch to Editor tab to continue?` 70 | ) 71 | if (confirmChange) { 72 | setActiveTab('editor') 73 | return 74 | } else { 75 | // Clear pending item if user chooses not to go to editor 76 | localStorage.removeItem('pendingEditorItem') 77 | setPendingEditorItems(0) 78 | } 79 | } 80 | setActiveTab(tab) 81 | } 82 | 83 | return ( 84 | 85 |
86 | {/* Header */} 87 |
88 |
89 |
90 |
91 | 92 | 96 | 97 |
98 | 99 | 100 |
101 |
102 |

103 | Fixtral 104 |

105 | AI Photoshop Assistant 106 |
107 |
108 |
109 |
110 | v0.2.0 111 | 112 | Reddit r/PhotoshopRequest 113 |
114 | {!loading && ( 115 | user ? ( 116 | 117 | ) : ( 118 | 126 | ) 127 | )} 128 | 129 |
130 |
131 |
132 |
133 | 134 | {/* Main Content */} 135 |
136 | 137 | 138 | 142 | 143 | Queue 144 | 145 | 149 | 150 | Editor 151 | {pendingEditorItems > 0 && ( 152 |
153 | {pendingEditorItems} 154 |
155 | )} 156 |
157 | 161 | 162 | History 163 | 164 |
165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 |
178 |
179 |
180 | 181 | {/* Auth Modal */} 182 | setShowAuthModal(false)} 185 | /> 186 |
187 | ) 188 | } 189 | -------------------------------------------------------------------------------- /supabase-schema.sql: -------------------------------------------------------------------------------- 1 | -- Supabase Database Schema for Fixtral 2 | -- Run this SQL in your Supabase SQL Editor 3 | 4 | -- Enable Row Level Security 5 | ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; 6 | 7 | -- Users table (extends Supabase auth.users) 8 | CREATE TABLE IF NOT EXISTS public.users ( 9 | id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY, 10 | email TEXT NOT NULL, 11 | name TEXT, 12 | avatar_url TEXT, 13 | is_admin BOOLEAN DEFAULT FALSE, 14 | created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 15 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() 16 | ); 17 | 18 | -- Reddit posts table 19 | CREATE TABLE IF NOT EXISTS public.reddit_posts ( 20 | id UUID DEFAULT gen_random_uuid() PRIMARY KEY, 21 | post_id TEXT NOT NULL UNIQUE, 22 | title TEXT NOT NULL, 23 | description TEXT, 24 | image_url TEXT NOT NULL, 25 | author TEXT NOT NULL, 26 | subreddit TEXT NOT NULL, 27 | score INTEGER DEFAULT 0, 28 | num_comments INTEGER DEFAULT 0, 29 | created_utc INTEGER NOT NULL, 30 | permalink TEXT, 31 | created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 32 | 33 | -- Indexes for performance 34 | CONSTRAINT reddit_posts_post_id_unique UNIQUE (post_id) 35 | ); 36 | 37 | -- Edit history table 38 | CREATE TABLE IF NOT EXISTS public.edit_history ( 39 | id UUID DEFAULT gen_random_uuid() PRIMARY KEY, 40 | user_id UUID REFERENCES public.users(id) ON DELETE CASCADE, 41 | post_id TEXT NOT NULL, 42 | post_title TEXT NOT NULL, 43 | request_text TEXT NOT NULL, 44 | analysis TEXT NOT NULL, 45 | edit_prompt TEXT NOT NULL, 46 | original_image_url TEXT NOT NULL, 47 | edited_image_url TEXT, 48 | post_url TEXT, 49 | method TEXT NOT NULL, 50 | status TEXT DEFAULT 'completed' CHECK (status IN ('completed', 'failed')), 51 | processing_time INTEGER, 52 | created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 53 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() 54 | ); 55 | 56 | -- Create indexes for better performance 57 | CREATE INDEX IF NOT EXISTS idx_reddit_posts_created_at ON public.reddit_posts(created_at DESC); 58 | CREATE INDEX IF NOT EXISTS idx_reddit_posts_subreddit ON public.reddit_posts(subreddit); 59 | CREATE INDEX IF NOT EXISTS idx_edit_history_user_id ON public.edit_history(user_id); 60 | CREATE INDEX IF NOT EXISTS idx_edit_history_created_at ON public.edit_history(created_at DESC); 61 | CREATE INDEX IF NOT EXISTS idx_edit_history_status ON public.edit_history(status); 62 | 63 | -- Row Level Security (RLS) Policies 64 | 65 | -- Users table policies 66 | ALTER TABLE public.users ENABLE ROW LEVEL SECURITY; 67 | 68 | CREATE POLICY "Users can view their own profile" 69 | ON public.users FOR SELECT 70 | USING (auth.uid() = id); 71 | 72 | CREATE POLICY "Users can update their own profile" 73 | ON public.users FOR UPDATE 74 | USING (auth.uid() = id); 75 | 76 | CREATE POLICY "Users can insert their own profile" 77 | ON public.users FOR INSERT 78 | WITH CHECK (auth.uid() = id); 79 | 80 | -- Reddit posts policies (public read, authenticated insert) 81 | ALTER TABLE public.reddit_posts ENABLE ROW LEVEL SECURITY; 82 | 83 | CREATE POLICY "Anyone can view reddit posts" 84 | ON public.reddit_posts FOR SELECT 85 | USING (true); 86 | 87 | CREATE POLICY "Authenticated users can insert reddit posts" 88 | ON public.reddit_posts FOR INSERT 89 | WITH CHECK (auth.role() = 'authenticated'); 90 | 91 | -- User credits table 92 | CREATE TABLE IF NOT EXISTS public.user_credits ( 93 | id UUID DEFAULT gen_random_uuid() PRIMARY KEY, 94 | user_id UUID REFERENCES public.users(id) ON DELETE CASCADE, 95 | daily_generations INTEGER DEFAULT 0, 96 | last_reset_date DATE DEFAULT CURRENT_DATE, 97 | total_generations INTEGER DEFAULT 0, 98 | created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 99 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 100 | 101 | -- Ensure one record per user 102 | CONSTRAINT user_credits_user_id_unique UNIQUE (user_id) 103 | ); 104 | 105 | -- Create indexes for better performance 106 | CREATE INDEX IF NOT EXISTS idx_user_credits_user_id ON public.user_credits(user_id); 107 | CREATE INDEX IF NOT EXISTS idx_user_credits_last_reset ON public.user_credits(last_reset_date); 108 | 109 | -- Function to reset daily credits 110 | CREATE OR REPLACE FUNCTION reset_daily_credits() 111 | RETURNS void AS $$ 112 | BEGIN 113 | UPDATE public.user_credits 114 | SET daily_generations = 0, last_reset_date = CURRENT_DATE, updated_at = NOW() 115 | WHERE last_reset_date < CURRENT_DATE; 116 | END; 117 | $$ LANGUAGE plpgsql; 118 | 119 | -- Function to get or create user credits 120 | CREATE OR REPLACE FUNCTION get_or_create_user_credits(user_uuid UUID) 121 | RETURNS TABLE ( 122 | daily_generations INTEGER, 123 | last_reset_date DATE, 124 | total_generations INTEGER 125 | ) AS $$ 126 | BEGIN 127 | -- First reset any outdated credits 128 | PERFORM reset_daily_credits(); 129 | 130 | -- Then get or create user credits 131 | RETURN QUERY 132 | INSERT INTO public.user_credits (user_id, daily_generations, last_reset_date, total_generations) 133 | VALUES (user_uuid, 0, CURRENT_DATE, 0) 134 | ON CONFLICT (user_id) DO UPDATE SET 135 | user_id = EXCLUDED.user_id 136 | RETURNING user_credits.daily_generations, user_credits.last_reset_date, user_credits.total_generations; 137 | END; 138 | $$ LANGUAGE plpgsql SECURITY DEFINER; 139 | 140 | -- User credits policies 141 | ALTER TABLE public.user_credits ENABLE ROW LEVEL SECURITY; 142 | 143 | CREATE POLICY "Users can view their own credits" 144 | ON public.user_credits FOR SELECT 145 | USING (auth.uid() = user_id); 146 | 147 | CREATE POLICY "Users can update their own credits" 148 | ON public.user_credits FOR ALL 149 | USING (auth.uid() = user_id); 150 | 151 | -- Edit history policies 152 | ALTER TABLE public.edit_history ENABLE ROW LEVEL SECURITY; 153 | 154 | CREATE POLICY "Users can view their own edit history" 155 | ON public.edit_history FOR SELECT 156 | USING (auth.uid() = user_id); 157 | 158 | CREATE POLICY "Users can insert their own edit history" 159 | ON public.edit_history FOR INSERT 160 | WITH CHECK (auth.uid() = user_id); 161 | 162 | CREATE POLICY "Users can update their own edit history" 163 | ON public.edit_history FOR UPDATE 164 | USING (auth.uid() = user_id); 165 | 166 | -- Create a function to handle user creation 167 | CREATE OR REPLACE FUNCTION public.handle_new_user() 168 | RETURNS TRIGGER AS $$ 169 | DECLARE 170 | user_name TEXT; 171 | is_admin_user BOOLEAN := FALSE; 172 | BEGIN 173 | -- Extract name from metadata 174 | user_name := new.raw_user_meta_data->>'name'; 175 | 176 | -- Check if this is an admin email or UID (set via environment variables in app) 177 | IF new.email = 'farizanjum2018@gmail.com' OR new.id::text = '337aaae0-a3ff-423e-8290-d24aab5de3ee' THEN 178 | is_admin_user := TRUE; 179 | END IF; 180 | 181 | INSERT INTO public.users (id, email, name, is_admin) 182 | VALUES (new.id, new.email, user_name, is_admin_user); 183 | 184 | RETURN new; 185 | END; 186 | $$ LANGUAGE plpgsql SECURITY DEFINER; 187 | 188 | -- Function to check if user is admin 189 | CREATE OR REPLACE FUNCTION public.is_user_admin(user_uuid UUID) 190 | RETURNS BOOLEAN AS $$ 191 | BEGIN 192 | RETURN EXISTS ( 193 | SELECT 1 FROM public.users 194 | WHERE id = user_uuid AND is_admin = TRUE 195 | ); 196 | END; 197 | $$ LANGUAGE plpgsql SECURITY DEFINER; 198 | 199 | -- Function to manually set user as admin by email (for existing users) 200 | CREATE OR REPLACE FUNCTION public.set_user_admin_by_email(user_email TEXT) 201 | RETURNS BOOLEAN AS $$ 202 | BEGIN 203 | UPDATE public.users 204 | SET is_admin = TRUE 205 | WHERE email = user_email; 206 | 207 | RETURN FOUND; 208 | END; 209 | $$ LANGUAGE plpgsql SECURITY DEFINER; 210 | 211 | -- Function to manually set user as admin by UID (for existing users) 212 | CREATE OR REPLACE FUNCTION public.set_user_admin_by_uid(user_uid UUID) 213 | RETURNS BOOLEAN AS $$ 214 | BEGIN 215 | UPDATE public.users 216 | SET is_admin = TRUE 217 | WHERE id = user_uid; 218 | 219 | RETURN FOUND; 220 | END; 221 | $$ LANGUAGE plpgsql SECURITY DEFINER; 222 | 223 | -- Function to check if user is admin by email or UID (flexible admin check) 224 | CREATE OR REPLACE FUNCTION public.is_user_admin_by_credentials(user_email TEXT DEFAULT NULL, user_uid UUID DEFAULT NULL) 225 | RETURNS BOOLEAN AS $$ 226 | BEGIN 227 | -- Check by UID if provided 228 | IF user_uid IS NOT NULL THEN 229 | RETURN EXISTS ( 230 | SELECT 1 FROM public.users 231 | WHERE id = user_uid AND is_admin = TRUE 232 | ); 233 | END IF; 234 | 235 | -- Check by email if provided 236 | IF user_email IS NOT NULL THEN 237 | RETURN EXISTS ( 238 | SELECT 1 FROM public.users 239 | WHERE email = user_email AND is_admin = TRUE 240 | ); 241 | END IF; 242 | 243 | RETURN FALSE; 244 | END; 245 | $$ LANGUAGE plpgsql SECURITY DEFINER; 246 | 247 | -- Create trigger to automatically create user profile 248 | CREATE OR REPLACE TRIGGER on_auth_user_created 249 | AFTER INSERT ON auth.users 250 | FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); 251 | 252 | -- Create updated_at trigger function 253 | CREATE OR REPLACE FUNCTION public.handle_updated_at() 254 | RETURNS TRIGGER AS $$ 255 | BEGIN 256 | NEW.updated_at = NOW(); 257 | RETURN NEW; 258 | END; 259 | $$ LANGUAGE plpgsql; 260 | 261 | -- Add updated_at triggers 262 | CREATE TRIGGER users_updated_at 263 | BEFORE UPDATE ON public.users 264 | FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at(); 265 | 266 | CREATE TRIGGER edit_history_updated_at 267 | BEFORE UPDATE ON public.edit_history 268 | FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at(); 269 | -------------------------------------------------------------------------------- /src/components/image-viewer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState, createContext, useContext, ReactNode } from 'react' 4 | import { X, Download, ExternalLink, ZoomIn, ZoomOut } from 'lucide-react' 5 | import Image from 'next/image' 6 | 7 | interface ImageViewerProps { 8 | src: string 9 | alt: string 10 | onClose: () => void 11 | downloadUrl?: string 12 | externalUrl?: string 13 | } 14 | 15 | export function ImageViewer({ src, alt, onClose, downloadUrl, externalUrl }: ImageViewerProps) { 16 | const [zoom, setZoom] = useState(0.6) // Start with 60% zoom for better fit 17 | const [isLoading, setIsLoading] = useState(true) 18 | const [showHelp, setShowHelp] = useState(false) 19 | 20 | // Zoom controls 21 | const zoomIn = () => setZoom(prev => Math.min(prev * 1.2, 3)) 22 | const zoomOut = () => setZoom(prev => Math.max(prev / 1.2, 0.3)) 23 | const resetZoom = () => setZoom(1) 24 | 25 | // Double click to reset zoom 26 | const handleImageDoubleClick = () => { 27 | resetZoom() 28 | } 29 | 30 | // Handle keyboard shortcuts 31 | useEffect(() => { 32 | const handleKeyDown = (event: KeyboardEvent) => { 33 | if (event.key === 'Escape') { 34 | onClose() 35 | } else if (event.key === '+' || event.key === '=') { 36 | event.preventDefault() 37 | zoomIn() 38 | } else if (event.key === '-' || event.key === '_') { 39 | event.preventDefault() 40 | zoomOut() 41 | } else if (event.key === '0') { 42 | event.preventDefault() 43 | resetZoom() 44 | } 45 | } 46 | 47 | document.addEventListener('keydown', handleKeyDown) 48 | document.body.style.overflow = 'hidden' 49 | 50 | return () => { 51 | document.removeEventListener('keydown', handleKeyDown) 52 | document.body.style.overflow = 'unset' 53 | } 54 | }, [onClose]) 55 | 56 | // Handle click outside 57 | const handleBackdropClick = (event: React.MouseEvent) => { 58 | if (event.target === event.currentTarget) { 59 | onClose() 60 | } 61 | } 62 | 63 | const handleDownload = () => { 64 | if (downloadUrl) { 65 | const link = document.createElement('a') 66 | link.href = downloadUrl 67 | link.download = `fixtral-image-${Date.now()}.png` 68 | document.body.appendChild(link) 69 | link.click() 70 | document.body.removeChild(link) 71 | } 72 | } 73 | 74 | return ( 75 |
79 | {/* Close button */} 80 | 86 | 87 | {/* Action buttons */} 88 |
89 | {downloadUrl && ( 90 | 97 | )} 98 | {externalUrl && ( 99 | 106 | 107 | 108 | )} 109 | 110 | {/* Zoom Controls */} 111 |
112 | 119 | 126 | 133 | 140 |
141 |
142 | 143 | {/* Help Overlay */} 144 | {showHelp && ( 145 |
146 |
147 |

Image Viewer Controls

148 |
149 |
150 | Zoom In: 151 | + 152 |
153 |
154 | Zoom Out: 155 | - 156 |
157 |
158 | Reset Zoom: 159 | 0 160 |
161 |
162 | Double Click: 163 | Reset Zoom 164 |
165 |
166 | Close: 167 | ESC 168 |
169 |
170 | 176 |
177 |
178 | )} 179 | 180 | {/* Image container */} 181 |
182 |
190 | {isLoading && ( 191 |
192 |
193 |
194 | )} 195 | 196 | {alt} setIsLoading(false)} 206 | onLoad={() => setIsLoading(false)} 207 | onDoubleClick={handleImageDoubleClick} 208 | style={{ imageRendering: zoom > 1 ? 'auto' : 'auto' }} 209 | /> 210 | 211 | {/* Image info overlay */} 212 |
213 |
214 |
215 |

{alt}

216 | {zoom !== 1 && ( 217 |

218 | Zoom: {Math.round(zoom * 100)}% • Double-click to reset 219 |

220 | )} 221 |
222 |
223 | Press ? for help 224 |
225 |
226 |
227 |
228 |
229 |
230 | ) 231 | } 232 | 233 | interface ImageViewerContextType { 234 | showImage: (src: string, alt: string, downloadUrl?: string, externalUrl?: string) => void 235 | hideImage: () => void 236 | } 237 | 238 | const ImageViewerContext = createContext(undefined) 239 | 240 | export function ImageViewerProvider({ children }: { children: ReactNode }) { 241 | const [viewerState, setViewerState] = useState<{ 242 | isOpen: boolean 243 | src: string 244 | alt: string 245 | downloadUrl?: string 246 | externalUrl?: string 247 | }>({ 248 | isOpen: false, 249 | src: '', 250 | alt: '', 251 | }) 252 | 253 | const showImage = (src: string, alt: string, downloadUrl?: string, externalUrl?: string) => { 254 | setViewerState({ 255 | isOpen: true, 256 | src, 257 | alt, 258 | downloadUrl, 259 | externalUrl, 260 | }) 261 | } 262 | 263 | const hideImage = () => { 264 | setViewerState(prev => ({ ...prev, isOpen: false })) 265 | } 266 | 267 | return ( 268 | 269 | {children} 270 | {viewerState.isOpen && ( 271 | 278 | )} 279 | 280 | ) 281 | } 282 | 283 | export function useImageViewer() { 284 | const context = useContext(ImageViewerContext) 285 | if (context === undefined) { 286 | throw new Error('useImageViewer must be used within an ImageViewerProvider') 287 | } 288 | return context 289 | } 290 | -------------------------------------------------------------------------------- /src/app/api/edit/route.ts: -------------------------------------------------------------------------------- 1 | // src/app/api/edit/route.ts 2 | export const runtime = 'nodejs'; // ensure Node runtime (Buffer required) 3 | 4 | import { NextRequest, NextResponse } from 'next/server'; 5 | import { GoogleGenerativeAI } from '@google/generative-ai'; 6 | import sharp from 'sharp'; 7 | 8 | // Validate API key 9 | if (!process.env.GEMINI_API_KEY) { 10 | console.error('❌ GEMINI_API_KEY not found in environment variables'); 11 | } 12 | 13 | const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); 14 | const geminiImageModel = genAI.getGenerativeModel({ 15 | model: 'gemini-2.5-flash-image-preview', 16 | generationConfig: { 17 | temperature: 0.7, 18 | maxOutputTokens: 2048, 19 | } 20 | }); 21 | 22 | export async function POST(request: NextRequest) { 23 | try { 24 | const { imageUrl, changeSummary } = await request.json(); 25 | 26 | if (!imageUrl || !changeSummary) { 27 | return NextResponse.json( 28 | { error: 'Image URL and change summary are required' }, 29 | { status: 400 } 30 | ); 31 | } 32 | 33 | console.log('🎨 Executing edit with Google Gemini 2.5 Flash Image...'); 34 | console.log('Image URL:', imageUrl); 35 | console.log('Change Summary:', changeSummary); 36 | 37 | // Step 1: Download the image 38 | console.log('📥 Downloading image...'); 39 | 40 | const controller = new AbortController(); 41 | const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout 42 | 43 | const imageResponse = await fetch(imageUrl, { 44 | signal: controller.signal, 45 | headers: { 46 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 47 | } 48 | }); 49 | 50 | clearTimeout(timeoutId); 51 | 52 | if (!imageResponse.ok) { 53 | throw new Error(`Failed to fetch image: ${imageResponse.status} ${imageResponse.statusText}`); 54 | } 55 | 56 | const imageBuffer = await imageResponse.arrayBuffer(); 57 | const contentType = imageResponse.headers.get('content-type') || 'image/jpeg'; 58 | 59 | console.log('✅ Downloaded image, size:', imageBuffer.byteLength, 'bytes'); 60 | console.log('📄 Content-Type:', contentType); 61 | 62 | // Step 2: Convert to Sharp image (similar to PIL in Python) 63 | let processedImageBuffer: Buffer; 64 | 65 | try { 66 | // Use Sharp to process the image (like PIL in Python) 67 | const sharpImage = sharp(Buffer.from(imageBuffer)); 68 | 69 | // Get image info 70 | const metadata = await sharpImage.metadata(); 71 | console.log(`🖼️ Image info: ${metadata.format} ${metadata.width}x${metadata.height} ${metadata.channels} channels`); 72 | 73 | // Convert to buffer for Gemini 74 | processedImageBuffer = await sharpImage.toBuffer(); 75 | 76 | } catch (sharpError) { 77 | console.error('❌ Sharp processing error:', sharpError); 78 | // Fallback to original buffer if Sharp fails 79 | processedImageBuffer = Buffer.from(imageBuffer); 80 | } 81 | 82 | // Step 3: Send to Google Gemini 2.5 Flash Image Preview (exactly as user requested) 83 | console.log('🤖 Sending to Google Gemini 2.5 Flash Image Preview...'); 84 | console.log('📋 Prompt:', changeSummary); 85 | console.log('📊 Image size:', processedImageBuffer.length, 'bytes'); 86 | console.log('📄 MIME type:', contentType); 87 | 88 | let response; 89 | try { 90 | // Add timeout for Gemini API call (45 seconds) 91 | const geminiTimeoutId = setTimeout(() => { 92 | console.log('⏰ Gemini API call timed out'); 93 | }, 45000); 94 | 95 | response = await geminiImageModel.generateContent([ 96 | changeSummary, 97 | { 98 | inlineData: { 99 | mimeType: contentType, 100 | data: processedImageBuffer.toString('base64') 101 | } 102 | } 103 | ]); 104 | 105 | clearTimeout(geminiTimeoutId); 106 | 107 | console.log('✅ Received response from Google Gemini 2.5 Flash Image'); 108 | console.log('📦 Raw response structure:', JSON.stringify(response, null, 2)); 109 | 110 | } catch (geminiError: any) { 111 | console.error('❌ Gemini API error:', geminiError); 112 | 113 | if (geminiError.message?.includes('timeout') || geminiError.name === 'AbortError') { 114 | console.log('⏰ Gemini API call timed out'); 115 | return NextResponse.json({ 116 | ok: false, 117 | error: 'Gemini API request timed out. Please try again.', 118 | method: 'timeout', 119 | timestamp: new Date().toISOString() 120 | }); 121 | } 122 | 123 | throw geminiError; // Re-throw to be caught by outer catch 124 | } 125 | 126 | // Step 4: Process the response (Google Gemini API format) 127 | const generatedImages: string[] = []; 128 | 129 | // Check for API errors first 130 | if (response.response?.candidates?.[0]?.finishReason === 'SAFETY') { 131 | console.log('🚫 Gemini blocked content due to safety filters'); 132 | return NextResponse.json({ 133 | ok: false, 134 | error: 'Content blocked by safety filters. Please try a different prompt.', 135 | method: 'safety_blocked', 136 | timestamp: new Date().toISOString() 137 | }); 138 | } 139 | 140 | if (response.response && response.response.candidates && response.response.candidates.length > 0) { 141 | const candidate = response.response.candidates[0]; 142 | console.log('🎯 Candidate finish reason:', candidate.finishReason); 143 | console.log('📋 Candidate safety ratings:', candidate.safetyRatings); 144 | 145 | if (candidate.content && candidate.content.parts) { 146 | console.log(`📦 Found ${candidate.content.parts.length} parts in response`); 147 | 148 | for (const part of candidate.content.parts) { 149 | if (part.text) { 150 | console.log('📝 Text response:', part.text); 151 | } else if (part.inlineData) { 152 | console.log('🖼️ Processing image part'); 153 | 154 | // Convert binary image data to base64 data URL 155 | const imageData = part.inlineData.data; 156 | const mimeType = part.inlineData.mimeType || 'image/png'; 157 | const dataUrl = `data:${mimeType};base64,${imageData}`; 158 | 159 | generatedImages.push(dataUrl); 160 | console.log(`✅ Generated image: ${mimeType}, size: ${imageData.length} chars`); 161 | } 162 | } 163 | } else { 164 | console.log('⚠️ No content in candidate'); 165 | console.log('📄 Full candidate:', JSON.stringify(candidate, null, 2)); 166 | } 167 | } else { 168 | console.log('⚠️ No candidates in response'); 169 | console.log('📄 Full response:', JSON.stringify(response, null, 2)); 170 | } 171 | 172 | if (generatedImages.length === 0) { 173 | console.log('⚠️ No images were generated by Gemini, using enhanced fallback processing...'); 174 | 175 | // Enhanced Fallback: Apply image enhancements with Sharp 176 | try { 177 | console.log('🔧 Applying enhanced fallback image processing with Sharp...'); 178 | 179 | // Get original image metadata 180 | const originalMetadata = await sharp(Buffer.from(imageBuffer)).metadata(); 181 | console.log('📊 Original image:', `${originalMetadata.width}x${originalMetadata.height} ${originalMetadata.format}`); 182 | 183 | // Apply enhancements: auto-orient, sharpen slightly, and optimize 184 | const processedBuffer = await sharp(Buffer.from(imageBuffer)) 185 | .rotate() // Auto-orient based on EXIF 186 | .sharpen({ sigma: 0.5 }) // Slight sharpening 187 | .jpeg({ 188 | quality: 95, // Higher quality 189 | progressive: true // Progressive JPEG 190 | }) 191 | .toBuffer(); 192 | 193 | const fallbackImage = `data:image/jpeg;base64,${processedBuffer.toString('base64')}`; 194 | 195 | console.log('✅ Enhanced fallback processing completed'); 196 | console.log('📊 Processed image size:', processedBuffer.length, 'bytes'); 197 | 198 | return NextResponse.json({ 199 | ok: true, 200 | edited: fallbackImage, 201 | method: 'enhanced_fallback', 202 | hasImageData: true, 203 | generatedImages: [fallbackImage], 204 | timestamp: new Date().toISOString(), 205 | note: 'Used enhanced fallback - Gemini API did not generate new images. Applied image optimization and sharpening.', 206 | originalSize: imageBuffer.byteLength, 207 | processedSize: processedBuffer.length 208 | }); 209 | } catch (fallbackError) { 210 | console.error('❌ Enhanced fallback processing failed:', fallbackError); 211 | 212 | // Last resort: return original image 213 | try { 214 | const originalImage = `data:${contentType};base64,${Buffer.from(imageBuffer).toString('base64')}`; 215 | 216 | return NextResponse.json({ 217 | ok: true, 218 | edited: originalImage, 219 | method: 'original_fallback', 220 | hasImageData: true, 221 | generatedImages: [originalImage], 222 | timestamp: new Date().toISOString(), 223 | note: 'Used original image - Both Gemini and enhanced processing failed', 224 | error: 'Processing failed, returning original image' 225 | }); 226 | } catch (originalError) { 227 | console.error('❌ Even original image processing failed:', originalError); 228 | 229 | const textResponse = response.response?.candidates?.[0]?.content?.parts?.[0]?.text || 230 | response.response?.text() || 231 | 'Unable to process image. Please try again.'; 232 | 233 | return NextResponse.json({ 234 | ok: false, 235 | error: 'All processing methods failed', 236 | method: 'complete_failure', 237 | hasImageData: false, 238 | generatedImages: [], 239 | timestamp: new Date().toISOString(), 240 | textResponse: textResponse 241 | }); 242 | } 243 | } 244 | } 245 | 246 | console.log(`🎉 Success! Generated ${generatedImages.length} image(s)`); 247 | 248 | return NextResponse.json({ 249 | ok: true, 250 | edited: generatedImages[0], // Primary image 251 | method: 'google_gemini', 252 | hasImageData: true, 253 | generatedImages: generatedImages, 254 | timestamp: new Date().toISOString() 255 | }); 256 | 257 | } catch (error) { 258 | console.error('❌ Edit execution error:', error); 259 | return NextResponse.json( 260 | { 261 | ok: false, 262 | error: error instanceof Error ? error.message : 'Failed to execute edit', 263 | timestamp: new Date().toISOString() 264 | }, 265 | { status: 500 } 266 | ); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/lib/database.ts: -------------------------------------------------------------------------------- 1 | import { supabase, Database } from './supabase' 2 | 3 | // Database service layer that maintains localStorage compatibility 4 | 5 | export interface UserCredits { 6 | dailyGenerations: number; 7 | lastResetDate: string; 8 | totalGenerations: number; 9 | } 10 | export class DatabaseService { 11 | // Check if Supabase is configured 12 | private static isSupabaseConfigured(): boolean { 13 | const configured = !!(supabase && 14 | process.env.NEXT_PUBLIC_SUPABASE_URL && 15 | process.env.NEXT_PUBLIC_SUPABASE_URL !== 'https://your-project-id.supabase.co' && 16 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY && 17 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY !== 'your-anon-key-here') 18 | 19 | if (!configured) { 20 | console.log('Supabase not configured:', { 21 | hasSupabase: !!supabase, 22 | hasUrl: !!process.env.NEXT_PUBLIC_SUPABASE_URL, 23 | urlValid: process.env.NEXT_PUBLIC_SUPABASE_URL !== 'https://your-project-id.supabase.co', 24 | hasKey: !!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, 25 | keyValid: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY !== 'your-anon-key-here' 26 | }) 27 | } 28 | 29 | return configured 30 | } 31 | 32 | // Reddit Posts 33 | static async saveRedditPost(post: Database['public']['Tables']['reddit_posts']['Insert']) { 34 | if (this.isSupabaseConfigured()) { 35 | try { 36 | const { data, error } = await supabase 37 | .from('reddit_posts') 38 | .upsert(post, { onConflict: 'post_id' }) 39 | .select() 40 | .single() 41 | 42 | if (error) throw error 43 | return data 44 | } catch (error) { 45 | console.warn('Supabase save failed, falling back to localStorage:', error) 46 | } 47 | } 48 | 49 | // Fallback: Store in localStorage 50 | const posts = this.getLocalRedditPosts() 51 | const existingIndex = posts.findIndex((p: Database['public']['Tables']['reddit_posts']['Row']) => p.post_id === post.post_id) 52 | 53 | if (existingIndex >= 0) { 54 | posts[existingIndex] = { ...posts[existingIndex], ...post } 55 | } else { 56 | posts.push(post) 57 | } 58 | 59 | localStorage.setItem('reddit_posts', JSON.stringify(posts)) 60 | return post 61 | } 62 | 63 | static async getRedditPosts(limit = 50) { 64 | if (this.isSupabaseConfigured()) { 65 | try { 66 | const { data, error } = await supabase 67 | .from('reddit_posts') 68 | .select('*') 69 | .order('created_at', { ascending: false }) 70 | .limit(limit) 71 | 72 | if (error) throw error 73 | return data || [] 74 | } catch (error) { 75 | console.warn('Supabase fetch failed, falling back to localStorage:', error) 76 | } 77 | } 78 | 79 | // Fallback: Get from localStorage 80 | return this.getLocalRedditPosts() 81 | } 82 | 83 | private static getLocalRedditPosts() { 84 | try { 85 | const posts = localStorage.getItem('reddit_posts') 86 | return posts ? JSON.parse(posts) : [] 87 | } catch { 88 | return [] 89 | } 90 | } 91 | 92 | // Edit History 93 | static async saveEditHistory(edit: Database['public']['Tables']['edit_history']['Insert'], userId?: string) { 94 | console.log('DatabaseService.saveEditHistory called:', { userId, hasSupabase: this.isSupabaseConfigured() }) 95 | 96 | if (this.isSupabaseConfigured() && userId) { 97 | try { 98 | const editWithUser = { ...edit, user_id: userId } 99 | console.log('Attempting to save to Supabase:', editWithUser) 100 | 101 | const { data, error } = await supabase 102 | .from('edit_history') 103 | .insert(editWithUser) 104 | .select() 105 | .single() 106 | 107 | if (error) { 108 | console.error('Supabase insert error:', error) 109 | throw error 110 | } 111 | 112 | console.log('Successfully saved to Supabase:', data) 113 | return data 114 | } catch (error: any) { 115 | console.warn('Supabase save failed, falling back to localStorage:', { 116 | message: error.message, 117 | details: error.details, 118 | hint: error.hint, 119 | code: error.code 120 | }) 121 | } 122 | } else { 123 | console.log('Supabase not configured or no userId, using localStorage only') 124 | } 125 | 126 | // Fallback: Store in localStorage (existing implementation) 127 | const history = this.getLocalEditHistory() 128 | const newEdit = { ...edit, id: edit.id || `history_${Date.now()}` } 129 | history.unshift(newEdit) 130 | 131 | // Keep only last 50 items to prevent storage issues 132 | const limitedHistory = history.slice(0, 50) 133 | localStorage.setItem('editHistory', JSON.stringify(limitedHistory)) 134 | return newEdit 135 | } 136 | 137 | static async getEditHistory(userId?: string, limit = 50) { 138 | if (this.isSupabaseConfigured() && userId) { 139 | try { 140 | const { data, error } = await supabase 141 | .from('edit_history') 142 | .select('*') 143 | .eq('user_id', userId) 144 | .order('created_at', { ascending: false }) 145 | .limit(limit) 146 | 147 | if (error) throw error 148 | return data || [] 149 | } catch (error) { 150 | console.warn('Supabase fetch failed, falling back to localStorage:', error) 151 | } 152 | } 153 | 154 | // Fallback: Get from localStorage 155 | return this.getLocalEditHistory() 156 | } 157 | 158 | private static getLocalEditHistory() { 159 | try { 160 | const history = localStorage.getItem('editHistory') 161 | return history ? JSON.parse(history) : [] 162 | } catch { 163 | return [] 164 | } 165 | } 166 | 167 | // User Management (placeholder for future auth) 168 | static async createUser(user: Database['public']['Tables']['users']['Insert']) { 169 | if (this.isSupabaseConfigured()) { 170 | try { 171 | const { data, error } = await supabase 172 | .from('users') 173 | .insert(user) 174 | .select() 175 | .single() 176 | 177 | if (error) throw error 178 | return data 179 | } catch (error) { 180 | console.warn('Supabase user creation failed:', error) 181 | throw error 182 | } 183 | } 184 | 185 | // For now, just return the user object (no persistence without Supabase) 186 | return user 187 | } 188 | 189 | // Clear all data (useful for development/testing) 190 | static async clearAllData() { 191 | if (this.isSupabaseConfigured()) { 192 | try { 193 | // Note: Be careful with this in production! 194 | await supabase.from('edit_history').delete().neq('id', '00000000-0000-0000-0000-000000000000') 195 | await supabase.from('reddit_posts').delete().neq('id', '00000000-0000-0000-0000-000000000000') 196 | } catch (error) { 197 | console.warn('Supabase clear failed:', error) 198 | } 199 | } 200 | 201 | // Clear localStorage 202 | localStorage.removeItem('editHistory') 203 | localStorage.removeItem('reddit_posts') 204 | localStorage.removeItem('pendingEditorItem') 205 | } 206 | 207 | // Credit Management 208 | static async getUserCredits(userId: string): Promise { 209 | if (this.isSupabaseConfigured()) { 210 | try { 211 | const { data, error } = await supabase 212 | .rpc('get_or_create_user_credits', { user_uuid: userId }) 213 | 214 | if (error) throw error 215 | 216 | if (data && data.length > 0) { 217 | return { 218 | dailyGenerations: data[0].daily_generations, 219 | lastResetDate: data[0].last_reset_date, 220 | totalGenerations: data[0].total_generations 221 | } 222 | } 223 | } catch (error: any) { 224 | // Only log if it's not the expected "function not found" error 225 | if (!error?.message?.includes('Could not find the function')) { 226 | console.warn('Supabase credit fetch failed:', error) 227 | } else { 228 | console.log('Using localStorage fallback (database functions not yet created)') 229 | } 230 | } 231 | } 232 | 233 | // Fallback to localStorage 234 | const credits = JSON.parse(localStorage.getItem(`user_credits_${userId}`) || 'null') 235 | if (credits) { 236 | // Check if we need to reset daily credits 237 | const lastReset = new Date(credits.lastResetDate) 238 | const today = new Date() 239 | if (lastReset.toDateString() !== today.toDateString()) { 240 | credits.dailyGenerations = 0 241 | credits.lastResetDate = today.toISOString().split('T')[0] 242 | localStorage.setItem(`user_credits_${userId}`, JSON.stringify(credits)) 243 | } 244 | return credits 245 | } 246 | 247 | // Create new credits 248 | const newCredits: UserCredits = { 249 | dailyGenerations: 0, 250 | lastResetDate: new Date().toISOString().split('T')[0], 251 | totalGenerations: 0 252 | } 253 | localStorage.setItem(`user_credits_${userId}`, JSON.stringify(newCredits)) 254 | return newCredits 255 | } 256 | 257 | static async incrementUserCredits(userId: string): Promise { 258 | const currentCredits = await this.getUserCredits(userId) 259 | 260 | // Check if user is admin (unlimited credits) 261 | const isAdmin = await this.checkAdminStatus(userId) 262 | if (!isAdmin) { 263 | // Check daily limit (2 generations per day) for regular users 264 | if (currentCredits.dailyGenerations >= 2) { 265 | throw new Error('Daily generation limit reached (2 per day)') 266 | } 267 | } 268 | 269 | const updatedCredits: UserCredits = { 270 | dailyGenerations: currentCredits.dailyGenerations + 1, 271 | lastResetDate: currentCredits.lastResetDate, 272 | totalGenerations: currentCredits.totalGenerations + 1 273 | } 274 | 275 | if (this.isSupabaseConfigured()) { 276 | try { 277 | const { error } = await supabase 278 | .from('user_credits') 279 | .upsert({ 280 | user_id: userId, 281 | daily_generations: updatedCredits.dailyGenerations, 282 | last_reset_date: updatedCredits.lastResetDate, 283 | total_generations: updatedCredits.totalGenerations 284 | }, { onConflict: 'user_id' }) 285 | 286 | if (error) throw error 287 | } catch (error) { 288 | console.warn('Supabase credit update failed, falling back to localStorage:', error) 289 | // Continue with localStorage fallback 290 | } 291 | } 292 | 293 | // Update localStorage 294 | localStorage.setItem(`user_credits_${userId}`, JSON.stringify(updatedCredits)) 295 | return updatedCredits 296 | } 297 | 298 | static async checkAdminStatus(userId: string): Promise { 299 | if (this.isSupabaseConfigured()) { 300 | try { 301 | const { data, error } = await supabase 302 | .rpc('is_user_admin', { user_uuid: userId }) 303 | 304 | if (error) throw error 305 | return !!data 306 | } catch (error: any) { 307 | // Only log if it's not the expected "function not found" error 308 | if (!error?.message?.includes('Could not find the function')) { 309 | console.warn('Supabase admin check failed:', error) 310 | } else { 311 | console.log('Using localStorage fallback for admin check (database functions not yet created)') 312 | } 313 | } 314 | } 315 | 316 | // Fallback to localStorage (store admin status locally) 317 | const adminStatus = localStorage.getItem(`user_admin_${userId}`) 318 | 319 | // Auto-set admin for the specified admin email and/or UID from env variables 320 | const adminEmail = process.env.ADMIN_ID 321 | const adminUID = process.env.ADMIN_UID 322 | const userEmail = localStorage.getItem('user_email') 323 | 324 | // Check both email and UID for admin access (only if env vars are set) 325 | const isAdminByEmail = adminEmail && userEmail === adminEmail 326 | const isAdminByUID = adminUID && userId === adminUID 327 | 328 | if ((isAdminByEmail || isAdminByUID) && !adminStatus) { 329 | localStorage.setItem(`user_admin_${userId}`, 'true') 330 | console.log('Admin access granted via environment variable verification') 331 | return true 332 | } 333 | 334 | return adminStatus === 'true' 335 | } 336 | 337 | static async checkCreditLimit(userId: string): Promise<{ canGenerate: boolean; remainingCredits: number; credits: UserCredits; isAdmin: boolean }> { 338 | const credits = await this.getUserCredits(userId) 339 | const isAdmin = await this.checkAdminStatus(userId) 340 | 341 | let canGenerate: boolean 342 | let remainingCredits: number 343 | 344 | if (isAdmin) { 345 | // Admin users have unlimited credits 346 | canGenerate = true 347 | remainingCredits = 999 // Show high number for unlimited 348 | } else { 349 | // Regular users have daily limits 350 | remainingCredits = Math.max(0, 2 - credits.dailyGenerations) 351 | canGenerate = credits.dailyGenerations < 2 352 | } 353 | 354 | return { canGenerate, remainingCredits, credits, isAdmin } 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /src/components/auth-modal.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useState, useEffect } from 'react' 4 | import { Button } from '@/components/ui/button' 5 | import { Input } from '@/components/ui/input' 6 | import { Label } from '@/components/ui/label' 7 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' 8 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' 9 | import { useAuth } from '@/lib/auth-context' 10 | import { LogIn, UserPlus, Mail, Lock } from 'lucide-react' 11 | import { useTheme } from '@/lib/theme-context' 12 | 13 | interface AuthModalProps { 14 | isOpen: boolean 15 | onClose: () => void 16 | onSuccess?: () => void 17 | } 18 | 19 | export const AuthModal: React.FC = ({ isOpen, onClose, onSuccess }) => { 20 | const { theme } = useTheme() 21 | const { signIn, signUp } = useAuth() 22 | const [loading, setLoading] = useState(false) 23 | const [email, setEmail] = useState('') 24 | const [password, setPassword] = useState('') 25 | const [confirmPassword, setConfirmPassword] = useState('') 26 | const [activeTab, setActiveTab] = useState('signin') 27 | const [userExistsMessage, setUserExistsMessage] = useState('') 28 | const [showEmailVerification, setShowEmailVerification] = useState(false) 29 | 30 | const handleSignIn = async (e: React.FormEvent) => { 31 | e.preventDefault() 32 | setLoading(true) 33 | setUserExistsMessage('') // Clear any previous messages 34 | 35 | const { error } = await signIn(email, password) 36 | 37 | if (error) { 38 | alert(error.message) 39 | } else { 40 | onClose() 41 | onSuccess?.() 42 | } 43 | 44 | setLoading(false) 45 | } 46 | 47 | const handleSignUp = async (e: React.FormEvent) => { 48 | e.preventDefault() 49 | 50 | if (password !== confirmPassword) { 51 | alert('Passwords do not match') 52 | return 53 | } 54 | 55 | setLoading(true) 56 | setUserExistsMessage('') 57 | 58 | const { error } = await signUp(email, password) 59 | 60 | if (error) { 61 | // Check if user already exists - comprehensive error detection 62 | const errorMessage = error.message?.toLowerCase() || '' 63 | const errorCode = (error as any)?.status || (error as any)?.code || '' 64 | 65 | // Common Supabase error patterns for existing users 66 | const isUserExistsError = 67 | errorMessage.includes('already registered') || 68 | errorMessage.includes('user already exists') || 69 | errorMessage.includes('already been registered') || 70 | errorMessage.includes('email already in use') || 71 | errorMessage.includes('email address is already registered') || 72 | errorMessage.includes('user with this email already exists') || 73 | errorMessage.includes('duplicate key value') || 74 | errorCode === 422 || 75 | errorCode === 400 76 | 77 | if (isUserExistsError) { 78 | console.log('User already exists detected:', { errorMessage, errorCode }) 79 | 80 | // Switch to signin tab and show helpful message 81 | setActiveTab('signin') 82 | setUserExistsMessage('Account already exists! Please sign in instead.') 83 | setPassword('') // Clear password for security 84 | setConfirmPassword('') // Clear confirm password 85 | 86 | // Focus on password field in signin tab after a brief delay 87 | setTimeout(() => { 88 | const passwordInput = document.getElementById('signin-password') as HTMLInputElement 89 | if (passwordInput) { 90 | passwordInput.focus() 91 | // Also select the text for better UX 92 | passwordInput.select() 93 | } 94 | }, 200) // Slightly longer delay to ensure DOM is ready 95 | 96 | setLoading(false) 97 | return 98 | } 99 | 100 | // For other errors, show alert as usual 101 | alert(error.message) 102 | } else { 103 | // Don't call onSuccess here - user needs to verify email first 104 | setShowEmailVerification(true) 105 | onClose() 106 | // Note: onSuccess is NOT called here - they need to verify email first 107 | } 108 | 109 | setLoading(false) 110 | } 111 | 112 | const handleTabChange = (tab: string) => { 113 | setActiveTab(tab) 114 | setUserExistsMessage('') // Clear message when switching tabs 115 | } 116 | 117 | // Reset state when modal opens 118 | useEffect(() => { 119 | if (isOpen) { 120 | setUserExistsMessage('') 121 | setActiveTab('signin') 122 | setShowEmailVerification(false) 123 | } 124 | }, [isOpen]) 125 | 126 | return ( 127 | <> 128 | 129 | 130 | 131 | 132 | 🚀 Get Started with Fixtral 133 | 134 | 135 | Sign up for FREE and get 2 AI image generations daily! 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | Sign In 144 | 145 | 146 | 147 | Sign Up 148 | 149 | 150 | 151 | 152 | {userExistsMessage && ( 153 |
154 |

155 | {userExistsMessage} 156 |

157 |
158 | )} 159 |
160 |
161 | 162 |
163 | 164 | setEmail(e.target.value)} 170 | className="pl-10" 171 | required 172 | /> 173 |
174 |
175 | 176 |
177 | 178 |
179 | 180 | setPassword(e.target.value)} 186 | className="pl-10" 187 | required 188 | /> 189 |
190 |
191 | 192 | 195 |
196 | 197 | 198 |
199 | 200 | 201 |
202 |
203 | 204 |
205 | 206 | setEmail(e.target.value)} 212 | className="pl-10" 213 | required 214 | /> 215 |
216 |
217 | 218 |
219 | 220 |
221 | 222 | setPassword(e.target.value)} 228 | className="pl-10" 229 | required 230 | /> 231 |
232 |
233 | 234 |
235 | 236 |
237 | 238 | setConfirmPassword(e.target.value)} 244 | className="pl-10" 245 | required 246 | /> 247 |
248 |
249 | 250 | 253 |
254 |
255 |
256 |
257 |
258 | 259 | {/* Email Verification Dialog */} 260 | setShowEmailVerification(false)}> 261 | 262 | 263 | 264 | 🎉 Account Created Successfully! 265 | 266 | 267 | Please verify your email to start using Fixtral 268 | 269 | 270 | 271 |
272 |
273 |
274 |
275 | 276 | 277 | 278 |
279 |
280 |

281 | Check your email 282 |

283 |

284 | We've sent a verification link to {email} 285 |

286 |
287 |
288 |
289 | 290 |
291 | 304 | 305 | 319 |
320 | 321 |
322 |

323 | Didn't receive the email? Check your spam folder or{' '} 324 | 330 |

331 |
332 | 333 | 339 |
340 |
341 |
342 | 343 | ) 344 | } 345 | -------------------------------------------------------------------------------- /src/app/api/reddit/posts/route.ts: -------------------------------------------------------------------------------- 1 | // src/app/api/reddit/posts/route.ts 2 | export const runtime = 'nodejs'; // ensure Node runtime (Buffer required) 3 | 4 | import type { NextRequest } from 'next/server'; 5 | import OpenAI from 'openai'; 6 | import { GoogleGenerativeAI } from '@google/generative-ai'; 7 | 8 | // Dedicated endpoints for the exact workflow 9 | const googleGenAI = process.env.GEMINI_API_KEY ? new GoogleGenerativeAI(process.env.GEMINI_API_KEY) : null; 10 | const googleGeminiModel = googleGenAI ? googleGenAI.getGenerativeModel({ model: 'gemini-2.5-flash' }) : null; 11 | 12 | const openRouterClient = process.env.OPENROUTER_API_KEY ? new OpenAI({ 13 | baseURL: 'https://openrouter.ai/api/v1', 14 | apiKey: process.env.OPENROUTER_API_KEY, 15 | }) : null; 16 | 17 | const TOKEN_URL = 'https://www.reddit.com/api/v1/access_token'; 18 | const API_BASE = 'https://oauth.reddit.com'; 19 | 20 | async function getAccessToken() { 21 | const clientId = process.env.REDDIT_CLIENT_ID?.replace(/"/g, '').trim(); 22 | const clientSecret = process.env.REDDIT_CLIENT_SECRET?.replace(/"/g, '').trim(); 23 | const username = process.env.REDDIT_USERNAME?.replace(/"/g, '').trim(); 24 | const password = process.env.REDDIT_PASSWORD?.replace(/"/g, ''); 25 | 26 | if (!clientId || !clientSecret || !username || !password) { 27 | throw new Error('Reddit credentials are missing from environment variables'); 28 | } 29 | 30 | const userAgent = 31 | process.env.REDDIT_USER_AGENT?.replace(/"/g, '').trim() || 32 | 'windows:com.varnan.wsbmcp:v1.0.0 (by /u/This_Cancel_5950)'; 33 | 34 | // EXACTLY matching the working curl command 35 | const body = new URLSearchParams({ 36 | grant_type: 'password', 37 | username, 38 | password, 39 | scope: 'identity,read' 40 | }).toString(); 41 | 42 | // Use the exact same Basic Auth format as curl -u flag 43 | const authString = `${clientId}:${clientSecret}`; 44 | const basic = Buffer.from(authString).toString('base64'); 45 | 46 | const headers: Record = { 47 | Authorization: `Basic ${basic}`, 48 | 'Content-Type': 'application/x-www-form-urlencoded', 49 | 'User-Agent': userAgent, 50 | }; 51 | 52 | // If you have 2FA, uncomment: 53 | // if (process.env.REDDIT_2FA_CODE) headers['X-Reddit-OTP'] = process.env.REDDIT_2FA_CODE; 54 | 55 | // Add delay to prevent rate limiting - being extra conservative 56 | await new Promise(resolve => setTimeout(resolve, 5000)); // 5 second delay 57 | 58 | const res = await fetch(TOKEN_URL, { method: 'POST', headers, body }); 59 | 60 | if (!res.ok) { 61 | const text = await res.text(); 62 | 63 | // DEBUG: show the basic prefix to compare with curl's success header 64 | console.error('Token call failed. Basic prefix:', 65 | `Basic ${basic.substring(0, 24)}... (compare to curl log)`); 66 | 67 | throw new Error(`Failed to get access token: ${res.status} ${text}`); 68 | } 69 | 70 | const json = await res.json(); 71 | return json.access_token as string; 72 | } 73 | 74 | async function redditGet(path: string, token: string) { 75 | const userAgent = 76 | process.env.REDDIT_USER_AGENT || 77 | 'windows:com.varnan.wsbmcp:v1.0.0 (by /u/Ok-Literature-9189)'; 78 | 79 | const res = await fetch(`${API_BASE}${path}`, { 80 | headers: { 81 | Authorization: `bearer ${token}`, 82 | 'User-Agent': userAgent, 83 | }, 84 | // Reddit API is fine with GET; no-cache avoids Next reusing bad responses 85 | cache: 'no-cache', 86 | }); 87 | 88 | if (!res.ok) { 89 | const text = await res.text(); 90 | throw new Error(`Reddit API error: ${res.status} ${text}`); 91 | } 92 | return res.json(); 93 | } 94 | 95 | // Analyze Reddit post with Google Gemini 2.5 Flash and generate edit prompt 96 | async function analyzeRedditPost(postData: { 97 | title: string; 98 | description: string; 99 | imageUrl: string; 100 | }) { 101 | // Fetch the image and convert to base64 for Google Gemini 102 | const imageResponse = await fetch(postData.imageUrl); 103 | const imageBuffer = await imageResponse.arrayBuffer(); 104 | const imageBase64 = Buffer.from(imageBuffer).toString('base64'); 105 | 106 | // Determine MIME type from URL or default to jpeg 107 | const mimeType = postData.imageUrl.includes('.png') ? 'image/png' : 'image/jpeg'; 108 | 109 | const analysisPrompt = ` 110 | You are an image-edit analyst. Read the title + description + image and return ONE concise instruction paragraph, 111 | strictly describing what to change (no extras, no emojis, no headers). Avoid speculation. 112 | 113 | Title: ${postData.title} 114 | Description: ${postData.description || 'No description provided'} 115 | 116 | Focus ONLY on technical editing requirements - remove/add objects, color changes, restoration, etc. No emotional context or fluff. 117 | `; 118 | 119 | if (!googleGeminiModel) { 120 | throw new Error('Gemini API key not configured'); 121 | } 122 | 123 | const { response } = await googleGeminiModel.generateContent({ 124 | contents: [ 125 | { 126 | role: 'user', 127 | parts: [ 128 | { text: analysisPrompt }, 129 | { 130 | inlineData: { 131 | mimeType: mimeType, 132 | data: imageBase64 133 | } 134 | } 135 | ] 136 | } 137 | ] 138 | }); 139 | 140 | // This should be a single sentence/paragraph 141 | const generatedPrompt = response.text().trim(); 142 | 143 | return { 144 | analysis: generatedPrompt, 145 | originalPost: postData 146 | }; 147 | } 148 | 149 | 150 | 151 | export async function GET(_req: NextRequest) { 152 | try { 153 | console.log('Starting Reddit GET request...'); 154 | const token = await getAccessToken(); 155 | console.log('Token obtained, fetching posts...'); 156 | 157 | // Add delay before fetching posts to prevent rate limiting - extra conservative 158 | await new Promise(resolve => setTimeout(resolve, 3000)); // 3 second delay 159 | 160 | const response = await redditGet('/r/PhotoshopRequest/new?limit=50', token); 161 | console.log('Reddit API response received successfully'); 162 | 163 | // Extract and filter posts with images 164 | const posts = response.data.children.map((child: any) => child.data); 165 | 166 | // Filter posts from last 24 hours and with images 167 | const oneDayAgo = Date.now() / 1000 - (24 * 60 * 60); 168 | 169 | const imagePosts = posts 170 | .filter((post: any) => { 171 | // Check if post has an image 172 | const hasImage = post.url && ( 173 | post.url.match(/\.(jpg|jpeg|png|gif|webp)$/i) || 174 | post.url.includes('i.redd.it') || 175 | post.url.includes('i.imgur.com') || 176 | post.url.includes('redditmedia') || 177 | (post.preview && post.preview.images && post.preview.images.length > 0) 178 | ); 179 | 180 | // Check if post is from last 24 hours 181 | const isRecent = post.created_utc > oneDayAgo; 182 | 183 | return hasImage && isRecent; 184 | }) 185 | .map((post: any) => { 186 | // Get the best image URL available 187 | let imageUrl = post.url; 188 | 189 | // If it's a gallery post, get the first image 190 | if (post.is_gallery && post.media_metadata) { 191 | const firstImageId = Object.keys(post.media_metadata)[0]; 192 | if (post.media_metadata[firstImageId]?.s?.u) { 193 | imageUrl = post.media_metadata[firstImageId].s.u.replace(/&/g, '&'); 194 | } 195 | } 196 | // If it's a crosspost, get the original post's image 197 | else if (post.crosspost_parent_list && post.crosspost_parent_list.length > 0) { 198 | const originalPost = post.crosspost_parent_list[0]; 199 | if (originalPost.url && ( 200 | originalPost.url.match(/\.(jpg|jpeg|png|gif|webp)$/i) || 201 | originalPost.url.includes('i.redd.it') || 202 | originalPost.url.includes('i.imgur.com') 203 | )) { 204 | imageUrl = originalPost.url; 205 | } 206 | } 207 | // If post has preview images, use the highest resolution 208 | else if (post.preview && post.preview.images && post.preview.images.length > 0) { 209 | const previewImage = post.preview.images[0]; 210 | if (previewImage.source?.url) { 211 | imageUrl = previewImage.source.url.replace(/&/g, '&'); 212 | } 213 | } 214 | 215 | return { 216 | id: post.id, 217 | title: post.title, 218 | description: post.selftext || post.title, // Use selftext if available, otherwise title 219 | imageUrl: imageUrl, 220 | postUrl: `https://reddit.com${post.permalink}`, 221 | created_utc: post.created_utc, 222 | created_date: new Date(post.created_utc * 1000).toISOString(), 223 | author: post.author, 224 | score: post.score, 225 | num_comments: post.num_comments, 226 | subreddit: post.subreddit, 227 | thumbnail: post.thumbnail, 228 | upvote_ratio: post.upvote_ratio 229 | }; 230 | }); 231 | 232 | console.log(`Found ${imagePosts.length} image posts from r/PhotoshopRequest in the last 24 hours`); 233 | 234 | return new Response(JSON.stringify({ 235 | ok: true, 236 | posts: imagePosts, 237 | total: imagePosts.length, 238 | timestamp: new Date().toISOString() 239 | }), { 240 | headers: { 'Content-Type': 'application/json' }, 241 | }); 242 | } catch (err: any) { 243 | console.error('Reddit handler error:', err); 244 | console.error('Stack trace:', err?.stack); 245 | 246 | // Check if it's a rate limiting error 247 | const isRateLimited = err?.message?.includes('rate limit') || 248 | err?.message?.includes('too many requests') || 249 | err?.message?.includes('429'); 250 | 251 | return new Response( 252 | JSON.stringify({ 253 | ok: false, 254 | error: String(err?.message || err), 255 | isRateLimited, 256 | solution: isRateLimited ? 257 | "Reddit is rate limiting your requests. Please wait 5-10 minutes and try again. If this persists, consider using a different IP or implementing longer delays between requests." : 258 | "Check your Reddit API credentials and ensure your app is properly registered.", 259 | stack: err?.stack, 260 | envCheck: { 261 | hasRedditClientId: !!process.env.REDDIT_CLIENT_ID, 262 | hasRedditClientSecret: !!process.env.REDDIT_CLIENT_SECRET, 263 | hasRedditUsername: !!process.env.REDDIT_USERNAME, 264 | hasRedditPassword: !!process.env.REDDIT_PASSWORD, 265 | hasGeminiApiKey: !!process.env.GEMINI_API_KEY, 266 | hasOpenRouterApiKey: !!process.env.OPENROUTER_API_KEY 267 | } 268 | }), 269 | { status: isRateLimited ? 429 : 500, headers: { 'Content-Type': 'application/json' } } 270 | ); 271 | } 272 | } 273 | 274 | // POST endpoint to analyze and process a specific Reddit post 275 | export async function POST(request: NextRequest) { 276 | try { 277 | const { postId } = await request.json(); 278 | 279 | if (!postId) { 280 | return new Response( 281 | JSON.stringify({ ok: false, error: 'Post ID is required' }), 282 | { status: 400, headers: { 'Content-Type': 'application/json' } } 283 | ); 284 | } 285 | 286 | console.log(`Analyzing Reddit post: ${postId}`); 287 | 288 | // First, get the specific post details 289 | const token = await getAccessToken(); 290 | const postResponse = await redditGet(`/r/PhotoshopRequest/comments/${postId}`, token); 291 | 292 | if (!postResponse || postResponse.length === 0) { 293 | return new Response( 294 | JSON.stringify({ ok: false, error: 'Post not found' }), 295 | { status: 404, headers: { 'Content-Type': 'application/json' } } 296 | ); 297 | } 298 | 299 | const post = postResponse[0].data.children[0].data; 300 | 301 | // Extract image URL (similar logic to GET endpoint) 302 | let imageUrl = post.url; 303 | 304 | // Handle different image types 305 | if (post.is_gallery && post.media_metadata) { 306 | const firstImageId = Object.keys(post.media_metadata)[0]; 307 | if (post.media_metadata[firstImageId]?.s?.u) { 308 | imageUrl = post.media_metadata[firstImageId].s.u.replace(/&/g, '&'); 309 | } 310 | } else if (post.preview && post.preview.images && post.preview.images.length > 0) { 311 | const previewImage = post.preview.images[0]; 312 | if (previewImage.source?.url) { 313 | imageUrl = previewImage.source.url.replace(/&/g, '&'); 314 | } 315 | } 316 | 317 | // Prepare post data for analysis 318 | const postData = { 319 | title: post.title, 320 | description: post.selftext || post.title, 321 | imageUrl: imageUrl 322 | }; 323 | 324 | console.log('Analyzing post with LLM...'); 325 | 326 | // Step 1: Analyze the post with LLM and generate concise edit prompt 327 | const analysisResult = await analyzeRedditPost(postData); 328 | 329 | console.log('Generated concise analysis prompt:', analysisResult.analysis); 330 | 331 | return new Response(JSON.stringify({ 332 | ok: true, 333 | postId: postId, 334 | originalPost: postData, 335 | analysis: analysisResult.analysis, 336 | timestamp: new Date().toISOString() 337 | }), { 338 | headers: { 'Content-Type': 'application/json' }, 339 | }); 340 | 341 | } catch (err: any) { 342 | console.error('Reddit post analysis error:', err); 343 | return new Response( 344 | JSON.stringify({ 345 | ok: false, 346 | error: String(err?.message || err), 347 | postId: null 348 | }), 349 | { status: 500, headers: { 'Content-Type': 'application/json' } } 350 | ); 351 | } 352 | } 353 | 354 | // Dedicated analysis endpoint using Google Gemini 2.5 Flash 355 | export async function PUT(request: NextRequest) { 356 | try { 357 | const { title, description, imageUrl } = await request.json(); 358 | 359 | if (!imageUrl) { 360 | return new Response( 361 | JSON.stringify({ ok: false, error: 'Image URL is required' }), 362 | { status: 400, headers: { 'Content-Type': 'application/json' } } 363 | ); 364 | } 365 | 366 | console.log('🔍 Analyzing with Google Gemini 2.5 Flash...'); 367 | 368 | // Fetch the image and convert to base64 for Google Gemini 369 | const imageResponse = await fetch(imageUrl); 370 | if (!imageResponse.ok) { 371 | throw new Error(`Failed to fetch image: ${imageResponse.status}`); 372 | } 373 | 374 | const imageBuffer = await imageResponse.arrayBuffer(); 375 | const imageBase64 = Buffer.from(imageBuffer).toString('base64'); 376 | 377 | // Determine MIME type 378 | const mimeType = imageUrl.includes('.png') ? 'image/png' : 'image/jpeg'; 379 | 380 | const analysisPrompt = ` 381 | You are an image-edit analyst. Read the title + description + image and return ONE concise instruction paragraph, 382 | strictly describing what to change (no extras, no emojis, no headers). Avoid speculation. 383 | 384 | Title: ${title || 'No title'} 385 | Description: ${description || 'No description provided'} 386 | 387 | Focus ONLY on technical editing requirements - remove/add objects, color changes, restoration, etc. No emotional context or fluff. 388 | `; 389 | 390 | if (!googleGeminiModel) { 391 | throw new Error('Gemini API key not configured'); 392 | } 393 | 394 | const { response } = await googleGeminiModel.generateContent({ 395 | contents: [ 396 | { 397 | role: 'user', 398 | parts: [ 399 | { text: analysisPrompt }, 400 | { 401 | inlineData: { 402 | mimeType: mimeType, 403 | data: imageBase64 404 | } 405 | } 406 | ] 407 | } 408 | ] 409 | }); 410 | 411 | const changeSummary = response.text().trim(); 412 | 413 | console.log('✅ Analysis complete:', changeSummary); 414 | 415 | return new Response(JSON.stringify({ 416 | ok: true, 417 | changeSummary, 418 | timestamp: new Date().toISOString() 419 | }), { 420 | headers: { 'Content-Type': 'application/json' }, 421 | }); 422 | 423 | } catch (err: any) { 424 | console.error('❌ Analysis error:', err); 425 | return new Response( 426 | JSON.stringify({ 427 | ok: false, 428 | error: String(err?.message || err) 429 | }), 430 | { status: 500, headers: { 'Content-Type': 'application/json' } } 431 | ); 432 | } 433 | } 434 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useEffect } from 'react' 4 | import { Button } from '@/components/ui/button' 5 | import { ThemeToggle } from '@/components/theme-toggle' 6 | import { UserMenu } from '@/components/user-menu' 7 | import { AuthModal } from '@/components/auth-modal' 8 | import { useAuth } from '@/lib/auth-context' 9 | import { Wand2, Sparkles, ArrowRight, Zap, Shield, Users, Star, Twitter, Database, User } from 'lucide-react' 10 | import Link from 'next/link' 11 | 12 | export default function LandingPage() { 13 | const [isVisible, setIsVisible] = useState(false) 14 | const [showAuthModal, setShowAuthModal] = useState(false) 15 | const { user, loading } = useAuth() 16 | 17 | useEffect(() => { 18 | setIsVisible(true) 19 | }, []) 20 | 21 | return ( 22 |
23 | {/* Header */} 24 |
25 |
26 |
27 |
28 |
29 | 30 | 31 |
32 |
33 |

34 | Fixtral 35 |

36 | AI Photoshop Assistant 37 |
38 |
39 |
40 | {!loading && ( 41 | user ? ( 42 | 43 | ) : ( 44 | 52 | ) 53 | )} 54 | 55 |
56 |
57 |
58 |
59 | 60 | {/* Hero Section */} 61 |
62 | {/* Self-Hosted Video Background */} 63 |
64 | 74 | {/* Dark overlay for better text readability */} 75 |
76 |
77 |
78 |
79 |
80 | {/* Logo Container with Maximum Z-Index Priority */} 81 |
82 | 83 | 84 |
85 |

86 | Fixtral 87 |

88 |
89 | 90 |

91 | Revolutionize your image editing with AI-powered Photoshop assistance. 92 | Automate edits from Reddit's r/PhotoshopRequest using Google Gemini AI. 93 |

94 | 95 |
96 | {user ? ( 97 | 98 | 106 | 107 | ) : ( 108 | 118 | )} 119 |
120 |
121 |
122 | 123 | {/* Floating Elements */} 124 |
125 |
126 |
127 | 128 | {/* Features Section */} 129 |
130 |
131 |
132 |

Powerful AI-Powered Editing

133 |

134 | Experience the future of image editing with our advanced AI technology 135 |

136 |
137 | 138 |
139 |
140 |
141 | 142 |
143 |

Lightning Fast

144 |

145 | Generate edited images in seconds using Google Gemini 2.5 Flash Image Preview 146 |

147 |
148 | 149 |
150 |
151 | 152 |
153 |

Privacy First

154 |

155 | Your images and data are processed securely with enterprise-grade security 156 |

157 |
158 | 159 |
160 |
161 | 162 |
163 |

Community Driven

164 |

165 | Powered by Reddit's r/PhotoshopRequest community for real-world editing challenges 166 |

167 |
168 | 169 |
170 |
171 | 172 |
173 |

Cloud Storage

174 |

175 | Supabase-powered cloud storage with user accounts and cross-device synchronization 176 |

177 |
178 |
179 |
180 |
181 | 182 | {/* How It Works */} 183 |
184 |
185 |
186 |

How It Works

187 |

188 | Three simple steps to professional image editing 189 |

190 |
191 | 192 |
193 |
194 |
195 | 1 196 |
197 |

Analyze

198 |

199 | AI analyzes Reddit posts and generates detailed edit instructions 200 |

201 |
202 | 203 |
204 |
205 | 2 206 |
207 |

Edit

208 |

209 | Customize prompts and generate AI-enhanced images instantly 210 |

211 |
212 | 213 |
214 |
215 | 3 216 |
217 |

Save

218 |

219 | Download your edits and build a portfolio of AI-generated images 220 |

221 |
222 |
223 |
224 |
225 | 226 | {/* CTA Section */} 227 |
228 |
229 |
230 |

Ready to Transform Your Images?

231 |

232 | Join thousands of creators using AI to enhance their visual content 233 |

234 | 235 | 236 | 241 | 242 |
243 |
244 |
245 | 246 | {/* Footer */} 247 | 297 | 298 | {/* Auth Modal */} 299 | setShowAuthModal(false)} 302 | onSuccess={() => { 303 | // Redirect to app after successful authentication 304 | window.location.href = '/app' 305 | }} 306 | /> 307 |
308 | ) 309 | } 310 | -------------------------------------------------------------------------------- /src/components/queue-view.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useEffect } from 'react' 4 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 5 | import { Button } from '@/components/ui/button' 6 | import { Badge } from '@/components/ui/badge' 7 | // Progress component will be created inline 8 | import { Wand2, Image as ImageIcon, Clock, User, ExternalLink, Loader2, CheckCircle, XCircle } from 'lucide-react' 9 | import Image from 'next/image' 10 | import { useImageViewer } from './image-viewer' 11 | 12 | interface RedditPost { 13 | id: string 14 | title: string 15 | description: string 16 | imageUrl: string 17 | postUrl: string 18 | created_utc: number 19 | created_date: string 20 | author: string 21 | score: number 22 | num_comments: number 23 | subreddit: string 24 | } 25 | 26 | interface AnalysisResult { 27 | ok: boolean 28 | postId: string 29 | originalPost: RedditPost 30 | analysis: string 31 | timestamp: string 32 | } 33 | 34 | interface EditRequest { 35 | id: string 36 | post: RedditPost 37 | status: 'pending' | 'processing' | 'completed' | 'failed' 38 | analysis?: string 39 | editForm?: any 40 | editedImageUrl?: string 41 | timestamp?: number 42 | } 43 | 44 | export function QueueView() { 45 | const [posts, setPosts] = useState([]) 46 | const [editRequests, setEditRequests] = useState([]) 47 | const [loading, setLoading] = useState(false) 48 | const [analyzingPostId, setAnalyzingPostId] = useState(null) 49 | const [analysisResult, setAnalysisResult] = useState(null) 50 | const { showImage } = useImageViewer() 51 | 52 | // Fetch Reddit posts 53 | const fetchPosts = async () => { 54 | setLoading(true) 55 | try { 56 | const response = await fetch('/api/reddit/posts') 57 | const data = await response.json() 58 | if (data.ok) { 59 | setPosts(data.posts || []) 60 | } 61 | } catch (error) { 62 | console.error('Error fetching posts:', error) 63 | } 64 | setLoading(false) 65 | } 66 | 67 | // Analyze post with Google Gemini 2.5 Flash 68 | const analyzePost = async (postId: string) => { 69 | setAnalyzingPostId(postId) 70 | try { 71 | // First, get the post details 72 | const postResponse = await fetch('/api/reddit/posts') 73 | const postsData = await postResponse.json() 74 | const post = postsData.posts.find((p: any) => p.id === postId) 75 | 76 | if (!post) { 77 | throw new Error('Post not found') 78 | } 79 | 80 | // Now analyze with Google Gemini 2.5 Flash 81 | const analysisResponse = await fetch('/api/reddit/posts', { 82 | method: 'PUT', 83 | headers: { 84 | 'Content-Type': 'application/json', 85 | }, 86 | body: JSON.stringify({ 87 | title: post.title, 88 | description: post.description, 89 | imageUrl: post.imageUrl 90 | }), 91 | }) 92 | 93 | const result = await analysisResponse.json() 94 | if (result.ok) { 95 | setAnalysisResult({ 96 | ok: true, 97 | postId: postId, 98 | originalPost: post, 99 | analysis: result.changeSummary, 100 | timestamp: result.timestamp 101 | }) 102 | } else { 103 | console.error('Analysis failed:', result.error) 104 | } 105 | } catch (error) { 106 | console.error('Error analyzing post:', error) 107 | } 108 | setAnalyzingPostId(null) 109 | } 110 | 111 | // Send analyzed post to editor (for the new workflow) 112 | const sendToEditor = (post: RedditPost, analysis: string) => { 113 | console.log('Sending to editor:', { post, analysis }) 114 | 115 | const requestId = `req_${Date.now()}_${post.id}` 116 | 117 | const newRequest: EditRequest = { 118 | id: requestId, 119 | post, 120 | status: 'completed', 121 | analysis: analysis, 122 | timestamp: Date.now() 123 | } 124 | 125 | setEditRequests(prev => [...prev, newRequest]) 126 | 127 | // Store in localStorage for the editor to pick up 128 | const editorData = { 129 | id: requestId, 130 | post: post, 131 | analysis: analysis, 132 | timestamp: new Date().toISOString() 133 | } 134 | 135 | console.log('Storing editor data:', editorData) 136 | localStorage.setItem('pendingEditorItem', JSON.stringify(editorData)) 137 | 138 | // Clear analysis result 139 | setAnalysisResult(null) 140 | 141 | // Force a storage event to trigger tab switch (for same-window navigation) 142 | window.dispatchEvent(new StorageEvent('storage', { 143 | key: 'pendingEditorItem', 144 | newValue: JSON.stringify(editorData), 145 | oldValue: null, 146 | storageArea: localStorage 147 | })) 148 | 149 | // Show success message 150 | alert('Post sent to Editor! The app will switch to the Editor tab automatically.') 151 | } 152 | 153 | useEffect(() => { 154 | fetchPosts() 155 | 156 | 157 | }, []) 158 | 159 | return ( 160 |
161 | {/* Header Section */} 162 |
163 |
164 |
165 |
166 | 167 |
168 |
169 |

170 | Fixtral 171 |

172 |
173 |
174 |

175 | AI-Powered Reddit Photoshop Assistant - Automated image editing with Google Gemini AI 176 |

177 | 178 | {/* Workflow Steps */} 179 |
180 |
181 |
182 |
183 | 1 184 |
185 | Analyze 186 |
187 |
188 | 189 |
190 |
191 |
192 | 2 193 |
194 | Edit 195 |
196 |
197 | 198 |
199 |
200 |
201 | 3 202 |
203 | Save 204 |
205 |
206 |
207 |
208 | 209 | {/* Analysis Result Modal */} 210 | {analysisResult && ( 211 | 212 | 213 |
214 |
215 | 216 |
217 |
218 | Analysis Complete! 219 | 220 | Google Gemini 2.5 Flash has analyzed the post and generated an edit prompt 221 | 222 |
223 | 224 | Step 1: AI Analysis Complete 225 | 226 |
227 |
228 | 229 |
230 | {/* Original Post */} 231 |
232 |

233 | 234 | Original Reddit Post 235 |

236 |
237 |
238 |

{analysisResult.originalPost.title}

239 |

240 | {analysisResult.originalPost.description} 241 |

242 |
243 | 244 | 245 | {analysisResult.originalPost.author} 246 | 247 | 248 | 249 | {new Date(analysisResult.originalPost.created_utc * 1000).toLocaleDateString()} 250 | 251 |
252 |
253 | 254 |
showImage( 257 | analysisResult.originalPost.imageUrl, 258 | 'Reddit Image', 259 | analysisResult.originalPost.imageUrl, 260 | analysisResult.originalPost.postUrl 261 | )} 262 | > 263 | Original Reddit image 269 |
270 |
271 |
272 |
273 | 274 | {/* Analysis & Actions */} 275 |
276 |

277 | 278 | AI-Generated Edit Prompt 279 |

280 |
281 |
282 |
283 | 284 |
285 |
286 |

{analysisResult.analysis}

287 |
288 |
289 |
290 | 291 |
292 | 299 | 300 | 307 |
308 | 309 |
310 |
311 |
312 | 313 |
314 |
315 |

Next Step:

316 |

Switch to the Editor tab to modify the prompt and generate the edited image.

317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 | )} 325 | 326 | {/* Posts Grid */} 327 |
328 |
329 |
330 |

Reddit Posts Queue

331 |

332 | Latest Photoshop requests from r/PhotoshopRequest 333 |

334 |
335 |
336 | 349 |
350 |
351 | 352 |
353 | {posts.map((post) => ( 354 | 355 | 356 |
357 |
358 | 359 | {post.title} 360 | 361 | 362 | 363 | 364 | {post.author} 365 | 366 | 367 | 368 | {new Date(post.created_utc * 1000).toLocaleDateString()} 369 | 370 | 371 | 372 | 378 | View on Reddit 379 | 380 | 381 | 382 |
383 |
384 |
385 | 386 | 387 | {post.imageUrl && ( 388 |
showImage( 391 | post.imageUrl, 392 | 'Reddit Image', 393 | post.imageUrl, 394 | post.postUrl 395 | )} 396 | > 397 | Post image 403 |
404 | )} 405 | 406 | {post.description && ( 407 |

408 | {post.description} 409 |

410 | )} 411 | 412 |
413 |
414 | 👍 {post.score} 415 | 💬 {post.num_comments} 416 |
417 | 418 | 436 |
437 |
438 |
439 | ))} 440 |
441 | 442 | {posts.length === 0 && !loading && ( 443 |
444 | 445 |

No posts found

446 |

447 | Check back later for new Photoshop requests from Reddit 448 |

449 |
450 | )} 451 |
452 |
453 | ) 454 | } 455 | --------------------------------------------------------------------------------