├── .example.env ├── .prettierrc ├── app ├── api │ ├── s3-upload │ │ └── route.ts │ └── validate-key │ │ └── route.ts ├── favicon.ico ├── Fieldset.tsx ├── SubmitButton.tsx ├── components │ ├── PlusIcon.tsx │ ├── TwitterIcon.tsx │ ├── DownloadIcon.tsx │ └── GitHubIcon.tsx ├── Logo.tsx ├── Spinner.tsx ├── SampleImages.tsx ├── actions.ts ├── suggested-prompts │ ├── SuggestedPrompts.tsx │ └── actions.ts ├── UserAPIKey.tsx ├── ImageUploader.tsx ├── globals.css ├── layout.tsx └── page.tsx ├── public ├── og-image.png ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── pnpm-workspace.yaml ├── postcss.config.mjs ├── lib ├── utils.ts ├── get-together.ts ├── get-adjusted-dimentions.ts ├── rate-limiter.ts └── preload-next-image.ts ├── eslint.config.mjs ├── next.config.ts ├── components.json ├── components └── ui │ └── sonner.tsx ├── .gitignore ├── README.md ├── tsconfig.json └── package.json /.example.env: -------------------------------------------------------------------------------- 1 | TOGETHER_API_KEY= 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {"plugins": ["prettier-plugin-tailwindcss"]} 2 | -------------------------------------------------------------------------------- /app/api/s3-upload/route.ts: -------------------------------------------------------------------------------- 1 | export { POST } from 'next-s3-upload/route'; 2 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nutlope/easyedit/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nutlope/easyedit/HEAD/public/og-image.png -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@tailwindcss/oxide' 3 | - lzo 4 | - sharp 5 | - unrs-resolver 6 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/Fieldset.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | import { useFormStatus } from "react-dom"; 3 | 4 | export function Fieldset({ children, ...rest }: ComponentProps<"fieldset">) { 5 | const { pending } = useFormStatus(); 6 | 7 | return ( 8 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /app/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | import { useFormStatus } from "react-dom"; 3 | import Spinner from "./Spinner"; 4 | 5 | export function SubmitButton({ 6 | children, 7 | disabled, 8 | ...props 9 | }: ComponentProps<"button">) { 10 | const { pending } = useFormStatus(); 11 | return ( 12 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: "https", 8 | hostname: "napkinsdev.s3.us-east-1.amazonaws.com", 9 | }, 10 | { 11 | protocol: "https", 12 | hostname: "api.together.ai", 13 | }, 14 | { 15 | protocol: "https", 16 | hostname: "fal.media", 17 | }, 18 | ], 19 | }, 20 | }; 21 | 22 | export default nextConfig; 23 | -------------------------------------------------------------------------------- /app/components/PlusIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | 3 | export function PlusIcon(props: ComponentProps<"svg">) { 4 | return ( 5 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /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": "", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 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 | } -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Toaster as Sonner, ToasterProps } from "sonner"; 4 | 5 | const Toaster = ({ ...props }: ToasterProps) => { 6 | return ( 7 |
3 | 7 | Edit images with a single prompt. Powered by Flux through Together.ai. 8 |
9 | 10 | ## Tech stack 11 | 12 | - [Flux.1 Kontext](https://www.together.ai/blog/flux-1-kontext) from BFL for the image model 13 | - [Together AI](https://togetherai.link) for inference 14 | - Next.js app router with Tailwind 15 | - Helicone for observability 16 | - Plausible for website analytics 17 | 18 | ## Cloning & running 19 | 20 | 1. Clone the repo: `git clone https://github.com/Nutlope/easyedit` 21 | 2. Create a `.env.local` file and add your [Together AI API key](https://togetherai.link): `TOGETHER_API_KEY=` 22 | 3. Run `npm install` and `npm run dev` to install dependencies and run locally 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "react-jsx", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts", 36 | ".next/dev/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /app/components/DownloadIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | 3 | export function DownloadIcon(props: ComponentProps<"svg">) { 4 | return ( 5 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/get-adjusted-dimentions.ts: -------------------------------------------------------------------------------- 1 | export function getAdjustedDimensions( 2 | width: number, 3 | height: number, 4 | ): { width: number; height: number } { 5 | const maxDim = 1024; 6 | const minDim = 64; 7 | 8 | const roundToMultipleOf16 = (n: number) => Math.round(n / 16) * 16; 9 | 10 | const aspectRatio = width / height; 11 | 12 | let scaledWidth = width; 13 | let scaledHeight = height; 14 | 15 | if (width > maxDim || height > maxDim) { 16 | if (aspectRatio >= 1) { 17 | scaledWidth = maxDim; 18 | scaledHeight = Math.round(maxDim / aspectRatio); 19 | } else { 20 | scaledHeight = maxDim; 21 | scaledWidth = Math.round(maxDim * aspectRatio); 22 | } 23 | } 24 | 25 | const adjustedWidth = Math.min( 26 | maxDim, 27 | Math.max(minDim, roundToMultipleOf16(scaledWidth)), 28 | ); 29 | const adjustedHeight = Math.min( 30 | maxDim, 31 | Math.max(minDim, roundToMultipleOf16(scaledHeight)), 32 | ); 33 | 34 | return { width: adjustedWidth, height: adjustedHeight }; 35 | } 36 | -------------------------------------------------------------------------------- /lib/rate-limiter.ts: -------------------------------------------------------------------------------- 1 | import { Ratelimit } from "@upstash/ratelimit"; 2 | import { Redis } from "@upstash/redis"; 3 | import { headers } from "next/headers"; 4 | import "server-only"; 5 | 6 | export function getRateLimiter() { 7 | let ratelimit: Ratelimit | undefined; 8 | 9 | // Add rate limiting if Upstash API keys are set, otherwise skip 10 | if (process.env.UPSTASH_REDIS_REST_URL) { 11 | ratelimit = new Ratelimit({ 12 | redis: Redis.fromEnv(), 13 | // Allow 5 requests per day 14 | limiter: Ratelimit.fixedWindow(5, "1 d"), 15 | analytics: true, 16 | prefix: "easyedit", 17 | }); 18 | } 19 | return ratelimit; 20 | } 21 | export async function getIPAddress() { 22 | const FALLBACK_IP_ADDRESS = "0.0.0.0"; 23 | const headersList = await headers(); 24 | const forwardedFor = headersList.get("x-forwarded-for"); 25 | 26 | if (forwardedFor) { 27 | return forwardedFor.split(",")[0] ?? FALLBACK_IP_ADDRESS; 28 | } 29 | 30 | return headersList.get("x-real-ip") ?? FALLBACK_IP_ADDRESS; 31 | } 32 | -------------------------------------------------------------------------------- /lib/preload-next-image.ts: -------------------------------------------------------------------------------- 1 | import { getImageProps, ImageProps } from "next/image"; 2 | 3 | export async function preloadNextImage(imageProps: Omit37 | Nothing to upload?{" "} 38 | Try a sample image: 39 |
40 |[Optional] Add your
76 | 82 | Together API Key: 83 | 84 |Prompt used:
235 |236 | {activeImage.prompt} 237 |
238 |256 | Editing your image... 257 |
258 |259 | This can take up to 15 seconds. 260 |
261 |414 | Select an image to make more edits. 415 |
416 | )} 417 |