├── .env.example ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── cache.yml │ ├── cleanup.yml │ ├── issue-stale.yml │ ├── lockfile-cloud.yml │ ├── lockfile.yml │ └── pr-reviewer.yml ├── .gitignore ├── .vercelignore ├── .vscode └── settings.json ├── README.md ├── cloud ├── .firebaserc ├── .gitignore ├── firebase.json └── functions │ ├── .env.example │ ├── .gitignore │ ├── .prettierrc │ ├── eslint.config.mjs │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── constants.ts │ ├── handlers │ │ ├── generateImage.ts │ │ ├── getImage.ts │ │ ├── getImages.ts │ │ └── getPromptSuggestion.ts │ ├── index.ts │ ├── lib │ │ ├── firebase.ts │ │ ├── gemini.ts │ │ ├── openai.ts │ │ ├── storeImage.ts │ │ └── vertexai.ts │ ├── types.ts │ └── utils │ │ └── prepareImage.ts │ └── tsconfig.json ├── iac ├── .gitignore ├── main.tf ├── provider.tf └── variables.tf ├── next.config.ts ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── favicon.ico ├── openai-b.png └── placeholder.jpg ├── src ├── app │ ├── [id] │ │ └── page.tsx │ ├── api │ │ ├── [trpc] │ │ │ └── route.ts │ │ └── auth │ │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── layout.tsx │ └── page.tsx ├── common.ts ├── components │ ├── AuthButton.tsx │ ├── ClientProvider.tsx │ ├── Footer.tsx │ ├── Header.tsx │ ├── Image.tsx │ ├── Images.tsx │ ├── ModelSelector.tsx │ ├── PromptInput.tsx │ ├── ProviderSelector.tsx │ └── VertexAILogo.tsx ├── data │ ├── ai-providers.ts │ └── imagen-models.ts ├── server │ ├── client.ts │ ├── index.ts │ ├── routes │ │ ├── generateImage.ts │ │ ├── getImages.ts │ │ └── suggestion.ts │ └── trpc.ts ├── services │ ├── fetchImages.ts │ ├── fetchSuggestion.ts │ └── firebase.ts ├── styles │ └── globals.css ├── types.ts └── utils │ ├── auth.ts │ ├── classname.ts │ └── date-fns.ts ├── tsconfig.json ├── vercel.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # Cloud Functions 2 | FIREBASE_API_KEY= 3 | FIREBASE_AUTH_DOMAIN= 4 | FIREBASE_PROJECT_ID= 5 | FIREBASE_STORAGE_BUCKET= 6 | FIREBASE_MESSAGING_SENDER_ID= 7 | FIREBASE_APP_ID= 8 | FIREBASE_MEASUREMENT_ID= 9 | FUNCTIONS_REGION= 10 | 11 | # TRPC Token 12 | NEXT_PUBLIC_TRPC_TOKEN= 13 | 14 | # Authentication 15 | GOOGLE_CLIENT_ID= 16 | GOOGLE_CLIENT_SECRET= 17 | AUTH_SECRET= 18 | ALLOWED_EMAILS= 19 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "eslint:recommended", 5 | "plugin:import/errors", 6 | "plugin:import/warnings", 7 | "plugin:import/typescript", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "ignorePatterns": ["cloud/", "node_modules/"], 11 | "rules": { 12 | "@typescript-eslint/consistent-type-imports": "error", 13 | "@typescript-eslint/no-import-type-side-effects": "error" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for github-actions 4 | - package-ecosystem: github-actions 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | 9 | # Maintain dependencies for npm/yarn 10 | - package-ecosystem: npm 11 | directory: "/" 12 | schedule: 13 | interval: weekly 14 | labels: 15 | - javascript 16 | - dependencies 17 | groups: 18 | nextjs: 19 | patterns: 20 | - "next" 21 | - "eslint-config-next" 22 | react: 23 | patterns: 24 | - "react" 25 | - "react-dom" 26 | typescript-eslint: 27 | patterns: 28 | - "@typescript-eslint/*" 29 | 30 | # Maintain dependencies for Cloud Functions 31 | - package-ecosystem: npm 32 | directory: "/cloud/functions" 33 | schedule: 34 | interval: weekly 35 | labels: 36 | - functions 37 | - dependencies 38 | groups: 39 | typescript-eslint: 40 | patterns: 41 | - "@typescript-eslint/*" 42 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Build Apps 2 | on: 3 | pull_request: 4 | branches: ["main"] 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 8 | cancel-in-progress: true 9 | 10 | env: 11 | NODE_VERSION: 20.x 12 | 13 | jobs: 14 | build: 15 | name: Run lint and build 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout Repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Node.js ${{ env.NODE_VERSION }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ env.NODE_VERSION }} 26 | 27 | - name: Check changed files 28 | uses: dorny/paths-filter@v3 29 | id: changes 30 | with: 31 | filters: | 32 | frontend: 33 | - "src/**" 34 | - "package.json" 35 | - "yarn.lock" 36 | functions: 37 | - "cloud/functions/**" 38 | 39 | - name: Lint frontend 40 | if: steps.changes.outputs.frontend == 'true' 41 | run: | 42 | yarn install --frozen-lockfile 43 | yarn lint 44 | 45 | - name: Lint functions 46 | if: steps.changes.outputs.functions == 'true' 47 | working-directory: ./cloud/functions 48 | run: | 49 | npm ci 50 | npm run lint --max-warnings=0 51 | npm run build 52 | -------------------------------------------------------------------------------- /.github/workflows/cache.yml: -------------------------------------------------------------------------------- 1 | name: Cache Node.js Dependencies 2 | on: 3 | schedule: 4 | # Every Sunday at 00:00 UTC+7 5 | - cron: "0 17 * * 6" 6 | workflow_dispatch: 7 | 8 | env: 9 | RUNNER_OS: linux 10 | RUNNER_ARCH: x64 11 | 12 | jobs: 13 | cache: 14 | name: Cache frontend dependencies 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 🛎️ Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: 📁 Get Yarn cache directory 21 | id: yarn-cache-dir 22 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 23 | shell: bash 24 | 25 | - name: ☁️ Cache Yarn 26 | uses: actions/cache@v4 27 | with: 28 | path: ${{ steps.yarn-cache-dir.outputs.dir }} 29 | key: node-cache-${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 30 | restore-keys: node-cache-${{ runner.os }}-yarn- 31 | 32 | - name: 🧽 Cache cleanup 33 | run: yarn cache clean 34 | 35 | - name: 📦 Install dependencies 36 | run: yarn install --frozen-lockfile 37 | -------------------------------------------------------------------------------- /.github/workflows/cleanup.yml: -------------------------------------------------------------------------------- 1 | name: Cleanup Old Cache 2 | on: 3 | workflow_run: 4 | workflows: 5 | - "Cache Node.js dependencies" 6 | - "Lockfile Maintenance" 7 | - "Lockfile Maintenance (Cloud)" 8 | branches: [main] 9 | types: [completed] 10 | workflow_dispatch: 11 | 12 | permissions: 13 | actions: write 14 | contents: read 15 | 16 | jobs: 17 | cleanup-yarn: 18 | name: Cleanup frontend cache 19 | uses: KenTandrian/workflows/.github/workflows/cleanup.yml@main 20 | with: 21 | name: Yarn 22 | pattern: node-cache-Linux-yarn- 23 | 24 | cleanup-npm: 25 | name: Cleanup backend cache 26 | uses: KenTandrian/workflows/.github/workflows/cleanup.yml@main 27 | with: 28 | name: NPM 29 | pattern: node-cache-Linux-npm- 30 | -------------------------------------------------------------------------------- /.github/workflows/issue-stale.yml: -------------------------------------------------------------------------------- 1 | name: Close Inactive Issues 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | stale: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | steps: 12 | - uses: actions/stale@v9 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | days-before-issue-close: 7 16 | days-before-issue-stale: 30 17 | days-before-pr-close: -1 18 | days-before-pr-stale: -1 19 | close-issue-message: "This issue has been automatically closed because it has been inactive for 7 days since being marked as stale. If you are running into a similar issue, please open a new issue with a reproduction. Thank you." 20 | stale-issue-label: "stale" 21 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 22 | -------------------------------------------------------------------------------- /.github/workflows/lockfile-cloud.yml: -------------------------------------------------------------------------------- 1 | name: Lockfile Maintenance (Cloud) 2 | on: workflow_dispatch 3 | 4 | jobs: 5 | run: 6 | name: Run workflow 7 | uses: KenTandrian/workflows/.github/workflows/lockfile.yml@main 8 | permissions: 9 | contents: write 10 | with: 11 | package-manager: npm 12 | working-directory: ./cloud/functions 13 | secrets: inherit 14 | -------------------------------------------------------------------------------- /.github/workflows/lockfile.yml: -------------------------------------------------------------------------------- 1 | name: Lockfile Maintenance 2 | on: workflow_dispatch 3 | 4 | jobs: 5 | run: 6 | name: Run workflow 7 | uses: KenTandrian/workflows/.github/workflows/lockfile.yml@main 8 | permissions: 9 | contents: write 10 | with: 11 | package-manager: yarn 12 | secrets: inherit 13 | -------------------------------------------------------------------------------- /.github/workflows/pr-reviewer.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Reviewer 2 | 3 | on: pull_request_target 4 | 5 | permissions: 6 | pull-requests: write 7 | contents: write 8 | repository-projects: read 9 | 10 | jobs: 11 | review-dependabot-pr: 12 | name: PR Review 13 | uses: KenTandrian/workflows/.github/workflows/dependabot-reviewer.yml@main 14 | -------------------------------------------------------------------------------- /.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.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | # Cloud Functions 2 | cloud/ 3 | 4 | # Terraform 5 | iac/ 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": "explicit" 4 | }, 5 | "typescript.tsdk": "node_modules/typescript/lib", 6 | "typescript.enablePromptUseWorkspaceTsdk": true 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Image Generator 2 | 3 | [![HTML5](https://img.shields.io/badge/-HTML5-black?style=for-the-badge&logo=html5&logoColor=orange)](https://github.com/KenTandrian?tab=repositories&language=html) 4 | [![CSS3](https://img.shields.io/badge/-CSS3-black?style=for-the-badge&logo=css3&logoColor=blue)](https://github.com/KenTandrian?tab=repositories&language=css) 5 | [![JavaScript](https://img.shields.io/badge/-JavaScript-black?style=for-the-badge&logo=javascript)](https://github.com/KenTandrian?tab=repositories&language=javascript) 6 | [![TypeScript](https://img.shields.io/badge/typescript-black?style=for-the-badge&logo=typescript&logoColor=%23007ACC)](https://github.com/KenTandrian?tab=repositories&language=typescript) 7 | [![React](https://img.shields.io/badge/-React-black?style=for-the-badge&logo=react)](https://github.com/KenTandrian?tab=repositories&language=javascript) 8 | [![Next JS](https://img.shields.io/badge/Next-black?style=for-the-badge&logo=next.js&logoColor=white)](https://github.com/KenTandrian?tab=repositories) 9 | [![Vercel](https://img.shields.io/badge/Vercel-000000?style=for-the-badge&logo=vercel&logoColor=white)](https://github.com/KenTandrian?tab=repositories) 10 | [![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-black?style=for-the-badge&logo=tailwind-css&logoColor=38B2AC)](https://github.com/KenTandrian?tab=repositories) 11 | 12 | ## Introduction 13 | 14 | AI Image Generator is an AI-based drawing generator site made using [Imagen](https://imagen.research.google), [Vertex AI](https://cloud.google.com/vertex-ai) and [Google Cloud](https://cloud.google.com).\ 15 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 16 | 17 | ## Running Locally 18 | 19 | 1. Install dependencies: `yarn` 20 | 2. Start the dev server: `yarn dev` 21 | 22 | ## Dependencies 23 | 24 | ### Production 25 | 26 | [`next`](https://yarnpkg.com/package/next) 27 | [`openai`](https://yarnpkg.com/package/openai) 28 | [`react`](https://yarnpkg.com/package/react) 29 | [`react-dom`](https://yarnpkg.com/package/react-dom) 30 | [`react-icons`](https://yarnpkg.com/package/react-icons) 31 | [`swr`](https://yarnpkg.com/package/swr) 32 | [`typescript`](https://yarnpkg.com/package/typescript) 33 | 34 | ### Development 35 | 36 | [`eslint`](https://yarnpkg.com/package/eslint) 37 | [`postcss`](https://yarnpkg.com/package/postcss) 38 | [`prettier`](https://yarnpkg.com/package/prettier) 39 | [`tailwindcss`](https://yarnpkg.com/package/tailwindcss) 40 | 41 | [Go to List of Dependencies](https://github.com/KenTandrian/ai-image-generator/network/dependencies) 42 | 43 | ## Features 44 | 45 | 1. Generate image with natural language prompts. 46 | 2. Check out images generated by other people. 47 | 3. Switch AI models. 48 | 4. AI-generated image scoring with Gemini (in progress). 49 | 5. Like images (in progress). 50 | 51 | ## 52 | 53 | © Ken Tandrian 2024. 54 | -------------------------------------------------------------------------------- /cloud/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "crwn-db-65f8d" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /cloud/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | firebase-debug.*.log* 9 | 10 | # Firebase cache 11 | .firebase/ 12 | 13 | # Firebase config 14 | 15 | # Uncomment this if you'd like others to create their own Firebase project. 16 | # For a team working on the same Firebase project(s), it is recommended to leave 17 | # it commented so all members can deploy to the same project(s) in .firebaserc. 18 | # .firebaserc 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (http://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | -------------------------------------------------------------------------------- /cloud/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": [ 3 | { 4 | "source": "functions", 5 | "codebase": "default", 6 | "ignore": [ 7 | "node_modules", 8 | ".git", 9 | "firebase-debug.log", 10 | "firebase-debug.*.log" 11 | ], 12 | "predeploy": [ 13 | "npm --prefix \"$RESOURCE_DIR\" run lint", 14 | "npm --prefix \"$RESOURCE_DIR\" run build" 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /cloud/functions/.env.example: -------------------------------------------------------------------------------- 1 | # Open AI Configuration 2 | OPENAI_ORGANIZATION= 3 | OPENAI_API_KEY= -------------------------------------------------------------------------------- /cloud/functions/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled JavaScript files 2 | lib/**/*.js 3 | lib/**/*.js.map 4 | 5 | # TypeScript v1 declaration files 6 | typings/ 7 | 8 | # Node.js dependency directory 9 | node_modules/ 10 | -------------------------------------------------------------------------------- /cloud/functions/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "insertPragma": false, 7 | "singleAttributePerLine": false, 8 | "bracketSameLine": false, 9 | "jsxSingleQuote": false, 10 | "printWidth": 80, 11 | "proseWrap": "preserve", 12 | "quoteProps": "as-needed", 13 | "requirePragma": false, 14 | "semi": true, 15 | "singleQuote": false, 16 | "tabWidth": 2, 17 | "trailingComma": "es5", 18 | "useTabs": false, 19 | "vueIndentScriptAndStyle": false 20 | } 21 | -------------------------------------------------------------------------------- /cloud/functions/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import pluginJs from "@eslint/js"; 2 | import google from "eslint-config-google"; 3 | import * as importPlugin from "eslint-plugin-import"; 4 | import pluginPrettier from "eslint-plugin-prettier/recommended"; 5 | import globals from "globals"; 6 | import tseslint from "typescript-eslint"; 7 | 8 | // Temporary workaround for jsdoc rules 9 | delete google.rules["require-jsdoc"]; 10 | delete google.rules["valid-jsdoc"]; 11 | 12 | /** @type {import('eslint').Linter.Config[]} */ 13 | export default [ 14 | { files: ["**/*.{js,ts}"] }, 15 | { languageOptions: { globals: globals.node } }, 16 | pluginJs.configs.recommended, 17 | importPlugin.flatConfigs?.errors, 18 | importPlugin.flatConfigs?.warnings, 19 | importPlugin.flatConfigs?.typescript, 20 | google, 21 | ...tseslint.configs.recommended, 22 | pluginPrettier, 23 | { 24 | rules: { 25 | "@typescript-eslint/consistent-type-imports": "error", 26 | "@typescript-eslint/no-import-type-side-effects": "error", 27 | quotes: ["error", "double"], 28 | "import/no-unresolved": 0, 29 | indent: ["error", 2], 30 | "object-curly-spacing": ["error", "always"], 31 | "prettier/prettier": ["error"], 32 | }, 33 | }, 34 | { 35 | ignores: ["lib/**/*", "node_modules/"], 36 | }, 37 | ]; 38 | -------------------------------------------------------------------------------- /cloud/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "version": "1.7.90", 4 | "scripts": { 5 | "lint": "eslint .", 6 | "build": "tsc", 7 | "build:watch": "tsc --watch", 8 | "serve": "npm run build && firebase emulators:start --only functions", 9 | "shell": "npm run build && firebase functions:shell", 10 | "start": "npm run shell", 11 | "deploy": "firebase deploy --only functions", 12 | "logs": "firebase functions:log" 13 | }, 14 | "engines": { 15 | "node": "22" 16 | }, 17 | "main": "lib/index.js", 18 | "dependencies": { 19 | "@google-cloud/aiplatform": "4.2.0", 20 | "@google-cloud/storage": "7.16.0", 21 | "@google-cloud/vertexai": "1.10.0", 22 | "axios": "1.9.0", 23 | "firebase-admin": "13.4.0", 24 | "firebase-functions": "6.3.2", 25 | "openai": "5.2.0" 26 | }, 27 | "devDependencies": { 28 | "eslint": "9.28.0", 29 | "eslint-config-google": "0.14.0", 30 | "eslint-config-prettier": "10.1.5", 31 | "eslint-plugin-import": "2.31.0", 32 | "eslint-plugin-prettier": "5.4.1", 33 | "prettier": "3.5.3", 34 | "typescript": "5.8.3", 35 | "typescript-eslint": "8.34.0" 36 | }, 37 | "private": true 38 | } 39 | -------------------------------------------------------------------------------- /cloud/functions/src/constants.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalOptions } from "firebase-functions/v2"; 2 | 3 | export const PROJECT_NAME = "ai-image-generator"; 4 | export const IMAGE_FOLDER_NAME = "images"; 5 | 6 | export const GLOBAL_OPTIONS: GlobalOptions = { 7 | maxInstances: 5, 8 | region: "asia-southeast1", 9 | }; 10 | 11 | export const IMAGEN_MODELS = [ 12 | { id: "imagegeneration@006", location: "asia-southeast1" }, 13 | { id: "imagen-3.0-fast-generate-001", location: "asia-southeast1" }, 14 | { id: "imagen-3.0-generate-002", location: "asia-southeast1" }, 15 | { id: "imagen-4.0-generate-preview-05-20", location: "us-central1" }, 16 | { id: "imagen-4.0-ultra-generate-exp-05-20", location: "us-central1" }, 17 | ] as const; 18 | -------------------------------------------------------------------------------- /cloud/functions/src/handlers/generateImage.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { randomUUID } from "crypto"; 3 | import { logger as log } from "firebase-functions/v2"; 4 | import { onCall } from "firebase-functions/v2/https"; 5 | import { GLOBAL_OPTIONS, IMAGE_FOLDER_NAME, PROJECT_NAME } from "../constants"; 6 | import OpenAIService from "../lib/openai"; 7 | import { storeImage } from "../lib/storeImage"; 8 | import VertexAIService from "../lib/vertexai"; 9 | import type { ImageMeta } from "../types"; 10 | 11 | type RequestData = { 12 | prompt: string; 13 | metadata: ImageMeta; 14 | }; 15 | 16 | interface PredictionError extends Error { 17 | code: number; 18 | details: string; 19 | metadata: object; 20 | } 21 | 22 | const RESPONSIBLE_AI_ERR = 23 | "Image generation failed with the following error: The prompt could not be submitted. This prompt contains sensitive words that violate Google's Responsible AI practices. Try rephrasing the prompt. If you think this was an error, send feedback."; 24 | const RESPONSIBLE_AI_MSG = 25 | "This prompt contains sensitive words that violate Google's Responsible AI practices. Try rephrasing the prompt."; 26 | 27 | export const generateImage = onCall( 28 | { ...GLOBAL_OPTIONS, timeoutSeconds: 540, memory: "4GiB" }, 29 | async (request) => { 30 | try { 31 | const { prompt: rawPrompt, metadata } = request.data; 32 | const prompt = rawPrompt.replace(/(\r\n|\n|\r)/gm, ""); 33 | log.info({ prompt, model: metadata.model, location: metadata.geo?.city }); 34 | 35 | // DALL·E is replaced by Imagen due to billing changes 36 | const { imgBuffer, model } = await generateWithImagen( 37 | prompt, 38 | metadata.model 39 | ); 40 | 41 | const timeStamp = new Date().getTime(); 42 | const fileName = `${timeStamp}-${randomUUID()}.png`; 43 | await storeImage(PROJECT_NAME, IMAGE_FOLDER_NAME, imgBuffer, fileName, { 44 | ...metadata, 45 | model, 46 | prompt, 47 | }); 48 | log.info("Image stored!"); 49 | 50 | return "File uploaded successfully!"; 51 | } catch (err) { 52 | log.error(err); 53 | if (err instanceof Error) { 54 | if ((err as PredictionError).details === RESPONSIBLE_AI_ERR) { 55 | return { error: { message: RESPONSIBLE_AI_MSG } }; 56 | } 57 | return { error: { message: err.message } }; 58 | } else if (typeof err === "string") { 59 | return { error: { message: err } }; 60 | } else { 61 | return { error: { message: "Oops! Something went wrong!" } }; 62 | } 63 | } 64 | } 65 | ); 66 | 67 | /** 68 | * Generate image with DALL·E 2.0 69 | * @param {string} prompt Prompt 70 | * @return {Buffer} Buffer of the generated image 71 | */ 72 | export async function generateWithDalle(prompt: string) { 73 | const openai = new OpenAIService(); 74 | const resp = await openai.images.generate({ 75 | prompt, 76 | n: 1, 77 | size: "1024x1024", 78 | }); 79 | log.info("Image generated!"); 80 | 81 | const imageUrl = resp.data?.[0].url ?? ""; 82 | const res = await axios.get(imageUrl, { 83 | responseType: "arraybuffer", 84 | }); 85 | const arrayBuffer = res.data; 86 | log.info("Image file fetched!"); 87 | 88 | return arrayBuffer; 89 | } 90 | 91 | /** 92 | * Generate image with Google Imagen 93 | * @param {string} prompt Prompt 94 | * @param {string} modelId Imagen model resource ID 95 | * @return {Buffer} Buffer of the generated image 96 | */ 97 | export async function generateWithImagen(prompt: string, modelId: string) { 98 | const vertexai = new VertexAIService(); 99 | const resp = await vertexai.imagen({ prompt, modelId }); 100 | if (!resp) throw new Error("Image generation failed"); 101 | log.info("Image generated!"); 102 | 103 | const imgBuffer = Buffer.from(resp.bytes, "base64"); 104 | log.info("Image file fetched!"); 105 | 106 | return { imgBuffer, model: resp.model }; 107 | } 108 | -------------------------------------------------------------------------------- /cloud/functions/src/handlers/getImage.ts: -------------------------------------------------------------------------------- 1 | import { logger as log } from "firebase-functions/v2"; 2 | import { type HttpsOptions, onCall } from "firebase-functions/v2/https"; 3 | import { GLOBAL_OPTIONS, IMAGE_FOLDER_NAME, PROJECT_NAME } from "../constants"; 4 | import { storage } from "../lib/firebase"; 5 | import { prepareImage } from "../utils/prepareImage"; 6 | 7 | const OPTIONS: HttpsOptions = { 8 | ...GLOBAL_OPTIONS, 9 | memory: "512MiB", 10 | }; 11 | 12 | export const getImage = onCall(OPTIONS, async ({ data }) => { 13 | try { 14 | const path = data.path; 15 | const bucket = storage.bucket(); 16 | const [file] = await bucket 17 | .file(`${PROJECT_NAME}/${IMAGE_FOLDER_NAME}/${path}`) 18 | .get(); 19 | log.info("All images retrieved."); 20 | return prepareImage(bucket)(file); 21 | } catch (err) { 22 | log.error(err); 23 | return err; 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /cloud/functions/src/handlers/getImages.ts: -------------------------------------------------------------------------------- 1 | import type { File } from "@google-cloud/storage"; 2 | import { logger as log } from "firebase-functions/v2"; 3 | import { onCall, type HttpsOptions } from "firebase-functions/v2/https"; 4 | import { GLOBAL_OPTIONS, IMAGE_FOLDER_NAME, PROJECT_NAME } from "../constants"; 5 | import { storage } from "../lib/firebase"; 6 | import { prepareImage } from "../utils/prepareImage"; 7 | 8 | const sortByTimeCreated = (a: File, b: File) => { 9 | const dateA = new Date(a.metadata.timeCreated!); 10 | const dateB = new Date(b.metadata.timeCreated!); 11 | return dateB.getTime() - dateA.getTime(); // descending 12 | }; 13 | 14 | const OPTIONS: HttpsOptions = { 15 | ...GLOBAL_OPTIONS, 16 | memory: "512MiB", 17 | }; 18 | 19 | export const getImages = onCall(OPTIONS, async () => { 20 | try { 21 | const bucket = storage.bucket(); 22 | const [files] = await bucket.getFiles({ 23 | prefix: `${PROJECT_NAME}/${IMAGE_FOLDER_NAME}`, 24 | }); 25 | log.info("All images retrieved."); 26 | 27 | const sorted = files.sort(sortByTimeCreated); 28 | const imageUrls = sorted.slice(0, 27).map(prepareImage(bucket)); 29 | const favorites = sorted 30 | .filter((x) => x.metadata.metadata?.favorite === "true") 31 | .slice(0, 27) 32 | .map(prepareImage(bucket)); 33 | return { imageUrls, favorites }; 34 | } catch (err) { 35 | log.error(err); 36 | return err; 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /cloud/functions/src/handlers/getPromptSuggestion.ts: -------------------------------------------------------------------------------- 1 | import { logger as log } from "firebase-functions/v2"; 2 | import { onCall, type HttpsOptions } from "firebase-functions/v2/https"; 3 | import { GLOBAL_OPTIONS } from "../constants"; 4 | import GeminiService from "../lib/gemini"; 5 | import type { Model } from "../types"; 6 | 7 | const ALLOWED_MODELS: Model[] = [ 8 | { id: "gemini-1.5-flash", location: "asia-southeast1" }, 9 | { id: "gemini-2.0-flash-lite", location: "us-central1" }, 10 | ]; 11 | 12 | const OPTIONS: HttpsOptions = { 13 | ...GLOBAL_OPTIONS, 14 | memory: "512MiB", 15 | }; 16 | 17 | export const getPromptSuggestion = onCall(OPTIONS, async ({ data }) => { 18 | try { 19 | const model = ALLOWED_MODELS.find(({ id }) => id === data.provider); 20 | 21 | let responseText = ""; 22 | if (model) { 23 | const gemini = new GeminiService(model); 24 | responseText = await gemini.suggestion(); 25 | } else { 26 | log.error("Invalid model", model); 27 | return { error: true, payload: "Invalid model" }; 28 | } 29 | return { error: false, payload: responseText }; 30 | } catch (err) { 31 | log.error(err); 32 | return { error: true, payload: err }; 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /cloud/functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import { generateImage } from "./handlers/generateImage"; 2 | import { getImage } from "./handlers/getImage"; 3 | import { getImages } from "./handlers/getImages"; 4 | import { getPromptSuggestion } from "./handlers/getPromptSuggestion"; 5 | 6 | exports.generateImage = generateImage; 7 | exports.getImage = getImage; 8 | exports.getImages = getImages; 9 | exports.getPromptSuggestion = getPromptSuggestion; 10 | -------------------------------------------------------------------------------- /cloud/functions/src/lib/firebase.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp } from "firebase-admin/app"; 2 | import { getStorage } from "firebase-admin/storage"; 3 | 4 | const app = initializeApp(); 5 | export const storage = getStorage(app); 6 | -------------------------------------------------------------------------------- /cloud/functions/src/lib/gemini.ts: -------------------------------------------------------------------------------- 1 | import { 2 | VertexAI, 3 | type GenerativeModel, 4 | type Part, 5 | } from "@google-cloud/vertexai"; 6 | import type { Model } from "../types"; 7 | 8 | /** Google Cloud Gemini API Service */ 9 | export default class GeminiService { 10 | private client: GenerativeModel; 11 | private project = process.env.GCLOUD_PROJECT; 12 | 13 | /** Initialize Gemini API Service */ 14 | constructor(model: Model) { 15 | const vertexAI = new VertexAI({ 16 | project: this.project!, 17 | location: model.location, 18 | }); 19 | this.client = vertexAI.getGenerativeModel({ 20 | model: model.id, 21 | }); 22 | } 23 | 24 | /** Get prompt suggestion */ 25 | async suggestion() { 26 | const context = 27 | "You are going to chat with DALL·E, an AI that generates images from text prompts. Always start the prompt with a capital letter."; 28 | const example = 29 | "For example, if you are asked to 'Write a random text prompt under 50 words for DALL·E to generate an image, this prompt will be shown to the user, include details such as the genre and what type of painting it should be, options can include: oil painting, watercolor, photo-realistic, 4k, abstract, modern, black and white, etc.', the response could be 'Create a modern, oil painting of a futuristic city skyline at night, with a high-tech transportation system and neon lights illuminating the bustling streets below'"; 30 | const q = 31 | "Now, please write one sentence of a random text prompt under 50 words for DALL·E to generate an image, this prompt will be shown to the user, include details such as the genre and what type of painting it should be, options can include: oil painting, watercolor, photo-realistic, 4k, abstract, modern, black and white, etc."; 32 | const textPart: Part = { 33 | text: [context, example, q].join(" "), 34 | }; 35 | 36 | const responseStream = await this.client.generateContentStream({ 37 | contents: [{ role: "user", parts: [textPart] }], 38 | generationConfig: { 39 | maxOutputTokens: 256, 40 | temperature: 1.6, 41 | topK: 40, 42 | topP: 0.95, 43 | }, 44 | }); 45 | 46 | // Wait for the response stream to complete 47 | const aggResp = await responseStream.response; 48 | const prediction = aggResp.candidates?.[0].content.parts[0].text; 49 | return prediction ?? ""; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /cloud/functions/src/lib/openai.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | 3 | /** OpenAI Service */ 4 | export default class OpenAIService { 5 | private openai: OpenAI; 6 | 7 | /** Initialize OpenAI Service */ 8 | constructor() { 9 | this.openai = new OpenAI({ 10 | organization: process.env.OPENAI_ORGANIZATION, 11 | apiKey: process.env.OPENAI_API_KEY, 12 | }); 13 | } 14 | 15 | /** Get OpenAI completions service */ 16 | get completions() { 17 | return this.openai.completions; 18 | } 19 | 20 | /** Get OpenAI images service */ 21 | get images() { 22 | return this.openai.images; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cloud/functions/src/lib/storeImage.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "crypto"; 2 | import type { GCSImageMeta, ImageMeta } from "../types"; 3 | import { storage } from "./firebase"; 4 | 5 | /** 6 | * Stores image into Firebase storage and returns path. 7 | * @param {string} projectName - Name of the project. 8 | * @param {string} folderName - Name of the bucket. 9 | * @param {Buffer} file - Image file in Buffer format. 10 | * @param {string} fileName - File name. 11 | * @param {ImageMeta} metadata - File name. 12 | * @return {Promise} - File path or download url of stored image. 13 | */ 14 | export async function storeImage( 15 | projectName: string, 16 | folderName: string, 17 | file: Buffer, 18 | fileName: string, 19 | metadata: ImageMeta 20 | ): Promise { 21 | const filePath = `${projectName}/${folderName}/${fileName}`; 22 | const uuid = randomUUID(); 23 | 24 | const imageMeta: GCSImageMeta = { 25 | firebaseStorageDownloadTokens: uuid, 26 | geo: JSON.stringify(metadata.geo), 27 | ip: metadata.ip, 28 | model: metadata.model, 29 | prompt: metadata.prompt, 30 | }; 31 | 32 | const bucket = storage.bucket(); 33 | await bucket.file(filePath).save(file, { 34 | resumable: false, 35 | metadata: { metadata: imageMeta }, 36 | }); 37 | return filePath; 38 | } 39 | 40 | /** 41 | * Create a persistent download URL for the given file path 42 | * @param {string} bucketName Name of the bucket 43 | * @param {string} pathToFile Path to the file to be accessed 44 | * @param {string} downloadToken The Firebase Storage Download Tokens 45 | * @return {string} A URL. 46 | */ 47 | export function createPersistentDownloadUrl( 48 | bucketName: string, 49 | pathToFile: string, 50 | downloadToken: string 51 | ): string { 52 | return `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodeURIComponent( 53 | pathToFile 54 | )}?alt=media&token=${downloadToken}`; 55 | } 56 | -------------------------------------------------------------------------------- /cloud/functions/src/lib/vertexai.ts: -------------------------------------------------------------------------------- 1 | import { PredictionServiceClient, helpers } from "@google-cloud/aiplatform"; 2 | import { IMAGEN_MODELS } from "../constants"; 3 | 4 | type ImgPredictionResult = { 5 | mimeType: "image/png"; 6 | bytesBase64Encoded: string; 7 | }; 8 | 9 | /** Google Cloud Vertex AI Service */ 10 | export default class VertexAIService { 11 | private client: PredictionServiceClient; 12 | private project = process.env.GCLOUD_PROJECT; 13 | private location = "asia-southeast1"; 14 | private publisher = "google"; 15 | private defaultImagen = IMAGEN_MODELS[0]; 16 | 17 | /** Initialize Vertex AI Service */ 18 | constructor() { 19 | this.client = new PredictionServiceClient({ 20 | apiEndpoint: `${this.location}-aiplatform.googleapis.com`, 21 | }); 22 | } 23 | 24 | /** Generate image */ 25 | async imagen({ prompt, modelId }: { prompt: string; modelId: string }) { 26 | // Validate Imagen model resource ID 27 | const model = 28 | IMAGEN_MODELS.find((m) => m.id === modelId) ?? this.defaultImagen; 29 | 30 | if (model.location !== this.location) { 31 | this.client = new PredictionServiceClient({ 32 | apiEndpoint: `${model.location}-aiplatform.googleapis.com`, 33 | }); 34 | } 35 | 36 | // Generate image 37 | const [response] = await this.client.predict({ 38 | endpoint: `projects/${this.project}/locations/${model.location}/publishers/${this.publisher}/models/${model.id}`, 39 | instances: [helpers.toValue({ prompt })!], 40 | parameters: helpers.toValue({ 41 | sampleCount: 1, 42 | }), 43 | }); 44 | if (!response.predictions?.[0]) return ""; 45 | const result = helpers.fromValue( 46 | response.predictions[0] as protobuf.common.IValue 47 | ) as ImgPredictionResult; 48 | return { 49 | bytes: result.bytesBase64Encoded ?? "", 50 | model: model.id, 51 | }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /cloud/functions/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { UUID } from "crypto"; 2 | 3 | export type ImageMeta = { 4 | geo?: { 5 | city?: string; 6 | country?: string; 7 | region?: string; 8 | latitude?: string; 9 | longitude?: string; 10 | }; 11 | ip?: string; 12 | model: string; 13 | prompt: string; 14 | }; 15 | 16 | export type GCSImageMeta = { 17 | firebaseStorageDownloadTokens?: UUID; 18 | geo?: string; 19 | ip?: string; 20 | model?: string; 21 | prompt?: string; 22 | }; 23 | 24 | export type Model = { 25 | id: string; 26 | location: string; 27 | }; 28 | -------------------------------------------------------------------------------- /cloud/functions/src/utils/prepareImage.ts: -------------------------------------------------------------------------------- 1 | import type { Bucket, File } from "@google-cloud/storage"; 2 | import { IMAGE_FOLDER_NAME, PROJECT_NAME } from "../constants"; 3 | import { createPersistentDownloadUrl } from "../lib/storeImage"; 4 | import type { GCSImageMeta } from "../types"; 5 | 6 | export const prepareImage = (bucket: Bucket) => (x: File) => { 7 | const fileMeta: GCSImageMeta | undefined = x.metadata.metadata; 8 | const geo = fileMeta?.geo ? JSON.parse(fileMeta.geo) : undefined; 9 | 10 | const unixCreatedAt = parseInt( 11 | x.name.split("/")?.pop()?.split("-")?.[0] ?? "" 12 | ); 13 | return { 14 | url: createPersistentDownloadUrl( 15 | bucket.name, 16 | x.name, 17 | fileMeta?.firebaseStorageDownloadTokens ?? "" 18 | ), 19 | name: x.name.replace(`${PROJECT_NAME}/${IMAGE_FOLDER_NAME}/`, ""), 20 | metadata: { 21 | createdAt: new Date(unixCreatedAt).toISOString(), 22 | geo: { 23 | city: geo?.city, 24 | country: geo?.country, 25 | }, 26 | model: fileMeta?.model ?? "imagegeneration@006", 27 | prompt: fileMeta?.prompt ?? "", 28 | }, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /cloud/functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es2017" 10 | }, 11 | "compileOnSave": true, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /iac/.gitignore: -------------------------------------------------------------------------------- 1 | # Local .terraform directories 2 | .terraform 3 | 4 | # Ignore the lock file 5 | .terraform.lock.hcl 6 | 7 | # .tfstate files 8 | *.tfstate 9 | *.tfstate.* 10 | 11 | # .tfvars files 12 | *.tfvars 13 | -------------------------------------------------------------------------------- /iac/main.tf: -------------------------------------------------------------------------------- 1 | resource "google_apphub_application" "ai_image_generator" { 2 | application_id = "ai-image-generator" 3 | description = "AI Image Generator application" 4 | display_name = "AI Image Generator" 5 | location = var.region 6 | 7 | attributes { 8 | criticality { type = "MISSION_CRITICAL" } 9 | environment { type = "PRODUCTION" } 10 | } 11 | 12 | scope { 13 | type = "REGIONAL" 14 | } 15 | } 16 | 17 | locals { 18 | cloud_functions = { 19 | "generateImage" = "Generate Image" 20 | "getImage" = "Get Image" 21 | "getImages" = "Get Images" 22 | "getPromptSuggestion" = "Get Prompt Suggestion" 23 | } 24 | } 25 | 26 | data "google_cloudfunctions2_function" "functions" { 27 | for_each = local.cloud_functions 28 | name = each.key 29 | location = var.region 30 | } 31 | 32 | data "google_cloud_run_service" "services" { 33 | for_each = local.cloud_functions 34 | name = lower(each.key) 35 | location = var.region 36 | } 37 | 38 | data "google_apphub_discovered_service" "gcf_services" { 39 | for_each = local.cloud_functions 40 | location = var.region 41 | service_uri = "//cloudfunctions.googleapis.com/projects/${var.project_id}/locations/${var.region}/functions/${data.google_cloudfunctions2_function.functions[each.key].name}" 42 | } 43 | 44 | data "google_apphub_discovered_service" "gcr_services" { 45 | for_each = local.cloud_functions 46 | location = var.region 47 | service_uri = "//run.googleapis.com/projects/${var.project_id}/locations/${var.region}/services/${data.google_cloud_run_service.services[each.key].name}" 48 | } 49 | 50 | resource "google_apphub_service" "gcf_services" { 51 | for_each = local.cloud_functions 52 | application_id = google_apphub_application.ai_image_generator.application_id 53 | discovered_service = data.google_apphub_discovered_service.gcf_services[each.key].name 54 | display_name = "Cloud Functions - ${each.value}" 55 | location = var.region 56 | service_id = "gcf-${lower(each.key)}" 57 | 58 | attributes { 59 | criticality { type = "MISSION_CRITICAL" } 60 | environment { type = "PRODUCTION" } 61 | } 62 | } 63 | 64 | resource "google_apphub_service" "gcr_services" { 65 | for_each = local.cloud_functions 66 | application_id = google_apphub_application.ai_image_generator.application_id 67 | discovered_service = data.google_apphub_discovered_service.gcr_services[each.key].name 68 | display_name = "Cloud Run - ${each.value}" 69 | location = var.region 70 | service_id = "gcr-${lower(each.key)}" 71 | 72 | attributes { 73 | criticality { type = "MISSION_CRITICAL" } 74 | environment { type = "PRODUCTION" } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /iac/provider.tf: -------------------------------------------------------------------------------- 1 | provider "google" { 2 | project = var.project_id 3 | region = var.region 4 | } 5 | 6 | terraform { 7 | required_providers { 8 | google = { 9 | source = "hashicorp/google" 10 | version = ">=6.34.0" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /iac/variables.tf: -------------------------------------------------------------------------------- 1 | variable "project_id" { 2 | description = "The ID of the project in which to provision resources." 3 | type = string 4 | } 5 | 6 | variable "region" { 7 | description = "The region in which to provision resources." 8 | type = string 9 | default = "asia-southeast1" 10 | } 11 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const securityHeaders = [ 4 | { 5 | key: "Strict-Transport-Security", 6 | value: "max-age=63072000; includeSubDomains; preload", 7 | }, 8 | ]; 9 | 10 | const nextConfig: NextConfig = { 11 | images: { 12 | remotePatterns: [ 13 | { hostname: "firebasestorage.googleapis.com", protocol: "https" }, 14 | ], 15 | }, 16 | async headers() { 17 | return [ 18 | { 19 | source: "/api/getImages", 20 | headers: [{ key: "Cache-Control", value: "no-store, max-age=0" }], 21 | }, 22 | { 23 | source: "/:path*", 24 | headers: securityHeaders, 25 | }, 26 | ]; 27 | }, 28 | }; 29 | 30 | export default nextConfig; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-image-generator", 3 | "version": "1.8.0", 4 | "packageManager": "yarn@1.22.22", 5 | "private": true, 6 | "scripts": { 7 | "dev": "next dev --turbopack", 8 | "build": "next build --turbopack", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "format": "prettier . --check --ignore-unknown", 12 | "format:write": "prettier . --write --ignore-unknown" 13 | }, 14 | "dependencies": { 15 | "@bprogress/next": "3.2.12", 16 | "@trpc/client": "11.1.2", 17 | "@trpc/server": "11.1.2", 18 | "@vercel/functions": "2.1.0", 19 | "clsx": "2.1.1", 20 | "firebase": "11.9.0", 21 | "javascript-time-ago": "2.5.11", 22 | "next": "15.3.3", 23 | "next-auth": "5.0.0-beta.28", 24 | "react": "19.1.0", 25 | "react-circle-flags": "0.0.23", 26 | "react-dom": "19.1.0", 27 | "react-hot-toast": "2.5.2", 28 | "react-icons": "5.5.0", 29 | "sharp": "0.34.2", 30 | "swr": "2.3.3", 31 | "tailwind-merge": "3.3.0", 32 | "zod": "3.25.56" 33 | }, 34 | "devDependencies": { 35 | "@tailwindcss/postcss": "4.1.8", 36 | "@types/node": "22.15.30", 37 | "@types/react": "19.1.6", 38 | "@types/react-dom": "19.1.6", 39 | "@typescript-eslint/eslint-plugin": "8.34.0", 40 | "autoprefixer": "10.4.21", 41 | "eslint": "9.28.0", 42 | "eslint-config-next": "15.3.3", 43 | "postcss": "8.5.4", 44 | "prettier": "3.5.3", 45 | "prettier-plugin-tailwindcss": "0.6.12", 46 | "tailwindcss": "4.1.8", 47 | "tailwindcss-debug-screens": "3.0.1", 48 | "typescript": "5.8.3" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | module.exports = { 3 | trailingComma: "es5", 4 | plugins: ["prettier-plugin-tailwindcss"], 5 | }; 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KenTandrian/ai-image-generator/088317b7a39d93f31e26a1851d5578da709d3f20/public/favicon.ico -------------------------------------------------------------------------------- /public/openai-b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KenTandrian/ai-image-generator/088317b7a39d93f31e26a1851d5578da709d3f20/public/openai-b.png -------------------------------------------------------------------------------- /public/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KenTandrian/ai-image-generator/088317b7a39d93f31e26a1851d5578da709d3f20/public/placeholder.jpg -------------------------------------------------------------------------------- /src/app/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { findModel } from "@/data/imagen-models"; 2 | import { callableFn } from "@/services/firebase"; 3 | import type { ImageType } from "@/types"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | import { CircleFlag } from "react-circle-flags"; 7 | import { FcGoogle } from "react-icons/fc"; 8 | import { LuChevronLeft } from "react-icons/lu"; 9 | 10 | async function fetchData(path: string) { 11 | const fn = callableFn<{ path: string }, ImageType>("getImage"); 12 | const resp = await fn({ path }); 13 | return resp.data; 14 | } 15 | 16 | type PageProps = { 17 | params: Promise<{ id: string }>; 18 | }; 19 | 20 | export default async function Page({ params }: PageProps) { 21 | const { id } = await params; 22 | const imgName = decodeURIComponent(id); 23 | 24 | // Fetch image data 25 | const image = await fetchData(imgName); 26 | const model = findModel(image.metadata.model); 27 | const Logo = model?.logo ?? FcGoogle; 28 | 29 | return ( 30 |
31 |
32 |
33 | {image.name} 43 |
44 |
45 |
46 | 50 | 51 | Back 52 | 53 |
54 |
55 |

