├── .eslintrc.json ├── app ├── favicon.ico ├── opengraph-image.png ├── page.tsx ├── api │ ├── upload │ │ └── route.ts │ └── webhook │ │ └── route.ts ├── sitemap.ts ├── t │ └── [id] │ │ ├── page.tsx │ │ └── opengraph-image.tsx ├── gallery │ └── page.tsx └── layout.tsx ├── public ├── logo.png ├── old_2.png ├── github.png └── old_logo.png ├── styles ├── globals.css └── ClashDisplay-Semibold.otf ├── postcss.config.js ├── prettier.config.js ├── components ├── icons │ ├── index.tsx │ ├── twitter.tsx │ ├── github.tsx │ ├── loading-circle.tsx │ └── buymeacoffee.tsx ├── generated-count.tsx ├── pattern-picker.tsx ├── popover.tsx ├── form-rsc.tsx ├── photo-booth.tsx └── form.tsx ├── lib ├── constants.ts ├── hooks │ ├── use-enter-submit.ts │ └── use-media-query.ts ├── utils.ts └── actions.ts ├── .env.example ├── .gitignore ├── tsconfig.json ├── next.config.js ├── package.json ├── README.md ├── tailwind.config.ts └── pnpm-lock.yaml /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrrikkotua/octoart/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrrikkotua/octoart/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/old_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrrikkotua/octoart/HEAD/public/old_2.png -------------------------------------------------------------------------------- /public/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrrikkotua/octoart/HEAD/public/github.png -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /public/old_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrrikkotua/octoart/HEAD/public/old_logo.png -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrrikkotua/octoart/HEAD/app/opengraph-image.png -------------------------------------------------------------------------------- /styles/ClashDisplay-Semibold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrrikkotua/octoart/HEAD/styles/ClashDisplay-Semibold.otf -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // prettier.config.js 2 | module.exports = { 3 | bracketSpacing: true, 4 | semi: true, 5 | trailingComma: "all", 6 | printWidth: 80, 7 | tabWidth: 2, 8 | plugins: ["prettier-plugin-tailwindcss"], 9 | }; 10 | -------------------------------------------------------------------------------- /components/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as Github } from "./github"; 2 | export { default as Twitter } from "./twitter"; 3 | export { default as LoadingCircle } from "./loading-circle"; 4 | export { default as BuyMeACoffee } from "./buymeacoffee"; 5 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import FormRSC from "@/components/form-rsc"; 2 | 3 | export default function Home() { 4 | return ( 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const WEBHOOK_URL = 2 | process.env.NEXT_PUBLIC_VERCEL_ENV === "production" 3 | ? "https://octoart.vercel.app/api/webhook" 4 | : process.env.NEXT_PUBLIC_VERCEL_ENV === "preview" 5 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}/api/webhook` 6 | : `${process.env.NGROK_URL}/api/webhook`; 7 | 8 | export const DEFAULT_PATTERN = 9 | "https://w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com/kk49gtk-XpDOG1662fNHYhVAQu8q7qEFema4S9.png"; 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ## Set up Replicate: https://replicate.com 2 | REPLICATE_API_TOKEN= 3 | ## (optional, but recommended) Generate a random secret for the webhook: https://generate-secret.vercel.app/32 4 | REPLICATE_WEBHOOK_SECRET= 5 | 6 | ## Set up Vercel Blob: https://vercel.com/docs/storage/vercel-blob/quickstart 7 | BLOB_READ_WRITE_TOKEN= 8 | 9 | ## Set up Vercel KV: https://vercel.com/docs/storage/vercel-kv/quickstart 10 | KV_URL= 11 | KV_REST_API_URL= 12 | KV_REST_API_TOKEN= 13 | KV_REST_API_READ_ONLY_TOKEN= -------------------------------------------------------------------------------- /.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 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /components/generated-count.tsx: -------------------------------------------------------------------------------- 1 | import { nFormatter } from "@/lib/utils"; 2 | import { kv } from "@vercel/kv"; 3 | 4 | export async function GeneratedCount() { 5 | const count = await kv.dbsize(); 6 | return ; 7 | } 8 | 9 | export const CountDisplay = ({ count }: { count?: number }) => { 10 | return ( 11 |

