├── .cursorrules
├── .env.example
├── .eslintrc.json
├── .gitignore
├── README.md
├── components.json
├── next.config.ts
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── src
├── app
│ ├── favicon.ico
│ ├── flux
│ │ └── [model-id]
│ │ │ └── page.tsx
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components
│ ├── api-key-input.tsx
│ ├── image-generator.tsx
│ ├── image-generator
│ │ ├── generation-settings.tsx
│ │ ├── generations-gallery.tsx
│ │ └── image-display.tsx
│ ├── navbar.tsx
│ └── ui
│ │ ├── alert.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── checkbox.tsx
│ │ ├── dialog.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── lightbox.tsx
│ │ ├── progress.tsx
│ │ ├── radio-group.tsx
│ │ ├── scroll-area.tsx
│ │ ├── select.tsx
│ │ ├── separator.tsx
│ │ ├── slider.tsx
│ │ ├── switch.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ ├── toast.tsx
│ │ └── toaster.tsx
├── hooks
│ └── use-toast.ts
└── lib
│ ├── actions
│ └── generate-image.ts
│ ├── models
│ ├── flux
│ │ ├── image-to-image.ts
│ │ ├── text-to-text.ts
│ │ └── training.ts
│ └── registry.ts
│ ├── types.ts
│ └── utils.ts
├── tailwind.config.ts
└── tsconfig.json
/.cursorrules:
--------------------------------------------------------------------------------
1 | # Project Overview
2 | name: FAL.AI Web Interface
3 | description: A modern web interface for FAL.AI API integration allowing users to input their API keys and interact with FAL's AI services.
4 |
5 | # Technical Stack
6 | framework: Next.js 14 (App Router)
7 | styling: Tailwind CSS + Shadcn UI
8 | language: TypeScript
9 | node_version: ">=20.0.0"
10 |
11 | # Key Features
12 | - User API key management
13 | - FAL.AI service integration
14 | - Real-time AI processing
15 | - Secure credential handling
16 |
17 | # Architecture Notes
18 | - Server components by default
19 | - Client components only when necessary (user interactions, state management)
20 | - API key storage in secure client-side storage
21 | - Server-side proxy implementation for FAL.AI requests
22 |
23 |
24 | # Dependencies
25 | - @fal-ai/client: Latest # Official FAL.AI client
26 | - @fal-ai/server-proxy # FAL.AI server proxy
27 | - shadcn/ui: Latest # UI component library
28 | - tailwindcss: Latest # Utility-first CSS
29 |
30 | # Security Considerations
31 | - API keys stored in encrypted client storage
32 | - Server-side proxy for secure API communication
33 | - No API keys in client-side code
34 | - Rate limiting implementation
35 |
36 | # Performance Optimization
37 | - React Server Components for initial render
38 | - Streaming responses for AI operations
39 | - Lazy loading for non-critical components
40 | - Image optimization for AI-generated content
41 |
42 | # State Management
43 | - React hooks for local state
44 | - Server actions for data mutations
45 | - Optimistic updates for better UX
46 |
47 | # Error Handling
48 | - Graceful fallbacks for API failures
49 | - User-friendly error messages
50 | - Request retry mechanisms
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_API_KEY = your_api_key
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "next/typescript"],
3 | "rules": {
4 | "@typescript-eslint/explicit-function-return-type": "off",
5 | "react/react-in-jsx-scope": "off",
6 | "react/jsx-props-no-spreading": "off",
7 | "import/prefer-default-export": "off",
8 | "@next/next/no-img-element": "off",
9 | "jsx-a11y/alt-text": "warn",
10 | "@typescript-eslint/no-explicit-any": "warn",
11 | "@typescript-eslint/no-floating-promises": "off",
12 | "@typescript-eslint/no-unused-vars": "off",
13 | "react-hooks/exhaustive-deps": "off"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 |
32 | # env files (can opt-in for committing if needed)
33 | .env*
34 | !.env.example
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FAL.AI Web Interface
2 |
3 | 
4 |
5 |
6 | A modern web interface for interacting with FAL.AI services, built with Next.js 14 and TypeScript. This application provides a seamless way to manage FAL.AI API keys and interact with various AI services.
7 |
8 | initial idea: reddit: https://www.reddit.com/r/StableDiffusion/comments/1hvklr4/i_made_a_simple_web_ui_to_use_flux_through_the/
9 | ## Features
10 |
11 | - 🔑 Secure API key management
12 | - 🤖 Direct FAL.AI service integration
13 | - ⚡ Real-time AI processing
14 | - 🔒 Secure credential handling
15 | - 🎨 Modern, responsive UI
16 |
17 | ## Tech Stack
18 |
19 | - **Framework:** Next.js 14 (App Router)
20 | - **Language:** TypeScript
21 | - **Styling:** Tailwind CSS + Shadcn UI
22 | - **AI Integration:** FAL.AI Client SDK
23 | - **Node Version:** >=20.0.0
24 |
25 | ## Getting Started
26 |
27 | 1. Clone the repository:
28 | ```bash
29 | git clone https://github.com/yourusername/fal-ai-web-interface.git
30 | cd fal-ai-web-interface
31 | ```
32 |
33 | 2. Install dependencies:
34 | ```bash
35 | npm install
36 | # or
37 | pnpm install
38 | # or
39 | yarn install
40 | ```
41 |
42 | 3. Create a `.env.local` file in the root directory and add your FAL.AI credentials(https://fal.ai/dashboard/keys):
43 | ```env
44 | NEXT_PUBLIC_API_KEY=your_fal_api_key
45 | ```
46 |
47 | 4. Run the development server:
48 | ```bash
49 | npm run dev
50 | # or
51 | pnpm dev
52 | # or
53 | yarn dev
54 | ```
55 |
56 | 5. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
57 |
58 | ## Project Structure
59 |
60 | ```
61 | src/
62 | ├── app/ # Next.js 14 App Router pages
63 | ├── components/ # Reusable UI components
64 | ├── lib/ # Utility functions and configurations
65 | ├── types/ # TypeScript type definitions
66 | └── styles/ # Global styles and Tailwind configurations
67 | ```
68 |
69 | ## Key Dependencies
70 |
71 | - `@fal-ai/client` - Official FAL.AI client
72 | - `@fal-ai/server-proxy` - FAL.AI server proxy
73 | - `shadcn/ui` - UI component library
74 | - `tailwindcss` - Utility-first CSS framework
75 |
76 | ## Security
77 |
78 | - API keys are stored securely in encrypted client storage
79 | - Server-side proxy implementation for secure API communication
80 | - No sensitive credentials exposed in client-side code
81 | - Built-in rate limiting
82 |
83 | ## Performance
84 |
85 | - Leverages React Server Components for optimal performance
86 | - Streaming responses for AI operations
87 | - Lazy loading for non-critical components
88 | - Optimized image handling for AI-generated content
89 |
90 | ## Contributing
91 |
92 | Contributions are welcome! Please feel free to submit a Pull Request.
93 |
94 | ## License
95 |
96 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
97 |
98 | ## Deployment
99 |
100 | The application is optimized for deployment on Vercel. For other platforms, please ensure they support Next.js 14 and Edge Runtime.
101 |
102 | To deploy on Vercel:
103 |
104 | 1. Push your code to GitHub
105 | 2. Import your repository on Vercel
106 | 3. Add your environment variables
107 | 4. set env in vercel: NEXT_PUBLIC_API_KEY=your_fal_api_key or leave it empty
108 | 5. Deploy!
109 |
110 | ## Support
111 |
112 | For support, please open an issue in the GitHub repository or contact the maintainers.
113 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | images: {
5 | remotePatterns: [
6 | {
7 | protocol: 'https',
8 | hostname: 'fal.media',
9 | pathname: '/files/**',
10 | },
11 | ],
12 | },
13 | }
14 |
15 | export default nextConfig;
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "simple-fluex-api-ui",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@fal-ai/client": "^1.2.3",
13 | "@fal-ai/server-proxy": "^1.1.1",
14 | "@radix-ui/react-checkbox": "^1.1.4",
15 | "@radix-ui/react-dialog": "^1.1.6",
16 | "@radix-ui/react-label": "^2.1.2",
17 | "@radix-ui/react-progress": "^1.1.2",
18 | "@radix-ui/react-radio-group": "^1.2.3",
19 | "@radix-ui/react-scroll-area": "^1.2.3",
20 | "@radix-ui/react-select": "^2.1.6",
21 | "@radix-ui/react-separator": "^1.1.2",
22 | "@radix-ui/react-slider": "^1.2.3",
23 | "@radix-ui/react-slot": "^1.1.2",
24 | "@radix-ui/react-switch": "^1.1.3",
25 | "@radix-ui/react-tabs": "^1.1.3",
26 | "@radix-ui/react-toast": "^1.2.6",
27 | "class-variance-authority": "^0.7.1",
28 | "clsx": "^2.1.1",
29 | "date-fns": "^4.1.0",
30 | "lucide-react": "^0.461.0",
31 | "next": "15.0.3",
32 | "react": "19.0.0-rc-66855b96-20241106",
33 | "react-dom": "19.0.0-rc-66855b96-20241106",
34 | "tailwind-merge": "^2.6.0",
35 | "tailwindcss-animate": "^1.0.7",
36 | "uuid": "^11.1.0",
37 | "zod": "^3.24.2"
38 | },
39 | "devDependencies": {
40 | "@types/node": "^20.17.23",
41 | "@types/react": "^18.3.18",
42 | "@types/react-dom": "^18.3.5",
43 | "@types/uuid": "^10.0.0",
44 | "eslint": "^8.57.1",
45 | "eslint-config-next": "15.0.3",
46 | "postcss": "^8.5.3",
47 | "tailwindcss": "^3.4.17",
48 | "typescript": "^5.8.2"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/14790897/simple-flux-web-api-ui/67f1631a296e8651324db1732254806ee869e469/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/flux/[model-id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { ImageGenerator } from "@/components/image-generator";
2 | import { allModels } from "@/lib/models/registry";
3 | import { notFound } from "next/navigation";
4 |
5 | type Props = {
6 | params: Promise<{ "model-id": string }>;
7 | };
8 |
9 | export default async function FluxModelPage({ params }: Props) {
10 | const { "model-id": modelId } = await params;
11 |
12 | // Find the model from our registry
13 | const model = allModels.find((m) => m.id.replace(/\//g, "-") === modelId);
14 |
15 | if (!model) {
16 | notFound();
17 | }
18 |
19 | return (
20 |
21 |
22 |
{model.name}
23 |
24 | Generate images using {model.name}
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | font-family: Arial, Helvetica, sans-serif;
7 | }
8 |
9 | @layer base {
10 | :root {
11 | --background: 0 0% 100%;
12 | --foreground: 240 10% 3.9%;
13 | --card: 0 0% 100%;
14 | --card-foreground: 240 10% 3.9%;
15 | --popover: 0 0% 100%;
16 | --popover-foreground: 240 10% 3.9%;
17 | --primary: 240 5.9% 10%;
18 | --primary-foreground: 0 0% 98%;
19 | --secondary: 240 4.8% 95.9%;
20 | --secondary-foreground: 240 5.9% 10%;
21 | --muted: 240 4.8% 95.9%;
22 | --muted-foreground: 240 3.8% 46.1%;
23 | --accent: 240 4.8% 95.9%;
24 | --accent-foreground: 240 5.9% 10%;
25 | --destructive: 0 84.2% 60.2%;
26 | --destructive-foreground: 0 0% 98%;
27 | --border: 240 5.9% 90%;
28 | --input: 240 5.9% 90%;
29 | --ring: 240 10% 3.9%;
30 | --chart-1: 12 76% 61%;
31 | --chart-2: 173 58% 39%;
32 | --chart-3: 197 37% 24%;
33 | --chart-4: 43 74% 66%;
34 | --chart-5: 27 87% 67%;
35 | --radius: 0.5rem;
36 | }
37 | .dark {
38 | --background: 240 10% 3.9%;
39 | --foreground: 0 0% 98%;
40 | --card: 240 10% 3.9%;
41 | --card-foreground: 0 0% 98%;
42 | --popover: 240 10% 3.9%;
43 | --popover-foreground: 0 0% 98%;
44 | --primary: 0 0% 98%;
45 | --primary-foreground: 240 5.9% 10%;
46 | --secondary: 240 3.7% 15.9%;
47 | --secondary-foreground: 0 0% 98%;
48 | --muted: 240 3.7% 15.9%;
49 | --muted-foreground: 240 5% 64.9%;
50 | --accent: 240 3.7% 15.9%;
51 | --accent-foreground: 0 0% 98%;
52 | --destructive: 0 62.8% 30.6%;
53 | --destructive-foreground: 0 0% 98%;
54 | --border: 240 3.7% 15.9%;
55 | --input: 240 3.7% 15.9%;
56 | --ring: 240 4.9% 83.9%;
57 | --chart-1: 220 70% 50%;
58 | --chart-2: 160 60% 45%;
59 | --chart-3: 30 80% 55%;
60 | --chart-4: 280 65% 60%;
61 | --chart-5: 340 75% 55%;
62 | }
63 | }
64 |
65 | @layer base {
66 | * {
67 | @apply border-border;
68 | }
69 | body {
70 | @apply bg-background text-foreground;
71 | }
72 | }
73 |
74 | /* Interactive elements cursor styles */
75 | button, a, [role="button"], select, summary,
76 | [type="button"], [type="reset"], [type="submit"],
77 | [type="checkbox"], [type="radio"] {
78 | cursor: pointer;
79 | }
80 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import { Metadata } from "next";
3 | import { Navbar } from "@/components/navbar";
4 | import { Toaster } from "@/components/ui/toaster";
5 |
6 | export const metadata: Metadata = {
7 | title: "FAL.AI Web Interface",
8 | description: "A modern web interface for FAL.AI image generation models",
9 | };
10 |
11 | export default function RootLayout({
12 | children,
13 | }: {
14 | children: React.ReactNode;
15 | }) {
16 | return (
17 |
18 |
19 |
20 | {children}
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Card, CardHeader, CardTitle } from "@/components/ui/card";
3 | import { allModels } from "@/lib/models/registry";
4 |
5 | export default function Home() {
6 | return (
7 |
8 |
9 |
FAL.AI Web Interface
10 |
11 | Generate amazing images using FAL.AI's powerful AI models
12 |
13 |
14 | {allModels.map((model) => (
15 |
20 |
21 |
22 | {model.name}
23 |
24 |
25 |
26 | ))}
27 |
28 |
29 |
30 | );
31 | }
--------------------------------------------------------------------------------
/src/components/api-key-input.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from "react";
4 | import { Input } from "@/components/ui/input";
5 | import { Button } from "@/components/ui/button";
6 | import { Eye, EyeOff, Check, Key } from "lucide-react";
7 | import { useToast } from "@/hooks/use-toast";
8 | import { fal } from "@fal-ai/client";
9 |
10 | const API_KEY_STORAGE_KEY = 'fal-ai-api-key';
11 |
12 | function configureFalClient(apiKey: string) {
13 | fal.config({
14 | credentials: apiKey,
15 | });
16 | }
17 |
18 | export function ApiKeyInput() {
19 | const [isVisible, setIsVisible] = useState(false);
20 | const [apiKey, setApiKey] = useState('');
21 | const [hasStoredKey, setHasStoredKey] = useState(false);
22 | const [isInputVisible, setIsInputVisible] = useState(false);
23 | const { toast } = useToast();
24 |
25 | useEffect(() => {
26 | const storedKey = localStorage.getItem(API_KEY_STORAGE_KEY);
27 | if (storedKey) {
28 | setApiKey(storedKey);
29 | setHasStoredKey(true);
30 | configureFalClient(storedKey);
31 | }
32 | }, []);
33 |
34 | const handleSave = () => {
35 | if (!apiKey.trim()) {
36 | toast({
37 | title: "Error",
38 | description: "Please enter a valid API key",
39 | variant: "destructive",
40 | });
41 | return;
42 | }
43 |
44 | const trimmedKey = apiKey.trim();
45 | localStorage.setItem(API_KEY_STORAGE_KEY, trimmedKey);
46 | configureFalClient(trimmedKey);
47 | setHasStoredKey(true);
48 | setIsInputVisible(false);
49 | toast({
50 | title: "API Key Saved",
51 | description: "Your API key has been saved successfully.",
52 | });
53 | };
54 |
55 | if (!isInputVisible) {
56 | return (
57 |
58 |
74 |
75 | );
76 | }
77 |
78 | return (
79 |
80 |
81 | setApiKey(e.target.value)}
85 | placeholder="Enter your FAL.AI API key"
86 | className="pr-8 w-[300px]"
87 | />
88 |
89 |
97 |
104 |
111 |
112 | );
113 | }
--------------------------------------------------------------------------------
/src/components/image-generator.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Model } from "@/lib/types";
4 | import { useState, useEffect } from "react";
5 | import { GenerationSettings } from "./image-generator/generation-settings";
6 | import { ImageDisplay } from "./image-generator/image-display";
7 | import { GenerationsGallery } from "./image-generator/generations-gallery";
8 | import { generateImage } from "@/lib/actions/generate-image";
9 | import { useToast } from "@/hooks/use-toast";
10 | import { Image, Generation } from "@/lib/types";
11 | import { v4 as uuidv4 } from 'uuid';
12 |
13 | const API_KEY_STORAGE_KEY = 'fal-ai-api-key';
14 | const GENERATIONS_STORAGE_KEY = 'fal-ai-generations';
15 |
16 | interface ImageGeneratorProps {
17 | model: Model;
18 | }
19 |
20 | export function ImageGenerator({ model }: ImageGeneratorProps) {
21 | const [prompt, setPrompt] = useState("");
22 | const [isGenerating, setIsGenerating] = useState(false);
23 | const [result, setResult] = useState(null);
24 | const [generations, setGenerations] = useState([]);
25 | const { toast } = useToast();
26 |
27 | const [parameters, setParameters] = useState>(() => {
28 | // Initialize parameters with default values from the model schema
29 | return Object.fromEntries(
30 | model.inputSchema
31 | .filter(param => param.default !== undefined)
32 | .map(param => [param.key, param.default])
33 | );
34 | });
35 |
36 | // Load generations from localStorage on mount
37 | useEffect(() => {
38 | const savedGenerations = localStorage.getItem(GENERATIONS_STORAGE_KEY);
39 | if (savedGenerations) {
40 | try {
41 | setGenerations(JSON.parse(savedGenerations));
42 | } catch (error) {
43 | console.error('Failed to parse saved generations:', error);
44 | }
45 | }
46 | }, []);
47 |
48 | async function handleGenerate() {
49 | console.log("🎨 Starting client-side image generation process");
50 |
51 | const apiKey =
52 | localStorage.getItem(API_KEY_STORAGE_KEY) ??
53 | process.env.NEXT_PUBLIC_API_KEY;
54 | if (!apiKey) {
55 | console.log("❌ No API key found in localStorage");
56 | toast({
57 | title: "API Key Required",
58 | description: "Please set your FAL.AI API key first",
59 | variant: "destructive",
60 | });
61 | return;
62 | }
63 |
64 | console.log("🔄 Setting generation state...");
65 | setIsGenerating(true);
66 |
67 | try {
68 | const allParameters = {
69 | ...parameters,
70 | prompt,
71 | };
72 |
73 | console.log("📤 Sending generation request with parameters:", {
74 | modelId: model.id,
75 | parameters: {
76 | ...allParameters,
77 | prompt: allParameters.prompt?.substring(0, 50) + "...",
78 | },
79 | });
80 |
81 | const response = await generateImage(model, allParameters, apiKey);
82 |
83 | if (response.success) {
84 | console.log("✅ Generation successful:", {
85 | seed: response.seed,
86 | requestId: response.requestId,
87 | });
88 | setResult(response.image);
89 |
90 | // Create a new generation record
91 | const newGeneration: Generation = {
92 | id: uuidv4(),
93 | modelId: model.id,
94 | modelName: model.name,
95 | prompt,
96 | parameters: allParameters,
97 | output: {
98 | images: [response.image],
99 | timings: response.timings || {},
100 | seed: response.seed,
101 | has_nsfw_concepts: response.has_nsfw_concepts || [],
102 | },
103 | timestamp: Date.now(),
104 | };
105 |
106 | // Update generations in state and localStorage
107 | const updatedGenerations = [newGeneration, ...generations];
108 | setGenerations(updatedGenerations);
109 | localStorage.setItem(
110 | GENERATIONS_STORAGE_KEY,
111 | JSON.stringify(updatedGenerations)
112 | );
113 |
114 | toast({
115 | title: "Image generated successfully",
116 | description: `Seed: ${response.seed}`,
117 | });
118 | } else {
119 | console.error("❌ Generation failed:", response.error);
120 | toast({
121 | title: "Generation failed",
122 | description: response.error,
123 | variant: "destructive",
124 | });
125 | }
126 | } catch (error) {
127 | console.error("💥 Unexpected error during generation:", error);
128 | toast({
129 | title: "Generation failed",
130 | description:
131 | error instanceof Error
132 | ? error.message
133 | : "An unexpected error occurred",
134 | variant: "destructive",
135 | });
136 | } finally {
137 | console.log("🏁 Finishing generation process");
138 | setIsGenerating(false);
139 | }
140 | }
141 |
142 | return (
143 |
144 |
145 |
154 |
155 |
156 |
157 |
158 | );
159 | }
--------------------------------------------------------------------------------
/src/components/image-generator/generation-settings.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
5 | import { Textarea } from "@/components/ui/textarea";
6 | import { Label } from "@/components/ui/label";
7 | import { Model, ModelParameter } from "@/lib/types";
8 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
9 | import { Switch } from "@/components/ui/switch";
10 | import { Input } from "@/components/ui/input";
11 |
12 | interface GenerationSettingsProps {
13 | prompt: string;
14 | setPrompt: (prompt: string) => void;
15 | onGenerate: () => void;
16 | isGenerating: boolean;
17 | model: Model;
18 | parameters: Record;
19 | setParameters: (params: Record) => void;
20 | }
21 |
22 | export function GenerationSettings({
23 | prompt,
24 | setPrompt,
25 | onGenerate,
26 | isGenerating,
27 | model,
28 | parameters,
29 | setParameters
30 | }: GenerationSettingsProps) {
31 | function renderParameter(param: ModelParameter) {
32 | if (param.key === 'prompt' || param.key === 'sync_mode' || param.key === 'enable_safety_checker') return null;
33 |
34 | const value = parameters[param.key] ?? param.default;
35 | const onChange = (newValue: any) => {
36 | setParameters({ ...parameters, [param.key]: newValue });
37 | };
38 |
39 | switch (param.type) {
40 | case 'enum':
41 | return (
42 |
43 |
44 |
56 |
57 | );
58 |
59 | case 'boolean':
60 | return (
61 |
62 |
63 |
68 |
69 | );
70 |
71 | case 'number':
72 | // Special handling for guidance_scale and num_inference_steps
73 | if (param.key === 'guidance_scale' || param.key === 'num_inference_steps') {
74 | const config = {
75 | guidance_scale: {
76 | min: 1,
77 | max: 10,
78 | step: 0.1,
79 | default: 3.5,
80 | decimals: 1
81 | },
82 | num_inference_steps: {
83 | min: 1,
84 | max: 50,
85 | step: 1,
86 | default: 35,
87 | decimals: 0
88 | }
89 | }[param.key];
90 |
91 | return (
92 |
93 |
94 |
97 |
98 | {Number(value || config.default).toFixed(config.decimals)}
99 |
100 |
101 |
onChange(Number(e.target.value))}
110 | />
111 |
112 | );
113 | }
114 |
115 | // Default number input for other numeric parameters
116 | return (
117 |
118 |
119 | onChange(Number(e.target.value))}
125 | />
126 |
127 | );
128 |
129 | case 'array':
130 | if (param.key === 'loras') {
131 | const loras = value as Array<{ path: string; scale: number }> || [];
132 | const MAX_LORAS = 3;
133 |
134 | return (
135 |
136 |
137 |
138 |
146 |
147 |
148 | {loras.map((lora, index) => (
149 |
150 |
151 |
152 |
164 |
165 |
197 |
198 | ))}
199 |
200 |
201 | );
202 | }
203 | return null;
204 |
205 | default:
206 | return null;
207 | }
208 | }
209 |
210 | // Group parameters by type for more efficient layout
211 | const groupParameters = () => {
212 | const enumParams: JSX.Element[] = [];
213 | const booleanParams: JSX.Element[] = [];
214 | const numberParams: JSX.Element[] = [];
215 | const otherParams: JSX.Element[] = [];
216 |
217 | model.inputSchema.forEach(param => {
218 | const rendered = renderParameter(param);
219 | if (!rendered) return;
220 |
221 | switch (param.type) {
222 | case 'enum':
223 | enumParams.push(rendered);
224 | break;
225 | case 'boolean':
226 | booleanParams.push(rendered);
227 | break;
228 | case 'number':
229 | numberParams.push(rendered);
230 | break;
231 | default:
232 | otherParams.push(rendered);
233 | }
234 | });
235 |
236 | return { enumParams, booleanParams, numberParams, otherParams };
237 | };
238 |
239 | const { enumParams, booleanParams, numberParams, otherParams } = groupParameters();
240 |
241 | return (
242 |
243 |
244 | Settings
245 | Configure your image generation for {model.name}
246 |
247 |
248 |
249 |
250 |
258 |
259 | {/* Grid layout for enum parameters */}
260 | {enumParams.length > 0 && (
261 |
262 | {enumParams}
263 |
264 | )}
265 |
266 | {/* Grid layout for boolean parameters */}
267 | {booleanParams.length > 0 && (
268 |
269 | {booleanParams}
270 |
271 | )}
272 |
273 | {/* Grid layout for number parameters */}
274 | {numberParams.length > 0 && (
275 |
276 | {numberParams}
277 |
278 | )}
279 |
280 | {/* Other parameters (like LoRA) */}
281 | {otherParams}
282 |
283 |
284 |
291 |
292 |
293 | );
294 | }
--------------------------------------------------------------------------------
/src/components/image-generator/generations-gallery.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from "react";
4 | import { Generation, Model } from "@/lib/types";
5 | import { Card, CardContent } from "@/components/ui/card";
6 | import { ScrollArea } from "@/components/ui/scroll-area";
7 | import { Lightbox } from "@/components/ui/lightbox";
8 | import { formatDistanceToNow } from "date-fns";
9 | import { Button } from "@/components/ui/button";
10 | import { Badge } from "@/components/ui/badge";
11 | import { Separator } from "@/components/ui/separator";
12 | import { Download, Search, Trash2 } from "lucide-react";
13 | import {
14 | Select,
15 | SelectContent,
16 | SelectItem,
17 | SelectTrigger,
18 | SelectValue,
19 | } from "@/components/ui/select";
20 | import { Input } from "@/components/ui/input";
21 | import { flux_1_1_pro, flux_1_1_pro_ultra } from "@/lib/models/flux/text-to-text";
22 | import { cn } from "@/lib/utils";
23 |
24 | type NSFWFilter = "show" | "blur" | "hide";
25 |
26 | interface GenerationsGalleryProps {
27 | generations: Generation[];
28 | }
29 |
30 | const ITEMS_PER_PAGE = 16;
31 | const AVAILABLE_MODELS = [flux_1_1_pro, flux_1_1_pro_ultra];
32 |
33 | const NSFW_OPTIONS = [
34 | { value: "blur", label: "Blur NSFW" },
35 | { value: "show", label: "Show NSFW" },
36 | { value: "hide", label: "Hide NSFW" },
37 | ] as const;
38 |
39 | function truncateText(text: string, maxWords: number = 150) {
40 | const words = text.split(' ');
41 | if (words.length <= maxWords) return text;
42 | return words.slice(0, maxWords).join(' ') + '...';
43 | }
44 |
45 | async function downloadImage(url: string, filename: string) {
46 | try {
47 | const response = await fetch(url);
48 | const blob = await response.blob();
49 | const objectUrl = URL.createObjectURL(blob);
50 |
51 | const link = document.createElement('a');
52 | link.href = objectUrl;
53 | link.download = filename;
54 | document.body.appendChild(link);
55 | link.click();
56 | document.body.removeChild(link);
57 | URL.revokeObjectURL(objectUrl);
58 | } catch (error) {
59 | console.error('Failed to download image:', error);
60 | }
61 | }
62 |
63 | export function GenerationsGallery({ generations }: GenerationsGalleryProps) {
64 | const [selectedImageIndex, setSelectedImageIndex] = useState(null);
65 | const [selectedGeneration, setSelectedGeneration] = useState(null);
66 | const [currentPage, setCurrentPage] = useState(1);
67 | const [selectedModels, setSelectedModels] = useState(AVAILABLE_MODELS.map(m => m.id));
68 | const [nsfwFilter, setNsfwFilter] = useState("blur");
69 | const [searchQuery, setSearchQuery] = useState("");
70 | const [localGenerations, setLocalGenerations] = useState(generations);
71 |
72 | // Update localGenerations when props change
73 | useEffect(() => {
74 | setLocalGenerations(generations);
75 | }, [generations]);
76 |
77 | const sortedAndFilteredGenerations = [...localGenerations]
78 | .sort((a, b) => b.timestamp - a.timestamp)
79 | .filter(gen => selectedModels.includes(gen.modelId))
80 | .filter(gen =>
81 | searchQuery === "" ||
82 | gen.prompt.toLowerCase().includes(searchQuery.toLowerCase())
83 | );
84 |
85 | const handleNSFWToggle = (isNSFW: boolean) => {
86 | if (!selectedGeneration) return;
87 |
88 | const updatedGenerations = localGenerations.map(gen => {
89 | if (gen.id === selectedGeneration.id) {
90 | return {
91 | ...gen,
92 | output: {
93 | ...gen.output,
94 | has_nsfw_concepts: [isNSFW]
95 | }
96 | };
97 | }
98 | return gen;
99 | });
100 |
101 | // Update local state
102 | setLocalGenerations(updatedGenerations);
103 |
104 | // Persist to localStorage
105 | localStorage.setItem('fal-ai-generations', JSON.stringify(updatedGenerations));
106 |
107 | // Update selected generation state
108 | setSelectedGeneration(prev => prev ? {
109 | ...prev,
110 | output: {
111 | ...prev.output,
112 | has_nsfw_concepts: [isNSFW]
113 | }
114 | } : null);
115 | };
116 |
117 | const handleDelete = (generationId: string) => {
118 | const updatedGenerations = localGenerations.filter(gen => gen.id !== generationId);
119 |
120 | // Update local state
121 | setLocalGenerations(updatedGenerations);
122 |
123 | // Persist to localStorage
124 | localStorage.setItem('fal-ai-generations', JSON.stringify(updatedGenerations));
125 |
126 | // Close lightbox if the deleted image was being viewed
127 | if (selectedGeneration?.id === generationId) {
128 | setSelectedGeneration(null);
129 | setSelectedImageIndex(null);
130 | }
131 | };
132 |
133 | const totalPages = Math.ceil(sortedAndFilteredGenerations.length / ITEMS_PER_PAGE);
134 | const paginatedGenerations = sortedAndFilteredGenerations.slice(
135 | (currentPage - 1) * ITEMS_PER_PAGE,
136 | currentPage * ITEMS_PER_PAGE
137 | );
138 |
139 | return (
140 |
141 |
142 |
143 |
Previous Generations
144 |
145 |
146 |
147 |
Search:
148 |
149 |
150 | {
154 | setSearchQuery(e.target.value);
155 | setCurrentPage(1);
156 | }}
157 | className="pl-8"
158 | />
159 |
160 |
161 |
162 |
163 | Models:
164 |
204 |
205 |
206 |
207 | NSFW:
208 |
223 |
224 |
225 |
226 |
227 | {totalPages > 1 && (
228 |
229 |
238 |
239 | {Array.from({ length: totalPages }, (_, i) => i + 1)
240 | .filter(page => {
241 | // On mobile, show fewer page numbers
242 | if (window.innerWidth < 640) {
243 | return page === 1 ||
244 | page === totalPages ||
245 | page === currentPage ||
246 | Math.abs(page - currentPage) <= 1;
247 | }
248 | return true;
249 | })
250 | .map((page, index, array) => (
251 | <>
252 | {index > 0 && array[index - 1] !== page - 1 && (
253 | ...
254 | )}
255 |
264 | >
265 | ))}
266 |
267 |
276 |
277 | )}
278 |
279 |
280 |
281 | {paginatedGenerations
282 | .filter(generation =>
283 | nsfwFilter !== "hide" || !generation.output.has_nsfw_concepts?.[0]
284 | )
285 | .map((generation) => {
286 | const isNSFW = generation.output.has_nsfw_concepts?.[0];
287 | const shouldBlur = isNSFW && nsfwFilter === "blur";
288 |
289 | return (
290 |
291 |
292 |
293 |
{
295 | setSelectedGeneration(generation);
296 | setSelectedImageIndex(0);
297 | }}
298 | className="relative aspect-square"
299 | >
300 |

307 |
308 |
323 |
335 |
336 |
337 |
338 |
339 | {generation.modelName}
340 | {isNSFW && (
341 | NSFW
342 | )}
343 |
344 |
{truncateText(generation.prompt)}
345 |
346 | {formatDistanceToNow(generation.timestamp, { addSuffix: true })}
347 |
348 |
349 |
350 |
351 |
352 | );
353 | })}
354 |
355 |
356 | {selectedGeneration && selectedImageIndex !== null && (
357 |
{
360 | setSelectedImageIndex(null);
361 | setSelectedGeneration(null);
362 | }}
363 | imageUrl={selectedGeneration.output.images[selectedImageIndex].url}
364 | onNext={selectedImageIndex < selectedGeneration.output.images.length - 1
365 | ? () => setSelectedImageIndex(i => i !== null ? i + 1 : null)
366 | : undefined}
367 | onPrevious={selectedImageIndex > 0
368 | ? () => setSelectedImageIndex(i => i !== null ? i - 1 : null)
369 | : undefined}
370 | hasNext={selectedImageIndex < selectedGeneration.output.images.length - 1}
371 | hasPrevious={selectedImageIndex > 0}
372 | onDownload={() => {
373 | const image = selectedGeneration.output.images[selectedImageIndex];
374 | downloadImage(image.url, `generation-${selectedGeneration.id}-${selectedImageIndex}.png`);
375 | }}
376 | onDelete={() => handleDelete(selectedGeneration.id)}
377 | isNSFW={selectedGeneration.output.has_nsfw_concepts?.[selectedImageIndex] ?? false}
378 | onNSFWToggle={handleNSFWToggle}
379 | >
380 |
381 |
382 |
Prompt
383 |
{selectedGeneration.prompt}
384 |
385 |
386 |
387 |
Model
388 |
{selectedGeneration.modelName}
389 |
390 |
391 |
392 |
Generated
393 |
394 | {formatDistanceToNow(selectedGeneration.timestamp, { addSuffix: true })}
395 |
396 |
397 |
398 |
399 | )}
400 |
401 | );
402 | }
--------------------------------------------------------------------------------
/src/components/image-generator/image-display.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { Button } from "@/components/ui/button";
3 | import { Download } from "lucide-react";
4 | import { Image } from "@/lib/types";
5 |
6 | interface ImageDisplayProps {
7 | result: Image | null;
8 | }
9 |
10 | export function ImageDisplay({ result }: ImageDisplayProps) {
11 | return (
12 |
13 |
14 |
15 | Generated Image
16 | Your AI-generated artwork will appear here
17 |
18 | {result && (
19 |
27 | )}
28 |
29 |
30 | {result ? (
31 |
38 | ) : (
39 |
40 |
No image generated yet
41 |
42 | )}
43 |
44 |
45 | );
46 | }
--------------------------------------------------------------------------------
/src/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button } from "@/components/ui/button";
4 | import Link from "next/link";
5 | import { usePathname } from "next/navigation";
6 | import { cn } from "@/lib/utils";
7 | import { ApiKeyInput } from "./api-key-input";
8 |
9 | const routes = [
10 | {
11 | href: "/flux/fal-ai-flux-pro-v1.1",
12 | label: "Flux Pro",
13 | },
14 | {
15 | href: "/flux/fal-ai-flux-pro-v1.1-ultra",
16 | label: "Flux Pro Ultra",
17 | },
18 | {
19 | href: "/flux/fal-ai-flux-lora",
20 | label: "Flux LoRA",
21 | },
22 | {
23 | href: "https://github.com/14790897/simple-flux-web-api-ui",
24 | label: "Give me a star in GitHub",
25 | },
26 | ] as const;
27 |
28 | export function Navbar() {
29 | const pathname = usePathname();
30 |
31 | return (
32 |
59 | );
60 | }
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md 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",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/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 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLDivElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5 | import { Check } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ))
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
29 |
30 | export { Checkbox }
31 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogTrigger,
116 | DialogClose,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/lightbox.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Dialog, DialogContent } from "@/components/ui/dialog";
4 | import { Button } from "@/components/ui/button";
5 | import { Switch } from "@/components/ui/switch";
6 | import { Label } from "@/components/ui/label";
7 | import { ChevronLeft, ChevronRight, Download, X, Trash2 } from "lucide-react";
8 |
9 | export interface LightboxProps {
10 | children?: React.ReactNode;
11 | isOpen: boolean;
12 | onClose: () => void;
13 | imageUrl: string;
14 | onNext?: () => void;
15 | onPrevious?: () => void;
16 | hasNext?: boolean;
17 | hasPrevious?: boolean;
18 | onDownload?: () => void;
19 | onDelete?: () => void;
20 | isNSFW?: boolean;
21 | onNSFWToggle?: (isNSFW: boolean) => void;
22 | }
23 |
24 | export function Lightbox({
25 | children,
26 | isOpen,
27 | onClose,
28 | imageUrl,
29 | onNext,
30 | onPrevious,
31 | hasNext,
32 | hasPrevious,
33 | onDownload,
34 | onDelete,
35 | isNSFW,
36 | onNSFWToggle,
37 | }: LightboxProps) {
38 | return (
39 |
137 | );
138 | }
--------------------------------------------------------------------------------
/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ProgressPrimitive from "@radix-ui/react-progress"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Progress = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, value, ...props }, ref) => (
12 |
20 |
24 |
25 | ))
26 | Progress.displayName = ProgressPrimitive.Root.displayName
27 |
28 | export { Progress }
29 |
--------------------------------------------------------------------------------
/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
5 | import { Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const RadioGroup = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => {
13 | return (
14 |
19 | )
20 | })
21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
22 |
23 | const RadioGroupItem = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => {
27 | return (
28 |
36 |
37 |
38 |
39 |
40 | )
41 | })
42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
43 |
44 | export { RadioGroup, RadioGroupItem }
45 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 | {children}
132 |
133 | ))
134 | SelectItem.displayName = SelectPrimitive.Item.displayName
135 |
136 | const SelectSeparator = React.forwardRef<
137 | React.ElementRef,
138 | React.ComponentPropsWithoutRef
139 | >(({ className, ...props }, ref) => (
140 |
145 | ))
146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
147 |
148 | export {
149 | Select,
150 | SelectGroup,
151 | SelectValue,
152 | SelectTrigger,
153 | SelectContent,
154 | SelectLabel,
155 | SelectItem,
156 | SelectSeparator,
157 | SelectScrollUpButton,
158 | SelectScrollDownButton,
159 | }
160 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/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 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Slider = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
21 |
22 |
23 |
24 |
25 | ))
26 | Slider.displayName = SliderPrimitive.Root.displayName
27 |
28 | export { Slider }
29 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/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 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | })
20 | Textarea.displayName = "Textarea"
21 |
22 | export { Textarea }
23 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ToastPrimitives from "@radix-ui/react-toast"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const ToastProvider = ToastPrimitives.Provider
11 |
12 | const ToastViewport = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | ))
25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
26 |
27 | const toastVariants = cva(
28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
29 | {
30 | variants: {
31 | variant: {
32 | default: "border bg-background text-foreground",
33 | destructive:
34 | "destructive group border-destructive bg-destructive text-destructive-foreground",
35 | },
36 | },
37 | defaultVariants: {
38 | variant: "default",
39 | },
40 | }
41 | )
42 |
43 | const Toast = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef &
46 | VariantProps
47 | >(({ className, variant, ...props }, ref) => {
48 | return (
49 |
54 | )
55 | })
56 | Toast.displayName = ToastPrimitives.Root.displayName
57 |
58 | const ToastAction = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
70 | ))
71 | ToastAction.displayName = ToastPrimitives.Action.displayName
72 |
73 | const ToastClose = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
86 |
87 |
88 | ))
89 | ToastClose.displayName = ToastPrimitives.Close.displayName
90 |
91 | const ToastTitle = React.forwardRef<
92 | React.ElementRef,
93 | React.ComponentPropsWithoutRef
94 | >(({ className, ...props }, ref) => (
95 |
100 | ))
101 | ToastTitle.displayName = ToastPrimitives.Title.displayName
102 |
103 | const ToastDescription = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | ToastDescription.displayName = ToastPrimitives.Description.displayName
114 |
115 | type ToastProps = React.ComponentPropsWithoutRef
116 |
117 | type ToastActionElement = React.ReactElement
118 |
119 | export {
120 | type ToastProps,
121 | type ToastActionElement,
122 | ToastProvider,
123 | ToastViewport,
124 | Toast,
125 | ToastTitle,
126 | ToastDescription,
127 | ToastClose,
128 | ToastAction,
129 | }
130 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useToast } from "@/hooks/use-toast"
4 | import {
5 | Toast,
6 | ToastClose,
7 | ToastDescription,
8 | ToastProvider,
9 | ToastTitle,
10 | ToastViewport,
11 | } from "@/components/ui/toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // Inspired by react-hot-toast library
4 | import * as React from "react"
5 |
6 | import type {
7 | ToastActionElement,
8 | ToastProps,
9 | } from "@/components/ui/toast"
10 |
11 | const TOAST_LIMIT = 1
12 | const TOAST_REMOVE_DELAY = 1000000
13 |
14 | type ToasterToast = ToastProps & {
15 | id: string
16 | title?: React.ReactNode
17 | description?: React.ReactNode
18 | action?: ToastActionElement
19 | }
20 |
21 | const actionTypes = {
22 | ADD_TOAST: "ADD_TOAST",
23 | UPDATE_TOAST: "UPDATE_TOAST",
24 | DISMISS_TOAST: "DISMISS_TOAST",
25 | REMOVE_TOAST: "REMOVE_TOAST",
26 | } as const
27 |
28 | let count = 0
29 |
30 | function genId() {
31 | count = (count + 1) % Number.MAX_SAFE_INTEGER
32 | return count.toString()
33 | }
34 |
35 | type ActionType = typeof actionTypes
36 |
37 | type Action =
38 | | {
39 | type: ActionType["ADD_TOAST"]
40 | toast: ToasterToast
41 | }
42 | | {
43 | type: ActionType["UPDATE_TOAST"]
44 | toast: Partial
45 | }
46 | | {
47 | type: ActionType["DISMISS_TOAST"]
48 | toastId?: ToasterToast["id"]
49 | }
50 | | {
51 | type: ActionType["REMOVE_TOAST"]
52 | toastId?: ToasterToast["id"]
53 | }
54 |
55 | interface State {
56 | toasts: ToasterToast[]
57 | }
58 |
59 | const toastTimeouts = new Map>()
60 |
61 | const addToRemoveQueue = (toastId: string) => {
62 | if (toastTimeouts.has(toastId)) {
63 | return
64 | }
65 |
66 | const timeout = setTimeout(() => {
67 | toastTimeouts.delete(toastId)
68 | dispatch({
69 | type: "REMOVE_TOAST",
70 | toastId: toastId,
71 | })
72 | }, TOAST_REMOVE_DELAY)
73 |
74 | toastTimeouts.set(toastId, timeout)
75 | }
76 |
77 | export const reducer = (state: State, action: Action): State => {
78 | switch (action.type) {
79 | case "ADD_TOAST":
80 | return {
81 | ...state,
82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83 | }
84 |
85 | case "UPDATE_TOAST":
86 | return {
87 | ...state,
88 | toasts: state.toasts.map((t) =>
89 | t.id === action.toast.id ? { ...t, ...action.toast } : t
90 | ),
91 | }
92 |
93 | case "DISMISS_TOAST": {
94 | const { toastId } = action
95 |
96 | // ! Side effects ! - This could be extracted into a dismissToast() action,
97 | // but I'll keep it here for simplicity
98 | if (toastId) {
99 | addToRemoveQueue(toastId)
100 | } else {
101 | state.toasts.forEach((toast) => {
102 | addToRemoveQueue(toast.id)
103 | })
104 | }
105 |
106 | return {
107 | ...state,
108 | toasts: state.toasts.map((t) =>
109 | t.id === toastId || toastId === undefined
110 | ? {
111 | ...t,
112 | open: false,
113 | }
114 | : t
115 | ),
116 | }
117 | }
118 | case "REMOVE_TOAST":
119 | if (action.toastId === undefined) {
120 | return {
121 | ...state,
122 | toasts: [],
123 | }
124 | }
125 | return {
126 | ...state,
127 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
128 | }
129 | }
130 | }
131 |
132 | const listeners: Array<(state: State) => void> = []
133 |
134 | let memoryState: State = { toasts: [] }
135 |
136 | function dispatch(action: Action) {
137 | memoryState = reducer(memoryState, action)
138 | listeners.forEach((listener) => {
139 | listener(memoryState)
140 | })
141 | }
142 |
143 | type Toast = Omit
144 |
145 | function toast({ ...props }: Toast) {
146 | const id = genId()
147 |
148 | const update = (props: ToasterToast) =>
149 | dispatch({
150 | type: "UPDATE_TOAST",
151 | toast: { ...props, id },
152 | })
153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154 |
155 | dispatch({
156 | type: "ADD_TOAST",
157 | toast: {
158 | ...props,
159 | id,
160 | open: true,
161 | onOpenChange: (open) => {
162 | if (!open) dismiss()
163 | },
164 | },
165 | })
166 |
167 | return {
168 | id: id,
169 | dismiss,
170 | update,
171 | }
172 | }
173 |
174 | function useToast() {
175 | const [state, setState] = React.useState(memoryState)
176 |
177 | React.useEffect(() => {
178 | listeners.push(setState)
179 | return () => {
180 | const index = listeners.indexOf(setState)
181 | if (index > -1) {
182 | listeners.splice(index, 1)
183 | }
184 | }
185 | }, [state])
186 |
187 | return {
188 | ...state,
189 | toast,
190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191 | }
192 | }
193 |
194 | export { useToast, toast }
195 |
--------------------------------------------------------------------------------
/src/lib/actions/generate-image.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { fal } from "@fal-ai/client";
4 | import { Model, Image } from "@/lib/types";
5 |
6 | interface SuccessResponse {
7 | success: true;
8 | image: Image;
9 | seed: number;
10 | requestId: string;
11 | timings: Record;
12 | has_nsfw_concepts: boolean[];
13 | }
14 |
15 | interface ErrorResponse {
16 | success: false;
17 | error: string;
18 | }
19 |
20 | type GenerateImageResponse = SuccessResponse | ErrorResponse;
21 |
22 | export async function generateImage(
23 | model: Model,
24 | input: Record,
25 | apiKey: string
26 | ): Promise {
27 | console.log('🚀 Starting image generation process:', {
28 | modelId: model.id,
29 | inputParams: { ...input, prompt: input.prompt?.substring(0, 50) + '...' } // Truncate prompt for logging
30 | });
31 |
32 | try {
33 | if (!apiKey) {
34 | console.error('❌ No API key provided');
35 | throw new Error("Please set your FAL.AI API key first");
36 | }
37 |
38 | console.log('📝 Configuring FAL client with API key');
39 | fal.config({
40 | credentials: apiKey,
41 | });
42 |
43 | console.log('⏳ Subscribing to FAL model...');
44 | const result = await fal.subscribe(model.id, {
45 | input,
46 | logs: true,
47 | onQueueUpdate: (update) => {
48 | console.log(`🔄 Queue Status: ${update.status}`);
49 | if (update.status === "IN_PROGRESS") {
50 | console.log('📊 Generation Logs:');
51 | update.logs.map((log) => log.message).forEach((msg) => console.log(` ${msg}`));
52 | }
53 | },
54 | });
55 |
56 | console.log('📦 Complete API Response:', JSON.stringify(result, null, 2));
57 | console.log('✅ Generation completed:', {
58 | requestId: result.requestId,
59 | hasImages: !!result.data?.images?.length
60 | });
61 |
62 | // Extract the first image from the result
63 | const image = result.data?.images?.[0];
64 | if (!image) {
65 | console.error('❌ No image in response');
66 | throw new Error("No image was generated");
67 | }
68 |
69 | console.log('🎉 Successfully generated image:', {
70 | seed: result.data?.seed,
71 | requestId: result.requestId,
72 | image
73 | });
74 |
75 | return {
76 | success: true,
77 | image,
78 | seed: result.data?.seed,
79 | requestId: result.requestId,
80 | timings: result.data?.timings || {},
81 | has_nsfw_concepts: result.data?.has_nsfw_concepts || [],
82 | };
83 | } catch (error) {
84 | console.error("❌ Image generation failed:", error);
85 | return {
86 | success: false,
87 | error: error instanceof Error ? error.message : "Failed to generate image",
88 | };
89 | }
90 | }
--------------------------------------------------------------------------------
/src/lib/models/flux/image-to-image.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/14790897/simple-flux-web-api-ui/67f1631a296e8651324db1732254806ee869e469/src/lib/models/flux/image-to-image.ts
--------------------------------------------------------------------------------
/src/lib/models/flux/text-to-text.ts:
--------------------------------------------------------------------------------
1 | import { Model } from "@/lib/types";
2 |
3 | export const flux_1_1_pro: Model = {
4 | name: "Flux 1.1 Pro",
5 | id: "fal-ai/flux-pro/v1.1",
6 | inputSchema: [
7 | {
8 | key: "prompt",
9 | type: "string",
10 | required: true
11 | },
12 | {
13 | key: "image_size",
14 | type: "enum",
15 | default: "portrait_4_3",
16 | options: ["square_hd", "square", "portrait_4_3", "portrait_16_9", "landscape_4_3", "landscape_16_9"]
17 | },
18 | {
19 | key: "sync_mode",
20 | type: "boolean",
21 | default: false
22 | },
23 | {
24 | key: "num_images",
25 | type: "number",
26 | default: 1
27 | },
28 | {
29 | key: "enable_safety_checker",
30 | type: "boolean",
31 | default: false
32 | },
33 | {
34 | key: "safety_tolerance",
35 | type: "enum",
36 | default: "6",
37 | options: ["1", "2", "3", "4", "5", "6"]
38 | },
39 | {
40 | key: "output_format",
41 | type: "enum",
42 | default: "jpeg",
43 | options: ["jpeg", "png"]
44 | },
45 | {
46 | key: "seed",
47 | type: "number"
48 | }
49 | ],
50 | outputSchema: [
51 | {
52 | key: "images",
53 | type: "array",
54 | required: true,
55 | items: {
56 | type: "object",
57 | properties: {
58 | url: { type: "string" },
59 | width: { type: "number" },
60 | height: { type: "number" },
61 | content_type: { type: "string" }
62 | }
63 | }
64 | },
65 | {
66 | key: "timings",
67 | type: "object",
68 | required: true
69 | },
70 | {
71 | key: "seed",
72 | type: "number",
73 | required: true
74 | },
75 | {
76 | key: "has_nsfw_concepts",
77 | type: "array",
78 | items: { type: "boolean" },
79 | required: true
80 | },
81 | {
82 | key: "prompt",
83 | type: "string",
84 | required: true
85 | }
86 | ]
87 | };
88 |
89 | export const flux_1_1_pro_ultra: Model = {
90 | name: "Flux 1.1 Pro Ultra",
91 | id: "fal-ai/flux-pro/v1.1-ultra",
92 | inputSchema: [
93 | {
94 | key: "prompt",
95 | type: "string",
96 | required: true
97 | },
98 | {
99 | key: "aspect_ratio",
100 | type: "enum",
101 | default: "16:9",
102 | options: ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"]
103 | },
104 | {
105 | key: "sync_mode",
106 | type: "boolean",
107 | default: false
108 | },
109 | {
110 | key: "num_images",
111 | type: "number",
112 | default: 1
113 | },
114 | {
115 | key: "enable_safety_checker",
116 | type: "boolean",
117 | default: false
118 | },
119 | {
120 | key: "safety_tolerance",
121 | type: "enum",
122 | default: "6",
123 | options: ["1", "2", "3", "4", "5", "6"]
124 | },
125 | {
126 | key: "output_format",
127 | type: "enum",
128 | default: "jpeg",
129 | options: ["jpeg", "png"]
130 | },
131 | {
132 | key: "raw",
133 | type: "boolean"
134 | },
135 | {
136 | key: "seed",
137 | type: "number"
138 | }
139 | ],
140 | outputSchema: [
141 | {
142 | key: "images",
143 | type: "array",
144 | required: true,
145 | items: {
146 | type: "object",
147 | properties: {
148 | url: { type: "string" },
149 | width: { type: "number" },
150 | height: { type: "number" },
151 | content_type: { type: "string" }
152 | }
153 | }
154 | },
155 | {
156 | key: "timings",
157 | type: "object",
158 | required: true
159 | },
160 | {
161 | key: "seed",
162 | type: "number",
163 | required: true
164 | },
165 | {
166 | key: "has_nsfw_concepts",
167 | type: "array",
168 | items: { type: "boolean" },
169 | required: true
170 | },
171 | {
172 | key: "prompt",
173 | type: "string",
174 | required: true
175 | }
176 | ]
177 | };
178 |
179 | export const flux_lora: Model = {
180 | name: "Flux LoRA",
181 | id: "fal-ai/flux-lora",
182 | inputSchema: [
183 | {
184 | key: "prompt",
185 | type: "string",
186 | required: true
187 | },
188 | {
189 | key: "image_size",
190 | type: "enum",
191 | default: "landscape_4_3",
192 | options: ["square_hd", "square", "portrait_4_3", "portrait_16_9", "landscape_4_3", "landscape_16_9"]
193 | },
194 | {
195 | key: "num_inference_steps",
196 | type: "number",
197 | default: 35,
198 | validation: {
199 | min: 1,
200 | max: 50
201 | }
202 | },
203 | {
204 | key: "guidance_scale",
205 | type: "number",
206 | default: 3.5,
207 | validation: {
208 | min: 1,
209 | max: 10
210 | }
211 | },
212 | {
213 | key: "seed",
214 | type: "number"
215 | },
216 | {
217 | key: "loras",
218 | type: "array",
219 | items: {
220 | type: "object",
221 | properties: {
222 | path: {
223 | type: "string",
224 | description: "URL or path to the LoRA weights"
225 | },
226 | scale: {
227 | type: "number",
228 | description: "Scale factor for the LoRA weight (0 to 2)",
229 | validation: {
230 | min: 0,
231 | max: 2
232 | },
233 | default: 1
234 | }
235 | }
236 | },
237 | description: "LoRA weights to use for image generation"
238 | },
239 | {
240 | key: "sync_mode",
241 | type: "boolean",
242 | default: false
243 | },
244 | {
245 | key: "num_images",
246 | type: "number",
247 | default: 1
248 | },
249 | {
250 | key: "enable_safety_checker",
251 | type: "boolean",
252 | default: true
253 | },
254 | {
255 | key: "output_format",
256 | type: "enum",
257 | default: "jpeg",
258 | options: ["jpeg", "png"]
259 | }
260 | ],
261 | outputSchema: [
262 | {
263 | key: "images",
264 | type: "array",
265 | required: true,
266 | items: {
267 | type: "object",
268 | properties: {
269 | url: { type: "string" },
270 | width: { type: "number" },
271 | height: { type: "number" },
272 | content_type: { type: "string" }
273 | }
274 | }
275 | },
276 | {
277 | key: "timings",
278 | type: "object",
279 | required: true
280 | },
281 | {
282 | key: "seed",
283 | type: "number",
284 | required: true
285 | },
286 | {
287 | key: "has_nsfw_concepts",
288 | type: "array",
289 | items: { type: "boolean" },
290 | required: true
291 | },
292 | {
293 | key: "prompt",
294 | type: "string",
295 | required: true
296 | }
297 | ]
298 | };
299 |
--------------------------------------------------------------------------------
/src/lib/models/flux/training.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/14790897/simple-flux-web-api-ui/67f1631a296e8651324db1732254806ee869e469/src/lib/models/flux/training.ts
--------------------------------------------------------------------------------
/src/lib/models/registry.ts:
--------------------------------------------------------------------------------
1 | import { Model } from "@/lib/types";
2 | import * as textToText from "./flux/text-to-text";
3 |
4 | // Get all exported models from text-to-text
5 | const textToTextModels = Object.values(textToText).filter(
6 | (value): value is Model => {
7 | return (
8 | typeof value === "object" &&
9 | value !== null &&
10 | "name" in value &&
11 | "id" in value &&
12 | "inputSchema" in value &&
13 | "outputSchema" in value
14 | );
15 | }
16 | );
17 |
18 | // Export all models in a single array
19 | export const allModels = [...textToTextModels];
--------------------------------------------------------------------------------
/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | export interface Model {
2 | name: string;
3 | id: string;
4 | inputSchema: ModelParameter[];
5 | outputSchema: ModelParameter[];
6 | }
7 |
8 | export interface Generation {
9 | id: string;
10 | modelId: string;
11 | modelName: string;
12 | prompt: string;
13 | parameters: Record;
14 | output: {
15 | images: Image[];
16 | timings: Record;
17 | seed: number;
18 | has_nsfw_concepts: boolean[];
19 | };
20 | timestamp: number;
21 | }
22 |
23 | export interface ModelParameter {
24 | key: string;
25 | type: ModelParameterType;
26 | description?: string;
27 | required?: boolean;
28 | default?: unknown;
29 | options?: unknown[]; // For enum-like parameters
30 | items?: {
31 | type: string;
32 | properties?: Record;
41 | }; // For array item types
42 | validation?: {
43 | min?: number;
44 | max?: number;
45 | pattern?: string;
46 | custom?: (value: unknown) => boolean;
47 | };
48 | }
49 |
50 | export type ModelParameterType =
51 | | 'string'
52 | | 'number'
53 | | 'boolean'
54 | | 'array'
55 | | 'object'
56 | | 'enum'
57 | | 'image' // Special type for image data
58 | | 'file' // Special type for file uploads
59 | | 'json'; // For structured JSON data
60 |
61 | export interface Image {
62 | url: string;
63 | width: number;
64 | height: number;
65 | content_type: string;
66 | [key: string]: unknown; // Allows additional image properties
67 | }
68 |
69 | // Helper type for runtime parameter values
70 | export interface ModelParameterValue {
71 | key: string;
72 | value: unknown;
73 | }
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | darkMode: ["class"],
5 | content: [
6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | colors: {
13 | background: 'hsl(var(--background))',
14 | foreground: 'hsl(var(--foreground))',
15 | card: {
16 | DEFAULT: 'hsl(var(--card))',
17 | foreground: 'hsl(var(--card-foreground))'
18 | },
19 | popover: {
20 | DEFAULT: 'hsl(var(--popover))',
21 | foreground: 'hsl(var(--popover-foreground))'
22 | },
23 | primary: {
24 | DEFAULT: 'hsl(var(--primary))',
25 | foreground: 'hsl(var(--primary-foreground))'
26 | },
27 | secondary: {
28 | DEFAULT: 'hsl(var(--secondary))',
29 | foreground: 'hsl(var(--secondary-foreground))'
30 | },
31 | muted: {
32 | DEFAULT: 'hsl(var(--muted))',
33 | foreground: 'hsl(var(--muted-foreground))'
34 | },
35 | accent: {
36 | DEFAULT: 'hsl(var(--accent))',
37 | foreground: 'hsl(var(--accent-foreground))'
38 | },
39 | destructive: {
40 | DEFAULT: 'hsl(var(--destructive))',
41 | foreground: 'hsl(var(--destructive-foreground))'
42 | },
43 | border: 'hsl(var(--border))',
44 | input: 'hsl(var(--input))',
45 | ring: 'hsl(var(--ring))',
46 | chart: {
47 | '1': 'hsl(var(--chart-1))',
48 | '2': 'hsl(var(--chart-2))',
49 | '3': 'hsl(var(--chart-3))',
50 | '4': 'hsl(var(--chart-4))',
51 | '5': 'hsl(var(--chart-5))'
52 | }
53 | },
54 | borderRadius: {
55 | lg: 'var(--radius)',
56 | md: 'calc(var(--radius) - 2px)',
57 | sm: 'calc(var(--radius) - 4px)'
58 | }
59 | }
60 | },
61 | plugins: [require("tailwindcss-animate")],
62 | } satisfies Config;
63 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------