56 | Prompt 57 |

58 |

59 | {image.metadata.prompt} 60 |

61 |
62 |
63 |

64 | Image Generator 65 |

66 |

67 | 68 | {model?.vendor} {model?.name} 69 |

70 |
71 |
72 |

73 | Created at 74 |

75 |

76 | {Intl.DateTimeFormat("en-US", { 77 | dateStyle: "long", 78 | timeStyle: "medium", 79 | }).format(new Date(image.metadata.createdAt))} 80 |

81 |
82 |
83 |

84 | Location 85 |

86 |

87 | {image.metadata.geo?.country && ( 88 | 92 | )} 93 | {image.metadata.geo 94 | ? `${image.metadata.geo?.city}, ${image.metadata.geo?.country}` 95 | : "Unknown"} 96 |

97 |
98 |
99 |
100 |
101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/app/api/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import type { NextRequest } from "next/server"; 3 | 4 | import { appRouter } from "@/server"; 5 | import { createContext } from "@/server/trpc"; 6 | 7 | const handler = (req: NextRequest) => 8 | fetchRequestHandler({ 9 | endpoint: "/api", 10 | req, 11 | router: appRouter, 12 | createContext, 13 | }); 14 | 15 | export const runtime = "edge"; 16 | export { handler as GET, handler as POST }; 17 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/utils/auth"; 2 | 3 | export const runtime = "edge"; 4 | export const { GET, POST } = handlers; 5 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import ClientProvider from "@/components/ClientProvider"; 2 | import Footer from "@/components/Footer"; 3 | import Header from "@/components/Header"; 4 | import { auth } from "@/utils/auth"; 5 | import { cn } from "@/utils/classname"; 6 | import { Plus_Jakarta_Sans } from "next/font/google"; 7 | 8 | import "@/styles/globals.css"; 9 | 10 | export const metadata = { 11 | title: "AI Art Gallery", 12 | description: 13 | "AI image generator powered by Imagen, Vertex AI & Google Cloud Functions", 14 | }; 15 | 16 | const pjs = Plus_Jakarta_Sans({ 17 | subsets: ["latin"], 18 | variable: "--font-pjs", 19 | display: "swap", 20 | }); 21 | 22 | export default async function RootLayout({ 23 | children, 24 | }: { 25 | children: React.ReactNode; 26 | }) { 27 | const session = await auth(); 28 | return ( 29 | 38 | 39 | 40 |
41 | {children} 42 |