15 | {count ? nFormatter(count) : "..."} photos generated and counting! 16 |

17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 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 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | serverActions: true, 5 | }, 6 | images: { 7 | domains: [ 8 | "w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com", 9 | "xd2kcvzsdpeyx1gu.public.blob.vercel-storage.com", 10 | "replicate.delivery", 11 | ], 12 | }, 13 | async redirects() { 14 | return [ 15 | { 16 | source: "/github", 17 | destination: "https://github.com/garrrikkotua/github-illusion", 18 | permanent: false, 19 | }, 20 | 21 | { 22 | source: "/t", 23 | destination: "/", 24 | permanent: false, 25 | }, 26 | ]; 27 | }, 28 | }; 29 | 30 | module.exports = nextConfig; 31 | -------------------------------------------------------------------------------- /app/api/upload/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { put } from "@vercel/blob"; 3 | import { kv } from "@vercel/kv"; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import { nanoid } from "@/lib/utils"; 7 | 8 | export async function POST(req: Request) { 9 | const body = await req.json(); 10 | const { filePath } = body; 11 | 12 | if (!filePath) { 13 | return new Response("Missing filepath", { status: 400 }); 14 | } 15 | const id = nanoid(); 16 | 17 | // read file from local filesystem 18 | const file = fs.readFileSync(path.resolve(filePath)); 19 | 20 | // upload & store in Vercel Blob 21 | const { url } = await put(`${id}.png`, file, { access: "public" }); 22 | 23 | await kv.hset(id, { image: url }); 24 | 25 | return NextResponse.json({ ok: true, id, url }); 26 | } 27 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { kv } from "@vercel/kv"; 2 | import { MetadataRoute } from "next"; 3 | 4 | export default async function sitemap(): Promise { 5 | const ids: string[] = []; 6 | let cursor = 0; 7 | do { 8 | const [nextCursor, keys] = await kv.scan(cursor, { 9 | match: "*", 10 | count: 1000, 11 | }); 12 | cursor = nextCursor; 13 | ids.push(...keys); 14 | 15 | // recommended max sitemap size is 50,000 URLs 16 | } while (cursor !== 0 && ids.length < 50000); 17 | 18 | console.log(ids.length); 19 | 20 | return [ 21 | { 22 | url: "https://octoart.vercel.app", 23 | lastModified: new Date().toISOString(), 24 | }, 25 | ...ids.map((id) => ({ 26 | url: `https://octoart.vercel.app/t/${id}`, 27 | lastModified: new Date().toISOString(), 28 | })), 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /lib/hooks/use-enter-submit.ts: -------------------------------------------------------------------------------- 1 | import { useRef, type RefObject } from "react"; 2 | 3 | export default function useEnterSubmit(): { 4 | formRef: RefObject; 5 | onKeyDown: (event: React.KeyboardEvent) => void; 6 | } { 7 | const formRef = useRef(null); 8 | 9 | const handleKeyDown = ( 10 | event: React.KeyboardEvent, 11 | ): void => { 12 | const value = event.currentTarget.value.trim(); 13 | if ( 14 | event.key === "Enter" && 15 | !event.shiftKey && 16 | !event.nativeEvent.isComposing 17 | ) { 18 | if (value.length === 0) { 19 | event.preventDefault(); 20 | return; 21 | } 22 | formRef.current?.requestSubmit(); 23 | event.preventDefault(); 24 | } 25 | }; 26 | 27 | return { formRef, onKeyDown: handleKeyDown }; 28 | } 29 | -------------------------------------------------------------------------------- /components/icons/twitter.tsx: -------------------------------------------------------------------------------- 1 | export default function Twitter({ className }: { className?: string }) { 2 | return ( 3 | 8 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/icons/github.tsx: -------------------------------------------------------------------------------- 1 | export default function Github({ className }: { className?: string }) { 2 | return ( 3 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | import { customAlphabet } from "nanoid"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | // 7-character random string 10 | export const nanoid = customAlphabet( 11 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 12 | 7, 13 | ); 14 | 15 | export function nFormatter(num: number, digits?: number) { 16 | if (!num) return "0"; 17 | const lookup = [ 18 | { value: 1, symbol: "" }, 19 | { value: 1e3, symbol: "K" }, 20 | { value: 1e6, symbol: "M" }, 21 | { value: 1e9, symbol: "G" }, 22 | { value: 1e12, symbol: "T" }, 23 | { value: 1e15, symbol: "P" }, 24 | { value: 1e18, symbol: "E" }, 25 | ]; 26 | const rx = /\.0+$|(\.[0-9]*[1-9])0+$/; 27 | var item = lookup 28 | .slice() 29 | .reverse() 30 | .find(function (item) { 31 | return num >= item.value; 32 | }); 33 | return item 34 | ? (num / item.value).toFixed(digits || 1).replace(rx, "$1") + item.symbol 35 | : "0"; 36 | } 37 | -------------------------------------------------------------------------------- /app/api/webhook/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { put } from "@vercel/blob"; 3 | import { kv } from "@vercel/kv"; 4 | 5 | export async function POST(req: Request) { 6 | const searchParams = new URL(req.url).searchParams; 7 | const id = searchParams.get("id") as string; 8 | 9 | if (process.env.REPLICATE_WEBHOOK_SECRET) { 10 | // if a secret is set, verify it 11 | const secret = searchParams.get("secret") as string; 12 | if (secret !== process.env.REPLICATE_WEBHOOK_SECRET) { 13 | return new Response("Invalid secret", { status: 401 }); 14 | } 15 | } 16 | 17 | // get output from Replicate 18 | const body = await req.json(); 19 | const { output } = body; 20 | 21 | if (!output) { 22 | return new Response("Missing output", { status: 400 }); 23 | } 24 | 25 | // convert output to a blob object 26 | const file = await fetch(output[0]).then((res) => res.blob()); 27 | 28 | // upload & store in Vercel Blob 29 | const { url } = await put(`${id}.png`, file, { access: "public", cacheControlMaxAge: 60 * 60 * 24 * 30 }); 30 | 31 | await kv.hset(id, { image: url }); 32 | 33 | return NextResponse.json({ ok: true }); 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "octoart", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "format:write": "prettier --write \"**/*.{css,js,json,jsx,ts,tsx}\"", 11 | "format": "prettier \"**/*.{css,js,json,jsx,ts,tsx}\"" 12 | }, 13 | "dependencies": { 14 | "@radix-ui/react-popover": "^1.0.6", 15 | "@types/node": "20.6.2", 16 | "@types/react": "18.2.22", 17 | "@types/react-dom": "18.2.7", 18 | "@vercel/analytics": "^1.0.2", 19 | "@vercel/blob": "^0.12.4", 20 | "@vercel/kv": "^0.2.3", 21 | "autoprefixer": "10.4.15", 22 | "clsx": "^2.0.0", 23 | "eslint": "8.49.0", 24 | "eslint-config-next": "13.4.19", 25 | "lucide-react": "^0.279.0", 26 | "nanoid": "^5.0.1", 27 | "next": "13.5.4-canary.8", 28 | "postcss": "8.4.30", 29 | "promptmaker": "^1.1.0", 30 | "react": "18.2.0", 31 | "react-dom": "18.2.0", 32 | "replicate": "^0.18.0", 33 | "sonner": "^1.0.3", 34 | "tailwind-merge": "^1.14.0", 35 | "tailwindcss": "3.3.3", 36 | "typescript": "5.2.2", 37 | "vaul": "^0.6.5" 38 | }, 39 | "devDependencies": { 40 | "prettier": "^3.0.3", 41 | "prettier-plugin-tailwindcss": "^0.5.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/hooks/use-media-query.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export default function useMediaQuery() { 4 | const [device, setDevice] = useState<"mobile" | "tablet" | "desktop" | null>( 5 | null, 6 | ); 7 | const [dimensions, setDimensions] = useState<{ 8 | width: number; 9 | height: number; 10 | } | null>(null); 11 | 12 | useEffect(() => { 13 | const checkDevice = () => { 14 | if (window.matchMedia("(max-width: 640px)").matches) { 15 | setDevice("mobile"); 16 | } else if ( 17 | window.matchMedia("(min-width: 641px) and (max-width: 1024px)").matches 18 | ) { 19 | setDevice("tablet"); 20 | } else { 21 | setDevice("desktop"); 22 | } 23 | setDimensions({ width: window.innerWidth, height: window.innerHeight }); 24 | }; 25 | 26 | // Initial detection 27 | checkDevice(); 28 | 29 | // Listener for windows resize 30 | window.addEventListener("resize", checkDevice); 31 | 32 | // Cleanup listener 33 | return () => { 34 | window.removeEventListener("resize", checkDevice); 35 | }; 36 | }, []); 37 | 38 | return { 39 | device, 40 | width: dimensions?.width, 41 | height: dimensions?.height, 42 | isMobile: device === "mobile", 43 | isTablet: device === "tablet", 44 | isDesktop: device === "desktop", 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /components/icons/loading-circle.tsx: -------------------------------------------------------------------------------- 1 | export default function LoadingCircle() { 2 | return ( 3 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/t/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { kv } from "@vercel/kv"; 2 | import { notFound } from "next/navigation"; 3 | import FormRSC from "@/components/form-rsc"; 4 | import { Metadata } from "next"; 5 | 6 | export async function generateMetadata({ 7 | params, 8 | }: { 9 | params: { 10 | id: string; 11 | }; 12 | }): Promise { 13 | const data = await kv.hgetall<{ prompt: string; image?: string }>(params.id); 14 | if (!data) { 15 | return; 16 | } 17 | 18 | const title = `OctoArt: ${data.prompt}`; 19 | const description = `An octo art generated from the prompt: ${data.prompt}`; 20 | const image = data.image || "https://octoart.vercel.app/opengraph-image.png"; 21 | 22 | return { 23 | title, 24 | description, 25 | openGraph: { 26 | title, 27 | description, 28 | }, 29 | twitter: { 30 | card: "summary_large_image", 31 | title, 32 | description, 33 | creator: "@garrrikkotua", 34 | }, 35 | }; 36 | } 37 | 38 | export default async function Results({ 39 | params, 40 | }: { 41 | params: { 42 | id: string; 43 | }; 44 | }) { 45 | const data = await kv.hgetall<{ 46 | prompt: string; 47 | pattern?: string; 48 | image?: string; 49 | }>(params.id); 50 | 51 | if (!data) { 52 | notFound(); 53 | } 54 | return ( 55 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /components/pattern-picker.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Dispatch, SetStateAction, useState } from "react"; 3 | 4 | const patterns = [ 5 | "https://w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com/kk49gtk-XpDOG1662fNHYhVAQu8q7qEFema4S9.png", 6 | ]; 7 | 8 | export default function PatternPicker({ 9 | setPattern, 10 | setOpenPopover, 11 | }: { 12 | setPattern: Dispatch>; 13 | setOpenPopover: Dispatch>; 14 | }) { 15 | const [dragActive, setDragActive] = useState(false); 16 | 17 | return ( 18 |
19 |
20 |

21 | Choose a pattern 22 |

23 |
24 | {patterns.map((p) => ( 25 | 42 | ))} 43 |
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /lib/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import Replicate from "replicate"; 4 | import { kv } from "@vercel/kv"; 5 | import { nanoid } from "./utils"; 6 | import { DEFAULT_PATTERN, WEBHOOK_URL } from "./constants"; 7 | import { put } from "@vercel/blob"; 8 | 9 | const replicate = new Replicate({ 10 | auth: process.env.REPLICATE_API_TOKEN as string, 11 | }); 12 | 13 | export async function generate(form: FormData) { 14 | const prompt = form.get("prompt") as string; 15 | let patternUrl = form.get("patternUrl") as string; 16 | const patternFile = form.get("patternFile") as File; 17 | if (patternFile.size > 0) { 18 | const response = await put(patternFile.name, patternFile, { 19 | access: "public", 20 | }); 21 | patternUrl = response.url; 22 | } 23 | 24 | const id = nanoid(); 25 | 26 | const res = await Promise.all([ 27 | kv.hset(id, { 28 | prompt, 29 | ...(patternUrl && { pattern: patternUrl }), 30 | }), 31 | replicate.predictions.create({ 32 | version: 33 | "75d51a73fce3c00de31ed9ab4358c73e8fc0f627dc8ce975818e653317cb919b", 34 | input: { 35 | prompt, 36 | qr_code_content: "https://octoart.vercel.app", 37 | image: patternUrl, 38 | controlnet_conditioning_scale: 1, 39 | qrcode_background: "white", 40 | }, 41 | webhook: `${WEBHOOK_URL}?id=${id}${ 42 | process.env.REPLICATE_WEBHOOK_SECRET 43 | ? `&secret=${process.env.REPLICATE_WEBHOOK_SECRET}` 44 | : "" 45 | }`, 46 | webhook_events_filter: ["completed"], 47 | }), 48 | ]); 49 | 50 | console.log(res); 51 | 52 | return id; 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | OctoArt – Generate beautiful GitHub octocat art with one click. Promote open-source! 3 |

OctoArt

4 |
5 | 6 |

7 | Generate beautiful GitHub octocat art with one click. Promote open-source! 8 |

9 | 10 |

11 | 12 | Igor Kotua Twitter follower count 13 | 14 | 15 | OctoArt repo star count 16 | 17 |

18 | 19 |

20 | Introduction · 21 | Tech Stack · 22 | Author · 23 | Credits 24 |

25 |
26 | 27 | ## Introduction 28 | 29 | OctoArt is an AI app that allows to generate GitHub octocat art with one click. Promote open-source! More patterns coming soon 30 | 31 | ## Tech Stack 32 | 33 | - Next.js [App Router](https://nextjs.org/docs/app) 34 | - Next.js [Server Actions](https://nextjs.org/docs/app/api-reference/functions/server-actions) 35 | - [Vercel Blob](https://vercel.com/storage/blob) for image storage 36 | - [Vercel KV](https://vercel.com/storage/kv) for redis 37 | - [`promptmaker`](https://github.com/zeke/promptmaker) lib by @zeke for generating random prompts 38 | 39 | ## Author 40 | 41 | - Igor Kotua ([@garrrikkotua](https://twitter.com/garrrikkotua)) 42 | 43 | ## Credits 44 | 45 | Basically all code in this repo is taken from Steven Tey's Spirals [repo](https://github.com/steven-tey/spirals) 46 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | fontFamily: { 12 | display: ["var(--font-clash)"], 13 | default: ["var(--font-inter)", "system-ui", "sans-serif"], 14 | }, 15 | animation: { 16 | // Modal 17 | "scale-in": "scale-in 0.2s cubic-bezier(0.16, 1, 0.3, 1)", 18 | "fade-in": "fade-in 0.3s ease-out forwards", 19 | // Fade up and down 20 | "fade-up": "fade-up 0.5s", 21 | "fade-down": "fade-down 0.5s", 22 | // Tooltip 23 | "slide-up-fade": "slide-up-fade 0.4s cubic-bezier(0.16, 1, 0.3, 1)", 24 | }, 25 | keyframes: { 26 | // Modal 27 | "scale-in": { 28 | "0%": { transform: "scale(0.95)" }, 29 | "100%": { transform: "scale(1)" }, 30 | }, 31 | "fade-in": { 32 | "0%": { opacity: "0" }, 33 | "100%": { opacity: "1" }, 34 | }, 35 | // Fade up and down 36 | "fade-up": { 37 | "0%": { 38 | opacity: "0", 39 | transform: "translateY(10px)", 40 | }, 41 | "80%": { 42 | opacity: "0.6", 43 | }, 44 | "100%": { 45 | opacity: "1", 46 | transform: "translateY(0px)", 47 | }, 48 | }, 49 | "fade-down": { 50 | "0%": { 51 | opacity: "0", 52 | transform: "translateY(-10px)", 53 | }, 54 | "80%": { 55 | opacity: "0.6", 56 | }, 57 | "100%": { 58 | opacity: "1", 59 | transform: "translateY(0px)", 60 | }, 61 | }, 62 | // Tooltip 63 | "slide-up-fade": { 64 | "0%": { opacity: "0", transform: "translateY(2px)" }, 65 | "100%": { opacity: "1", transform: "translateY(0)" }, 66 | }, 67 | }, 68 | }, 69 | }, 70 | plugins: [], 71 | }; 72 | export default config; 73 | -------------------------------------------------------------------------------- /components/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Dispatch, ReactNode, SetStateAction } from "react"; 4 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 5 | import { Drawer } from "vaul"; 6 | import useMediaQuery from "@/lib/hooks/use-media-query"; 7 | 8 | export default function Popover({ 9 | children, 10 | content, 11 | align = "center", 12 | openPopover, 13 | setOpenPopover, 14 | }: { 15 | children: ReactNode; 16 | content: ReactNode | string; 17 | align?: "center" | "start" | "end"; 18 | openPopover: boolean; 19 | setOpenPopover: Dispatch>; 20 | }) { 21 | const { isMobile } = useMediaQuery(); 22 | 23 | if (isMobile) { 24 | return ( 25 | 26 |
{children}
27 | 28 | 29 | 30 |
31 |
32 |
33 |
34 | {content} 35 |
36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | return ( 44 | 45 | 46 | {children} 47 | 48 | 49 | 54 | {content} 55 | 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /app/gallery/page.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import PhotoBooth from "@/components/photo-booth"; 3 | import Link from "next/link"; 4 | 5 | const ids = [ 6 | "UkVGEPQ", 7 | "2FAL1k5", 8 | "4ROSAnh", 9 | "5NVCbBV", 10 | "NsGwYpR", 11 | "QX1fpOE", 12 | "RHKy5cj", 13 | "RYIP03P", 14 | "SmNX3WQ", 15 | "X1eRouH", 16 | "YYj3ZnK", 17 | "ay9gmAa", 18 | "pnDVeK7", 19 | "s05xRGB", 20 | "vABtm1p", 21 | "DvcH4ln", 22 | "5aRlgYl", 23 | "tVeBZZP", 24 | ]; 25 | 26 | const images: Record = { 27 | UkVGEPQ: 28 | "https://w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com/UkVGEPQ-ZgE1nHu1YhfrC6TiazD377mBAkhHEm.png", 29 | "2FAL1k5": 30 | "https://w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com/2FAL1k5-rPsCor2JCyu0Ak3lK0iqD6SxMhFJZh.png", 31 | "4ROSAnh": 32 | "https://w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com/4ROSAnh-LeeHNcVQRdKXJYb330l3ZuhZfXEhhL.png", 33 | "5NVCbBV": 34 | "https://w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com/5NVCbBV-oKx6XllPrTGbWlJ88RlZhZNZ9RiFaG.png", 35 | NsGwYpR: 36 | "https://w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com/NsGwYpR-PDOfJAyFOhZcQ0mQHeDFWJScEOVvbX.png", 37 | QX1fpOE: 38 | "https://w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com/QX1fpOE-BQvMmy88KOkCDcikpJCUgNe7b6HSyc.png", 39 | RHKy5cj: 40 | "https://w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com/RHKy5cj-SFsaiDVYvbjlcnoiNn8LOUeJgRlmJG.png", 41 | RYIP03P: 42 | "https://w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com/RYIP03P-RzxjN8HzCBkPPPMvFkTIYVIpsGSh7c.png", 43 | s05xRGB: 44 | "https://w3hp0wwfpdgpzwdt.public.blob.vercel-storage.com/s05xRGB-wrsS1Rzc4GY7pTc6j4lWdO5Dfm3CTJ.png", 45 | }; 46 | 47 | export default async function Gallery() { 48 | return ( 49 |
50 |

54 | Gallery 55 |

56 |
57 | {Object.keys(images).map((key) => ( 58 | 59 | 60 | 61 | ))} 62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /components/form-rsc.tsx: -------------------------------------------------------------------------------- 1 | import Form from "@/components/form"; 2 | import { Twitter } from "@/components/icons"; 3 | import PhotoBooth from "@/components/photo-booth"; 4 | import { CountDisplay, GeneratedCount } from "./generated-count"; 5 | import { Suspense } from "react"; 6 | 7 | export default function FormRSC({ 8 | prompt, 9 | pattern, 10 | image, 11 | newsletter, 12 | }: { 13 | prompt?: string; 14 | pattern?: string; 15 | image: string | null; 16 | newsletter?: boolean; 17 | }) { 18 | return ( 19 |
20 | {newsletter ? ( 21 | 25 |

26 | 🚀 Generate AI picture with your logo - check out LogoPicture AI 27 |

28 |
29 | ) : ( 30 | 36 |

37 | 🚀 Generate AI picture with your logo - check out LogoPicture AI ↣ 38 |

39 |
40 | )} 41 | 42 |

46 | OctoArt 47 |

48 |

52 | Generate beautiful GitHub octocat art with one click. Promote 53 | open-source! 54 |

55 |
56 | }> 57 | 58 | 59 | 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import type { Metadata } from "next"; 3 | import localFont from "next/font/local"; 4 | import { Inter } from "next/font/google"; 5 | import Image from "next/image"; 6 | import Link from "next/link"; 7 | import { cn } from "@/lib/utils"; 8 | import { Github } from "@/components/icons"; 9 | import { Analytics } from "@vercel/analytics/react"; 10 | import { Toaster } from "sonner"; 11 | 12 | const clash = localFont({ 13 | src: "../styles/ClashDisplay-Semibold.otf", 14 | variable: "--font-clash", 15 | }); 16 | 17 | const inter = Inter({ 18 | variable: "--font-inter", 19 | subsets: ["latin"], 20 | }); 21 | 22 | export const metadata: Metadata = { 23 | title: "OctoArt", 24 | description: 25 | "Generate beautiful GitHub octocat art with one click. Promote open-source!", 26 | metadataBase: new URL("https://octoart.vercel.app"), 27 | }; 28 | 29 | export default function RootLayout({ 30 | children, 31 | }: { 32 | children: React.ReactNode; 33 | }) { 34 | const scrolled = false; 35 | return ( 36 | 37 | 38 | 39 |
40 | 41 |
48 |
49 | 53 | Logo image of OctoArt 60 |

OctoArt

61 | 62 |
63 | 67 | Gallery 68 | 69 | 74 | 75 | 76 |
77 |
78 |
79 |
80 | {children} 81 |
82 |
83 |

84 | A project by{" "} 85 | 91 | Igor Kotua 92 | 93 |

94 |

95 | Subscribe to my{" "} 96 | 102 | newsletter 103 | 104 |

105 |
106 | 107 | 108 | 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /app/t/[id]/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "next/server"; 2 | import { notFound } from "next/navigation"; 3 | import { kv } from "@vercel/kv"; 4 | 5 | export default async function DynamicOG({ 6 | params, 7 | }: { 8 | params: { id: string }; 9 | }) { 10 | const data = await kv.hgetall<{ prompt: string; image?: string }>(params.id); 11 | if (!data) { 12 | return notFound(); 13 | } 14 | 15 | const [inter] = await Promise.all([ 16 | fetch( 17 | "https://github.com/rsms/inter/raw/master/docs/font-files/Inter-Regular.woff", 18 | ).then((res) => res.arrayBuffer()), 19 | ]); 20 | 21 | return new ImageResponse( 22 | ( 23 |
35 |
39 |
40 | {data.prompt.substring(0, 72)} 41 | {data.prompt.length > 72 && "..."} 42 |
43 |
44 | 51 | 56 | 86 | 87 | 88 |
89 |
90 |
91 | ), 92 | { 93 | width: 1200, 94 | height: 630, 95 | fonts: [ 96 | { 97 | name: "Inter", 98 | data: inter, 99 | }, 100 | ], 101 | headers: { 102 | "cache-control": "public, max-age=60, stale-while-revalidate=86400", 103 | }, 104 | }, 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /components/photo-booth.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Copy, Download } from "lucide-react"; 4 | import Image from "next/image"; 5 | import { useEffect, useState } from "react"; 6 | import { LoadingCircle } from "@/components/icons"; 7 | import { useParams, useRouter } from "next/navigation"; 8 | import va from "@vercel/analytics"; 9 | 10 | function forceDownload(blobUrl: string, filename: string) { 11 | let a: any = document.createElement("a"); 12 | a.download = filename; 13 | a.href = blobUrl; 14 | document.body.appendChild(a); 15 | a.click(); 16 | a.remove(); 17 | } 18 | 19 | export default function PhotoBooth({ image }: { image: string | null }) { 20 | const router = useRouter(); 21 | const params = useParams(); 22 | const { id } = params; 23 | const [copying, setCopying] = useState(false); 24 | const [downloading, setDownloading] = useState(false); 25 | 26 | useEffect(() => { 27 | let interval: NodeJS.Timeout; 28 | 29 | if (!image) { 30 | interval = setInterval(() => { 31 | router.refresh(); 32 | }, 1000); 33 | } 34 | 35 | return () => clearInterval(interval); 36 | }, [image, router]); 37 | 38 | return ( 39 |
43 | {id && image && ( 44 |
45 | 78 | 107 |
108 | )} 109 | {image ? ( 110 | output image 117 | ) : ( 118 |
119 | 120 | {id && ( 121 |
125 |

126 | This can take anywhere between 20s-30s to run. 127 |

128 |
129 | )} 130 |
131 | )} 132 |
133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /components/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { generate } from "@/lib/actions"; 4 | import useEnterSubmit from "@/lib/hooks/use-enter-submit"; 5 | import { SendHorizonal } from "lucide-react"; 6 | import { useEffect, useRef, useState } from "react"; 7 | import { experimental_useFormStatus as useFormStatus } from "react-dom"; 8 | import { LoadingCircle } from "./icons"; 9 | import { cn } from "@/lib/utils"; 10 | import { useRouter } from "next/navigation"; 11 | import va from "@vercel/analytics"; 12 | // @ts-ignore 13 | import promptmaker from "promptmaker"; 14 | import Image from "next/image"; 15 | import Popover from "./popover"; 16 | import { DEFAULT_PATTERN } from "@/lib/constants"; 17 | import PatternPicker from "./pattern-picker"; 18 | import { toast } from "sonner"; 19 | 20 | export default function Form({ 21 | promptValue, 22 | patternValue, 23 | }: { 24 | promptValue?: string; 25 | patternValue?: string; 26 | }) { 27 | const router = useRouter(); 28 | const [prompt, setPrompt] = useState(promptValue || ""); 29 | const [placeholderPrompt, setPlaceholderPrompt] = useState(""); 30 | useEffect(() => { 31 | if (promptValue) { 32 | setPlaceholderPrompt(""); 33 | } else { 34 | setPlaceholderPrompt(promptmaker()); 35 | } 36 | }, [promptValue]); 37 | 38 | const { formRef, onKeyDown } = useEnterSubmit(); 39 | 40 | const textareaRef = useRef(null); 41 | useEffect(() => { 42 | if (promptValue && textareaRef.current) { 43 | textareaRef.current.select(); 44 | } 45 | }, [promptValue]); 46 | 47 | const [pattern, setPattern] = useState(patternValue || DEFAULT_PATTERN); 48 | const [openPopover, setOpenPopover] = useState(false); 49 | 50 | const onChangePicture = (e: React.ChangeEvent) => { 51 | const file = e.target.files && e.target.files[0]; 52 | if (file) { 53 | if (file.size / 1024 / 1024 > 5) { 54 | toast.error("File size too big (max 5MB)"); 55 | } else if (file.type !== "image/png" && file.type !== "image/jpeg") { 56 | toast.error("File type not supported (.png or .jpg only)"); 57 | } else { 58 | const reader = new FileReader(); 59 | reader.onload = (e) => { 60 | setPattern(e.target?.result as string); 61 | setOpenPopover(false); 62 | }; 63 | reader.readAsDataURL(file); 64 | } 65 | } 66 | }; 67 | 68 | return ( 69 | { 74 | va.track("generate prompt", { 75 | prompt: prompt, 76 | }); 77 | generate(data).then((id) => { 78 | router.push(`/t/${id}`); 79 | }); 80 | }} 81 | > 82 | 83 | 89 | } 90 | openPopover={openPopover} 91 | setOpenPopover={setOpenPopover} 92 | > 93 | 106 | 107 | 115 |