├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── api │ └── webhook │ │ └── route.ts ├── favicon.ico ├── layout.tsx ├── opengraph-image.png ├── page.tsx ├── sitemap.ts └── t │ └── [id] │ ├── opengraph-image.tsx │ └── page.tsx ├── bun.lockb ├── components ├── form-rsc.tsx ├── form.tsx ├── generated-count.tsx ├── icons │ ├── buymeacoffee.tsx │ ├── github.tsx │ ├── index.tsx │ ├── loading-circle.tsx │ └── twitter.tsx ├── pattern-picker.tsx ├── photo-booth.tsx └── popover.tsx ├── lib ├── actions.ts ├── constants.ts ├── hooks │ ├── use-enter-submit.ts │ └── use-media-query.ts └── utils.ts ├── next.config.js ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── logo.png ├── next.svg └── vercel.svg ├── styles ├── ClashDisplay-Semibold.otf └── globals.css ├── tailwind.config.ts └── tsconfig.json /.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= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Spirals – Generate beautiful AI spiral art with one click. 3 |

Spirals

4 |
5 | 6 |

7 | Generate beautiful AI spiral art with one click. Powered by Vercel and Replicate. 8 |

9 | 10 |

11 | 12 | Steven Tey Twitter follower count 13 | 14 | 15 | Spirals repo star count 16 | 17 |

18 | 19 |

20 | Introduction · 21 | Tech Stack · 22 | Deploy Your Own · 23 | Author 24 |

25 |
26 | 27 | ## Introduction 28 | 29 | Spirals is an AI app for you to generate beautiful spiral art with one click. Powered by Vercel and Replicate. 30 | 31 | https://github.com/steven-tey/spirals/assets/28986134/9f0202d4-2a31-47a0-b43f-bdcd189743ef 32 | 33 | ## Tech Stack 34 | 35 | - Next.js [App Router](https://nextjs.org/docs/app) 36 | - Next.js [Server Actions](https://nextjs.org/docs/app/api-reference/functions/server-actions) 37 | - [Bun](https://bun.sh/) for compilation 38 | - [Vercel Blob](https://vercel.com/storage/blob) for image storage 39 | - [Vercel KV](https://vercel.com/storage/kv) for redis 40 | - [`promptmaker`](https://github.com/zeke/promptmaker) lib by @zeke for generating random prompts 41 | 42 | ## Deploy Your Own 43 | 44 | You can deploy this template to Vercel with the button below: 45 | 46 | [![Deploy with Vercel](https://vercel.com/button)](https://stey.me/spirals-deploy) 47 | 48 | Note that you'll need to: 49 | 50 | - Set up a [Replicate](https://replicate.com) account to get the `REPLICATE_API_TOKEN` env var. 51 | - Set up [Vercel KV](https://vercel.com/docs/storage/vercel-kv/quickstart) to get the 52 | - Set up [Vercel Blob](https://vercel.com/docs/storage/vercel-blob/quickstart) 53 | 54 | ## Author 55 | 56 | - Steven Tey ([@steventey](https://twitter.com/steventey)) 57 | -------------------------------------------------------------------------------- /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" }); 30 | 31 | await kv.hset(id, { image: url }); 32 | 33 | return NextResponse.json({ ok: true }); 34 | } 35 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steven-tey/spirals/098ab7a2c2f0de76a128e24502d11501d8539e74/app/favicon.ico -------------------------------------------------------------------------------- /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, BuyMeACoffee } 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: "Spirals", 24 | description: 25 | "Generate beautiful AI spiral art with one click. Powered by Vercel and Replicate.", 26 | metadataBase: new URL("https://spirals.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 |
47 |
48 | 49 | Logo image of a chat bubble 57 |

Spirals

58 | 59 |
60 | 66 | 72 | 79 | 80 |

Deploy to Vercel

81 |
82 | 88 | 96 | 97 | 98 | 99 | 104 | 105 | 106 |
107 |
108 |
109 |
110 | {children} 111 |
112 |
113 |

114 | A project by{" "} 115 | 121 | Steven Tey 122 | 123 |

124 | 130 | 131 |

Buy me a coffee

132 |
133 |
134 | 135 | 136 | 137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steven-tey/spirals/098ab7a2c2f0de76a128e24502d11501d8539e74/app/opengraph-image.png -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import FormRSC from "@/components/form-rsc"; 2 | 3 | export default function Home() { 4 | return ( 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /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://spirals.vercel.app", 23 | lastModified: new Date().toISOString(), 24 | }, 25 | ...ids.map((id) => ({ 26 | url: `https://spirals.vercel.app/t/${id}`, 27 | lastModified: new Date().toISOString(), 28 | })), 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 = `Spirals: ${data.prompt}`; 19 | const description = `A spiral generated from the prompt: ${data.prompt}`; 20 | 21 | return { 22 | title, 23 | description, 24 | openGraph: { 25 | title, 26 | description, 27 | }, 28 | twitter: { 29 | card: "summary_large_image", 30 | title, 31 | description, 32 | creator: "@steventey", 33 | }, 34 | }; 35 | } 36 | 37 | export default async function Results({ 38 | params, 39 | }: { 40 | params: { 41 | id: string; 42 | }; 43 | }) { 44 | const data = await kv.hgetall<{ 45 | prompt: string; 46 | pattern?: string; 47 | image?: string; 48 | }>(params.id); 49 | 50 | if (!data) { 51 | notFound(); 52 | } 53 | return ( 54 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steven-tey/spirals/098ab7a2c2f0de76a128e24502d11501d8539e74/bun.lockb -------------------------------------------------------------------------------- /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 | }: { 12 | prompt?: string; 13 | pattern?: string; 14 | image: string | null; 15 | }) { 16 | return ( 17 |
18 | 24 | 25 |

26 | Introducing Spirals 27 |

28 |
29 |

33 | Spirals 34 |

35 |

39 | Generate beautiful AI spiral art with one click. Powered by{" "} 40 | 46 | Vercel 47 | {" "} 48 | and{" "} 49 | 55 | Replicate 56 | 57 | . 58 |

59 |
60 | }> 61 | 62 | 63 | 64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /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 | 107 | 108 | 116 |