├── .env.example
├── .eslintrc.json
├── .github
└── images
│ └── preview.gif
├── .gitignore
├── .nvmrc
├── .prettierrc
├── README.md
├── bun.lockb
├── next.config.js
├── package.json
├── postcss.config.js
├── public
├── desktop-background.png
├── mobile-background.png
└── painting-frame.png
├── sentry.client.config.ts
├── sentry.edge.config.ts
├── sentry.server.config.ts
├── src
├── app
│ ├── (image-generation)
│ │ ├── actions.ts
│ │ └── generate-image
│ │ │ ├── _components
│ │ │ ├── CopyButton.tsx
│ │ │ ├── DownloadButton.tsx
│ │ │ ├── GenerateImageForm.tsx
│ │ │ ├── ImageResult.tsx
│ │ │ └── PromptForm.tsx
│ │ │ ├── page.tsx
│ │ │ └── result
│ │ │ └── [id]
│ │ │ └── page.tsx
│ ├── (landing-page)
│ │ ├── _components
│ │ │ └── AuthButton.tsx
│ │ ├── callback
│ │ │ └── route.ts
│ │ └── page.tsx
│ ├── api
│ │ └── webhook
│ │ │ └── route.ts
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── lib
│ │ ├── auth.ts
│ │ ├── className.ts
│ │ ├── envSchema.ts
│ │ ├── fonts.ts
│ │ ├── hooks
│ │ │ ├── useEnterSubmit.ts
│ │ │ ├── useMediaQuery.ts
│ │ │ ├── usePrefersReducedMotion.ts
│ │ │ └── useRandomInterval.ts
│ │ ├── nanoid.ts
│ │ ├── numbers.ts
│ │ └── rateLimiter.ts
│ ├── sitemap.ts
│ └── ui
│ │ ├── Button
│ │ ├── index.tsx
│ │ └── styles.module.css
│ │ ├── Icons
│ │ ├── ArrowLeftIcon.tsx
│ │ ├── BuyMeACoffeeIcon.tsx
│ │ ├── CopyIcon.tsx
│ │ ├── DownloadIcon.tsx
│ │ ├── LoadingIcon.tsx
│ │ ├── SendIcon.tsx
│ │ ├── SparkleIcon.tsx
│ │ └── UploadIcon.tsx
│ │ └── Sparkles
│ │ ├── index.tsx
│ │ └── styles.module.css
└── middleware.ts
├── tailwind.config.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | # Authentication
2 | WORKOS_API_KEY=
3 | WORKOS_CLIENT_ID=
4 | WORKOS_REDIRECT_URI=
5 | JWT_SECRET_KEY=
6 |
7 | # Replicate
8 | REPLICATE_API_TOKEN=
9 | REPLICATE_WEBHOOK_SECRET=
10 |
11 | # Vercel's KV - Redis database
12 | KV_REST_API_READ_ONLY_TOKEN=
13 | KV_REST_API_TOKEN=
14 | KV_REST_API_URL=
15 | KV_URL=
16 |
17 | # Vercel's Blob - For static assets storage
18 | BLOB_READ_WRITE_TOKEN=
19 |
20 | # Ngrok tunnel to receive Replicate's webhook while developing locally
21 | NGROK_URL=
22 |
23 | # Sentry DSN for error reporting
24 | NEXT_PUBLIC_SENTRY_DSN=
25 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["import"],
3 | "rules": {
4 | "import/order": [
5 | "error",
6 | {
7 | "groups": [
8 | ["builtin", "external"],
9 | ["internal", "parent", "sibling", "index"]
10 | ],
11 | "newlines-between": "always",
12 | "alphabetize": {
13 | "order": "asc",
14 | "caseInsensitive": true
15 | }
16 | }
17 | ]
18 | },
19 | "extends": "next/core-web-vitals"
20 | }
21 |
--------------------------------------------------------------------------------
/.github/images/preview.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LauraBeatris/starry/ba77a7ac2996e143ad0afa2fc4253f9686dcfb6d/.github/images/preview.gif
--------------------------------------------------------------------------------
/.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 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 | # Sentry Config File
38 | .sentryclirc
39 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 18.17.0
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "quoteProps": "consistent",
3 | "singleQuote": true,
4 | "trailingComma": "all",
5 | "plugins": ["prettier-plugin-tailwindcss"]
6 | }
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Starry 💫
5 |
6 |
7 |
8 |
9 | Turn your ideas into Van Gogh's Starry Night
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | ## How it works
22 |
23 | This application leverages an AI model, specifically [Stable Diffusion](https://replicate.com/stability-ai/stable-diffusion) hosted on Replicate, to transform your images into art inspired by Van Gogh's Starry Night. Simply enter a prompt, and it will be processed through this AI model via a Next.js server action and an API route that listens to Replicate's webhook.
24 |
25 | ## 🎥 Walkthrough videos
26 |
27 |
28 |
29 | - [Video explanation on how I built this application]()
30 | - [Launch demo video](https://x.com/lauradotjs/status/1754970380547117314?s=20)
31 |
32 | ## Powered by
33 |
34 | - [Bun](https://bun.sh/) for compilation
35 | - [Replicate](https://replicate.ai/) for AI API
36 | - [Vercel](https://vercel.com)
37 | - Next.js [App Router](https://nextjs.org/docs/app)
38 | - Next.js [Server Actions](https://nextjs.org/docs/app/api-reference/functions/server-actions)
39 | - [Vercel Blob](https://vercel.com/storage/blob) for image storage
40 | - [Vercel KV](https://vercel.com/storage/kv) for redis
41 | - [WorkOS](https://workos.com/)
42 | - [AuthKit](https://authkit.com/) for user management
43 |
44 | ## How to run on your own machine
45 |
46 | ### 1. Install [Bun](https://bun.sh/) for compilation
47 |
48 | ### 2. Install [ngrok](https://ngrok.com/) to listen to Replicate's webhook events while developing locally
49 |
50 | ### 2. Cloning the repository
51 |
52 | ```bash
53 | git clone
54 | ```
55 |
56 | ### 3. Sign up for the services mentioned in the [Powered by](#powered-by) section
57 |
58 | ### 4. Storing API keys in .env file.
59 |
60 | Copy the `.env.example` file and update with your own API keys.
61 |
62 | ### 5. Installing the dependencies.
63 |
64 | ```bash
65 | bun install
66 | ```
67 |
68 | ### 6. Running the application.
69 |
70 | Then, run the application in the command line and it will be available at `http://localhost:3000`.
71 |
72 | ```bash
73 | bun run dev
74 | ```
75 |
76 | Also, run `ngrok http 3000` to create a tunnel to listen to Replicate's webhook events.
77 |
78 | ## Author
79 |
80 | - Laura Beatris ([@lauradotjs](https://twitter.com/lauradotjs))
81 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LauraBeatris/starry/ba77a7ac2996e143ad0afa2fc4253f9686dcfb6d/bun.lockb
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: 'https',
7 | hostname: 'jxgoqlyxc3jqoq07.public.blob.vercel-storage.com',
8 | port: '',
9 | },
10 | ],
11 | },
12 | async redirects() {
13 | return [
14 | {
15 | source: '/github',
16 | destination: 'https://github.com/laurabeatris/starry',
17 | permanent: false,
18 | },
19 | ];
20 | },
21 | };
22 |
23 | module.exports = nextConfig;
24 |
25 | const { withSentryConfig } = require('@sentry/nextjs');
26 |
27 | module.exports = withSentryConfig(
28 | module.exports,
29 | {
30 | // For all available options, see:
31 | // https://github.com/getsentry/sentry-webpack-plugin#options
32 |
33 | // Suppresses source map uploading logs during build
34 | silent: true,
35 | org: 'personal-9x3',
36 | project: 'starry',
37 | },
38 | {
39 | // For all available options, see:
40 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
41 |
42 | // Upload a larger set of source maps for prettier stack traces (increases build time)
43 | widenClientFileUpload: true,
44 |
45 | // Transpiles SDK to be compatible with IE11 (increases bundle size)
46 | transpileClientSDK: true,
47 |
48 | // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
49 | tunnelRoute: '/monitoring',
50 |
51 | // Hides source maps from generated client bundles
52 | hideSourceMaps: true,
53 |
54 | // Automatically tree-shake Sentry logger statements to reduce bundle size
55 | disableLogger: true,
56 |
57 | // Enables automatic instrumentation of Vercel Cron Monitors.
58 | // See the following for more information:
59 | // https://docs.sentry.io/product/crons/
60 | // https://vercel.com/docs/cron-jobs
61 | automaticVercelMonitors: true,
62 | },
63 | );
64 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "starry",
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": "prettier --ignore-path .gitignore --write ."
11 | },
12 | "dependencies": {
13 | "@sentry/nextjs": "^7.99.0",
14 | "@upstash/ratelimit": "^1.0.0",
15 | "@vercel/analytics": "^1.1.2",
16 | "@vercel/blob": "^0.19.0",
17 | "@vercel/kv": "^1.0.1",
18 | "@vercel/speed-insights": "^1.0.7",
19 | "@workos-inc/node": "^4.0.0",
20 | "clsx": "^2.0.0",
21 | "fp-ts": "^2.16.2",
22 | "jose": "^5.2.0",
23 | "next": "canary",
24 | "promptmaker": "^1.1.0",
25 | "react": "18.2.0",
26 | "react-dom": "18.2.0",
27 | "replicate": "^0.25.2",
28 | "tailwind-merge": "^1.14.0",
29 | "tailwindcss": "3.3.3",
30 | "zod": "^3.22.4"
31 | },
32 | "devDependencies": {
33 | "@types/node": "20.6.5",
34 | "@types/react": "^18.2.48",
35 | "@types/react-dom": "^18.2.18",
36 | "autoprefixer": "10.4.16",
37 | "eslint": "8.50.0",
38 | "eslint-config-next": "13.5.2",
39 | "eslint-plugin-import": "^2.29.1",
40 | "postcss": "8.4.30",
41 | "prettier": "^3.1.1",
42 | "prettier-plugin-tailwindcss": "^0.5.11",
43 | "typescript": "5.2.2"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/desktop-background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LauraBeatris/starry/ba77a7ac2996e143ad0afa2fc4253f9686dcfb6d/public/desktop-background.png
--------------------------------------------------------------------------------
/public/mobile-background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LauraBeatris/starry/ba77a7ac2996e143ad0afa2fc4253f9686dcfb6d/public/mobile-background.png
--------------------------------------------------------------------------------
/public/painting-frame.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LauraBeatris/starry/ba77a7ac2996e143ad0afa2fc4253f9686dcfb6d/public/painting-frame.png
--------------------------------------------------------------------------------
/sentry.client.config.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the client.
2 | // The config you add here will be used whenever a users loads a page in their browser.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from '@sentry/nextjs';
6 |
7 | Sentry.init({
8 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
9 |
10 | // Adjust this value in production, or use tracesSampler for greater control
11 | tracesSampleRate: 1,
12 |
13 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
14 | debug: false,
15 |
16 | replaysOnErrorSampleRate: 1.0,
17 |
18 | // This sets the sample rate to be 10%. You may want this to be 100% while
19 | // in development and sample at a lower rate in production
20 | replaysSessionSampleRate: 0.1,
21 |
22 | // You can remove this option if you're not planning to use the Sentry Session Replay feature:
23 | integrations: [
24 | Sentry.replayIntegration({
25 | // Additional Replay configuration goes in here, for example:
26 | maskAllText: true,
27 | blockAllMedia: true,
28 | }),
29 | ],
30 | });
31 |
--------------------------------------------------------------------------------
/sentry.edge.config.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
2 | // The config you add here will be used whenever one of the edge features is loaded.
3 | // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
5 |
6 | import * as Sentry from '@sentry/nextjs';
7 |
8 | Sentry.init({
9 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
10 |
11 | // Adjust this value in production, or use tracesSampler for greater control
12 | tracesSampleRate: 1,
13 |
14 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
15 | debug: false,
16 | });
17 |
--------------------------------------------------------------------------------
/sentry.server.config.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the server.
2 | // The config you add here will be used whenever the server handles a request.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from '@sentry/nextjs';
6 |
7 | Sentry.init({
8 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
9 |
10 | // Adjust this value in production, or use tracesSampler for greater control
11 | tracesSampleRate: 1,
12 |
13 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
14 | debug: false,
15 | });
16 |
--------------------------------------------------------------------------------
/src/app/(image-generation)/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { kv } from '@vercel/kv';
4 | import * as E from 'fp-ts/lib/Either';
5 | import { redirect } from 'next/navigation';
6 | import Replicate from 'replicate';
7 | import * as z from 'zod';
8 |
9 | import { getUser } from '@/app/lib/auth';
10 | import { nanoid } from '@/app/lib/nanoid';
11 | import { performRateLimitByUser } from '@/app/lib/rateLimiter';
12 |
13 | const replicate = new Replicate({
14 | auth: process.env.REPLICATE_API_TOKEN,
15 | });
16 |
17 | const GenerateImageFormSchema = z.object({
18 | prompt: z.string({
19 | invalid_type_error: 'Please enter a prompt.',
20 | }),
21 | });
22 |
23 | export type FormState = {
24 | errors?: {
25 | prompt?: string[];
26 | };
27 | message?: string | null;
28 | };
29 |
30 | export async function generateImage(_prevState: FormState, formData: FormData) {
31 | const parsedFormData = GenerateImageFormSchema.safeParse(
32 | Object.fromEntries(formData.entries()),
33 | );
34 |
35 | if (!parsedFormData.success) {
36 | return {
37 | errors: parsedFormData.error.flatten().fieldErrors,
38 | message: 'Invalid form data. Make sure to send a prompt value.',
39 | };
40 | }
41 |
42 | const getUserResult = await getUser();
43 |
44 | if (E.isLeft(getUserResult)) {
45 | redirect('/');
46 | }
47 |
48 | if (process.env.RATE_LIMIT_ENABLED) {
49 | const { user } = getUserResult.right;
50 | const rateLimitResult = await performRateLimitByUser(user);
51 |
52 | if (E.isLeft(rateLimitResult)) {
53 | return {
54 | message: rateLimitResult.left,
55 | };
56 | }
57 | }
58 |
59 | const id = nanoid();
60 |
61 | const { prompt } = parsedFormData.data;
62 |
63 | await Promise.all([
64 | kv.hset(id, {
65 | prompt,
66 | }),
67 | replicate.predictions.create({
68 | version:
69 | 'ac732df83cea7fff18b8472768c88ad041fa750ff7682a21affe81863cbe77e4',
70 | input: {
71 | prompt: `Use a background as a starry night, but with a ${prompt}`,
72 | width: 768,
73 | height: 768,
74 | scheduler: 'K_EULER',
75 | num_outputs: 1,
76 | guidance_scale: 7.5,
77 | num_inference_steps: 50,
78 | },
79 | webhook: `${process.env.REPLICATE_WEBHOOK_URL}?id=${id}&secret=${process.env.REPLICATE_WEBHOOK_SECRET}`,
80 | webhook_events_filter: ['completed'],
81 | }),
82 | ]);
83 |
84 | redirect(`/generate-image/result/${id}`);
85 | }
86 |
--------------------------------------------------------------------------------
/src/app/(image-generation)/generate-image/_components/CopyButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import analytics from '@vercel/analytics';
4 | import { useState } from 'react';
5 |
6 | import { CopyIcon } from '@/app/ui/Icons/CopyIcon';
7 | import { LoadingCircleIcon } from '@/app/ui/Icons/LoadingIcon';
8 |
9 | interface CopyButtonProps {
10 | generatedImageUrl: string;
11 | imageId: string;
12 | }
13 |
14 | export function CopyButton({ generatedImageUrl, imageId }: CopyButtonProps) {
15 | const [isCopying, setIsCopying] = useState(false);
16 |
17 | function copyImage() {
18 | setIsCopying(true);
19 |
20 | analytics.track('Copy image', {
21 | generatedImageUrl,
22 | page: `/generate-image/result/${imageId}`,
23 | });
24 |
25 | fetch(generatedImageUrl, {
26 | headers: new Headers({
27 | Origin: location.origin,
28 | }),
29 | mode: 'cors',
30 | })
31 | .then((response) => response.blob())
32 | .then((blob) => {
33 | navigator.clipboard.write([
34 | new ClipboardItem({
35 | 'image/png': blob,
36 | }),
37 | ]);
38 | })
39 | .catch((e) => console.error(e))
40 | .finally(() => setIsCopying(false));
41 | }
42 |
43 | return (
44 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/app/(image-generation)/generate-image/_components/DownloadButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import analytics from '@vercel/analytics';
4 | import { useState } from 'react';
5 |
6 | import { DownloadIcon } from '@/app/ui/Icons/DownloadIcon';
7 | import { LoadingCircleIcon } from '@/app/ui/Icons/LoadingIcon';
8 |
9 | interface DownloadButtonProps {
10 | generatedImageUrl: string;
11 | imageId: string;
12 | }
13 |
14 | export function DownloadButton({
15 | generatedImageUrl,
16 | imageId,
17 | }: DownloadButtonProps) {
18 | const [isDownloading, setIsDownloading] = useState(false);
19 |
20 | function downloadImage() {
21 | setIsDownloading(true);
22 | analytics.track('Download image', {
23 | generatedImageUrl,
24 | page: `/generate-image/result/${imageId}`,
25 | });
26 |
27 | fetch(generatedImageUrl, {
28 | headers: new Headers({
29 | Origin: location.origin,
30 | }),
31 | mode: 'cors',
32 | })
33 | .then((response) => response.blob())
34 | .then((blob) => {
35 | let blobUrl = window.URL.createObjectURL(blob);
36 |
37 | const a = document.createElement('a');
38 | a.download = `${imageId || 'generated-image'}.png`;
39 | a.href = blobUrl;
40 | document.body.appendChild(a);
41 | a.click();
42 | a.remove();
43 | })
44 | .catch((e) => console.error(e))
45 | .finally(() => setIsDownloading(false));
46 | }
47 |
48 | return (
49 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/app/(image-generation)/generate-image/_components/GenerateImageForm.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | import promptmaker from 'promptmaker';
3 |
4 | import { PromptForm } from './PromptForm';
5 |
6 | type GenerateImageFormProps = {
7 | imagePlaceholder?: string;
8 | remainingGenerations: number;
9 | };
10 |
11 | export function GenerateImageForm({
12 | remainingGenerations,
13 | }: GenerateImageFormProps) {
14 | return (
15 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/(image-generation)/generate-image/_components/ImageResult.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Image from 'next/image';
4 | import { useRouter } from 'next/navigation';
5 | import { useEffect } from 'react';
6 |
7 | import { LoadingCircleIcon } from '@/app/ui/Icons/LoadingIcon';
8 |
9 | interface ImageResultProps {
10 | generatedImageUrl?: string;
11 | }
12 |
13 | const intervalMilliseconds = 1000;
14 |
15 | export function ImageResult({ generatedImageUrl }: ImageResultProps) {
16 | const router = useRouter();
17 |
18 | /**
19 | * Interval to keep refreshing/pooling the page until the image is
20 | * stored on Vercel blob storage
21 | */
22 | useEffect(() => {
23 | let interval: NodeJS.Timeout;
24 |
25 | if (!generatedImageUrl) {
26 | interval = setInterval(() => {
27 | router.refresh();
28 | }, intervalMilliseconds);
29 | }
30 |
31 | return () => clearInterval(interval);
32 | }, [generatedImageUrl, router]);
33 |
34 | return (
35 |
36 | {generatedImageUrl ? (
37 |
44 | ) : (
45 | <>
46 |
47 |
48 | Your image is currently being created.
This usually takes
49 | about 20 seconds.{' '}
50 |
51 | >
52 | )}
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/app/(image-generation)/generate-image/_components/PromptForm.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import analytics from '@vercel/analytics';
4 | // @ts-ignore
5 | import { ElementRef, useEffect, useRef, useState } from 'react';
6 | import { useFormState, useFormStatus } from 'react-dom';
7 |
8 | import { generateImage } from '@/app/(image-generation)/actions';
9 | import { className } from '@/app/lib/className';
10 | import useEnterSubmit from '@/app/lib/hooks/useEnterSubmit';
11 | import { LoadingCircleIcon } from '@/app/ui/Icons/LoadingIcon';
12 | import { SendIcon } from '@/app/ui/Icons/SendIcon';
13 |
14 | declare global {
15 | interface promptmaker {
16 | (): string;
17 | }
18 | }
19 |
20 | interface PromptFormProps {
21 | initialPromptText?: string;
22 | disabled?: boolean;
23 | }
24 |
25 | const initialFormState = { message: '', errors: {} };
26 |
27 | export function PromptForm({ disabled, initialPromptText }: PromptFormProps) {
28 | const [prompt, setPrompt] = useState('');
29 | const [placeholderPrompt, setPlaceholderPrompt] = useState(initialPromptText);
30 |
31 | const [state, dispatch] = useFormState(generateImage, initialFormState);
32 |
33 | const textareaRef = useRef>(null);
34 |
35 | const { formRef, onKeyDown } = useEnterSubmit();
36 |
37 | useEffect(() => {
38 | if (textareaRef.current) {
39 | textareaRef.current.select();
40 | }
41 | }, [initialPromptText]);
42 |
43 | return (
44 | <>
45 |
81 |
82 |
88 | {state.errors?.prompt &&
89 | state.errors.prompt.map((error: string) => (
90 |
91 | {error}
92 |
93 | ))}
94 |
95 |
96 | {state.message && (
97 |
98 |
102 | {state.message}
103 |
104 |
105 | )}
106 | >
107 | );
108 | }
109 |
110 | interface SubmitButtonProps {
111 | disabled?: boolean;
112 | }
113 |
114 | const SubmitButton = ({ disabled }: SubmitButtonProps) => {
115 | const { pending } = useFormStatus();
116 |
117 | const isDisabled = disabled || pending;
118 |
119 | return (
120 |
135 | );
136 | };
137 |
--------------------------------------------------------------------------------
/src/app/(image-generation)/generate-image/page.tsx:
--------------------------------------------------------------------------------
1 | import { kv } from '@vercel/kv';
2 | import assert from 'assert';
3 | import * as E from 'fp-ts/lib/Either';
4 | import { Metadata } from 'next';
5 | import { unstable_noStore } from 'next/cache';
6 |
7 | import { GenerateImageForm } from './_components/GenerateImageForm';
8 | import { getUser } from '@/app/lib/auth';
9 |
10 | export const metadata: Metadata = {
11 | title: 'Generate image with a prompt',
12 | };
13 |
14 | async function getRemainingGenerations() {
15 | unstable_noStore();
16 |
17 | const userResult = await getUser();
18 | assert(E.isRight(userResult));
19 |
20 | const identifier = userResult.right.user.email;
21 | const windowDuration = 24 * 60 * 60 * 1000;
22 | const bucket = Math.floor(Date.now() / windowDuration);
23 |
24 | const usedGenerations =
25 | (await kv?.get(`@upstash/ratelimit:${identifier!}:${bucket}`)) || 0;
26 |
27 | const resetDate = new Date();
28 | resetDate.setHours(19, 0, 0, 0);
29 | const diff = Math.abs(resetDate.getTime() - new Date().getTime());
30 | const hours = Math.floor(diff / 1000 / 60 / 60);
31 | const minutes = Math.floor(diff / 1000 / 60) - hours * 60;
32 | const remainingGenerations = 5 - Number(usedGenerations);
33 |
34 | return {
35 | remainingGenerations: Math.max(remainingGenerations, 0),
36 | hours,
37 | minutes,
38 | };
39 | }
40 |
41 | export default async function GenerateImagePage() {
42 | const { hours, minutes, remainingGenerations } =
43 | await getRemainingGenerations();
44 |
45 | return (
46 | <>
47 |
51 |
52 | You have{' '}
53 |
54 | {remainingGenerations} generations
55 | {' '}
56 | left today. Your generation
57 | {Number(remainingGenerations) > 1 ? 's' : ''} will renew in{' '}
58 |
59 | {hours} hours and {minutes} minutes.
60 |
61 |
62 | >
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/src/app/(image-generation)/generate-image/result/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { kv } from '@vercel/kv';
2 | import { Metadata } from 'next';
3 | import { unstable_noStore } from 'next/cache';
4 | import Link from 'next/link';
5 | import { notFound } from 'next/navigation';
6 |
7 | import { CopyButton } from '../../_components/CopyButton';
8 | import { DownloadButton } from '../../_components/DownloadButton';
9 | import { ImageResult } from '../../_components/ImageResult';
10 | import { ArrowLeftIcon } from '@/app/ui/Icons/ArrowLeftIcon';
11 |
12 | interface GenerateMetadataOptions {
13 | params: {
14 | id: string;
15 | };
16 | }
17 |
18 | export async function generateMetadata({
19 | params,
20 | }: GenerateMetadataOptions): Promise {
21 | const data = await kv.hgetall<{ prompt: string; generatedImageUrl?: string }>(
22 | params.id,
23 | );
24 | if (!data) {
25 | return;
26 | }
27 |
28 | const title = `${data.prompt}`;
29 | const description = `An image generated based on the prompt: ${data.prompt}`;
30 |
31 | return {
32 | title,
33 | description,
34 | openGraph: {
35 | title,
36 | description,
37 | },
38 | twitter: {
39 | card: 'summary_large_image',
40 | title,
41 | description,
42 | creator: '@lauradotjs',
43 | },
44 | };
45 | }
46 |
47 | interface GenerateImageResultPageProps {
48 | params: {
49 | id: string;
50 | };
51 | }
52 |
53 | export default async function GenerateImageResultPage({
54 | params,
55 | }: GenerateImageResultPageProps) {
56 | unstable_noStore();
57 |
58 | const data = await kv.hgetall<{
59 | generatedImageUrl: string;
60 | }>(params.id);
61 |
62 | if (!data) {
63 | notFound();
64 | }
65 |
66 | const { generatedImageUrl } = data;
67 |
68 | return (
69 |
70 |
71 |
79 |
80 |
81 |
85 |
89 |
90 |
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/app/(landing-page)/_components/AuthButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 |
5 | import { Button } from '@/app/ui/Button';
6 | import { SparkleIcon } from '@/app/ui/Icons/SparkleIcon';
7 |
8 | export function AuthButton() {
9 | const [isRedirecting, setIsRedirecting] = useState(false);
10 |
11 | return (
12 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/(landing-page)/callback/route.ts:
--------------------------------------------------------------------------------
1 | import { WorkOS } from '@workos-inc/node';
2 | import { SignJWT } from 'jose';
3 | import { NextRequest, NextResponse } from 'next/server';
4 | import z from 'zod';
5 |
6 | import { getJwtSecretKey } from '@/app/lib/auth';
7 |
8 | const workos = new WorkOS(process.env.WORKOS_API_KEY);
9 | const clientId = process.env.WORKOS_CLIENT_ID;
10 |
11 | const CallbackRequestQuery = z.object({
12 | code: z.string(),
13 | });
14 |
15 | export async function GET(request: NextRequest) {
16 | const query = Object.fromEntries(new URL(request.url).searchParams.entries());
17 | const parsedRequestQuery = CallbackRequestQuery.safeParse(query);
18 | const url = request.nextUrl.clone();
19 |
20 | if (!parsedRequestQuery.success) {
21 | url.searchParams.set('error_message', 'Invalid query params');
22 |
23 | return NextResponse.redirect(url);
24 | }
25 |
26 | try {
27 | const { user } = await workos.userManagement.authenticateWithCode({
28 | code: parsedRequestQuery.data.code,
29 | clientId,
30 | });
31 |
32 | const token = await new SignJWT({ user })
33 | .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
34 | .setIssuedAt()
35 | .setExpirationTime('1h')
36 | .sign(getJwtSecretKey());
37 |
38 | url.searchParams.delete('code');
39 | url.pathname = '/generate-image';
40 |
41 | const response = NextResponse.redirect(url);
42 |
43 | response.cookies.set({
44 | name: 'token',
45 | value: token,
46 | httpOnly: true,
47 | path: '/',
48 | secure: true,
49 | sameSite: 'lax',
50 | });
51 |
52 | return response;
53 | } catch (error) {
54 | if (error instanceof Error) {
55 | url.searchParams.set('error_message', error.message);
56 |
57 | return NextResponse.redirect(url);
58 | }
59 | }
60 |
61 | return NextResponse.redirect(url);
62 | }
63 |
--------------------------------------------------------------------------------
/src/app/(landing-page)/page.tsx:
--------------------------------------------------------------------------------
1 | import { kv } from '@vercel/kv';
2 | import { unstable_noStore } from 'next/cache';
3 | import Link from 'next/link';
4 |
5 | import { AuthButton } from './_components/AuthButton';
6 | import { ImageResult } from '../(image-generation)/generate-image/_components/ImageResult';
7 | import { getAuthorizationUrl } from '@/app/lib/auth';
8 |
9 | export default function HomePage() {
10 | return (
11 | <>
12 |
13 |
14 |
15 |
16 |
17 | >
18 | );
19 | }
20 |
21 | async function GeneratedCount() {
22 | unstable_noStore();
23 |
24 | const count = await kv.dbsize();
25 | return (
26 |
27 | Over {count} snapshots of creativity and memories, and still counting!
28 |
29 | );
30 | }
31 |
32 | async function AuthorizationLink() {
33 | const authorizationUrl = await getAuthorizationUrl();
34 |
35 | return (
36 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/api/webhook/route.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from '@sentry/node';
2 | import { put } from '@vercel/blob';
3 | import { kv } from '@vercel/kv';
4 | import { NextResponse } from 'next/server';
5 | import * as z from 'zod';
6 |
7 | const WebhookParamsSchema = z.object({
8 | /**
9 | * ID of the stored blob, initially created at the server action
10 | */
11 | id: z.string(),
12 | /**
13 | * Secret params sent in the webhook request to valid if the request was
14 | * made from within the server action
15 | */
16 | secret: z.string(),
17 | });
18 |
19 | const WebhookPayloadSchema = z
20 | .object({
21 | output: z.array(z.string()),
22 | })
23 | .partial();
24 |
25 | export async function POST(req: Request) {
26 | const parsedSearchParams = WebhookParamsSchema.safeParse(
27 | Object.fromEntries(new URL(req.url).searchParams),
28 | );
29 |
30 | if (!parsedSearchParams.success) {
31 | const errorMessage = 'Invalid request params';
32 |
33 | Sentry.captureException(errorMessage, {
34 | extra: {
35 | validationError: parsedSearchParams.error,
36 | },
37 | });
38 |
39 | return new NextResponse(errorMessage, { status: 400 });
40 | }
41 |
42 | const { id, secret } = parsedSearchParams.data;
43 |
44 | if (secret !== process.env.REPLICATE_WEBHOOK_SECRET) {
45 | const errorMessage = 'Invalid secret';
46 |
47 | Sentry.captureException(errorMessage, {
48 | extra: {
49 | secret,
50 | },
51 | });
52 |
53 | return new NextResponse(errorMessage, { status: 401 });
54 | }
55 |
56 | const payload = await req.json();
57 | const parsedWebhookPayload = WebhookPayloadSchema.safeParse(payload);
58 |
59 | if (!parsedWebhookPayload.success) {
60 | const errorMessage = 'Invalid payload';
61 |
62 | Sentry.captureException(errorMessage, {
63 | extra: {
64 | payload,
65 | },
66 | });
67 |
68 | return new NextResponse(errorMessage, { status: 400 });
69 | }
70 |
71 | const { output } = parsedWebhookPayload.data;
72 | if (!output) {
73 | const errorMessage = 'Missing Replicate output within response';
74 |
75 | Sentry.captureException(errorMessage, {
76 | extra: {
77 | payload,
78 | },
79 | });
80 |
81 | return new NextResponse(errorMessage, { status: 400 });
82 | }
83 |
84 | const file = await fetch(output[0]).then((res) => res.blob());
85 |
86 | const { url } = await put(`${id}.png`, file, { access: 'public' });
87 |
88 | await kv.hset(id, { generatedImageUrl: url });
89 |
90 | return NextResponse.json({ ok: true });
91 | }
92 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LauraBeatris/starry/ba77a7ac2996e143ad0afa2fc4253f9686dcfb6d/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .starry-background {
6 | background-color: #0c1c6e;
7 | min-height: 100vh;
8 | min-width: 100vw;
9 | }
10 |
11 | .starry-background::before {
12 | content: '';
13 | position: absolute;
14 | top: 0;
15 | right: 0;
16 | bottom: 0;
17 | left: 0;
18 | background-repeat: no-repeat;
19 | background-image: linear-gradient(
20 | to top,
21 | rgba(12, 28, 110, 1),
22 | rgba(12, 28, 110, 0.4767) 40%,
23 | rgba(12, 28, 110, 0)
24 | ),
25 | url('/mobile-background.png');
26 | opacity: 0.4;
27 | }
28 |
29 | @media (min-width: theme('screens.md')) {
30 | .starry-background::before {
31 | background-image: linear-gradient(
32 | to top,
33 | rgba(12, 28, 110, 1),
34 | rgba(12, 28, 110, 0.4767) 40%,
35 | rgba(12, 28, 110, 0)
36 | ),
37 | url('/desktop-background.png');
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './globals.css';
2 |
3 | import { Analytics } from '@vercel/analytics/react';
4 | import { SpeedInsights } from '@vercel/speed-insights/next';
5 | import { Metadata } from 'next';
6 | import { PropsWithChildren } from 'react';
7 |
8 | import { className } from '@/app/lib/className';
9 | import { interFont, playfairFont } from '@/app/lib/fonts';
10 | import { BuyMeACoffeeIcon } from '@/app/ui/Icons/BuyMeACoffeeIcon';
11 | import { Sparkles } from '@/app/ui/Sparkles';
12 |
13 | import '@/app/lib/envSchema';
14 |
15 | export const metadata: Metadata = {
16 | title: {
17 | template: 'Starry | %s',
18 | default: 'Starry',
19 | },
20 | description: "Turn your ideas into Van Gogh's Starry Night.",
21 | metadataBase: new URL('https://my-starry.com'),
22 | };
23 |
24 | export default function RootLayout({ children }: PropsWithChildren) {
25 | return (
26 | <>
27 |
28 |
29 |
30 |
37 |
38 |
39 |
40 | {children}
41 |
42 |
43 |
44 |
45 |
46 | >
47 | );
48 | }
49 |
50 | function Header() {
51 | return (
52 |
65 | );
66 | }
67 |
68 | function Footer() {
69 | return (
70 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/src/app/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import { WorkOS } from '@workos-inc/node';
2 | import type { User } from '@workos-inc/node';
3 | import * as E from 'fp-ts/lib/Either';
4 | import { jwtVerify } from 'jose';
5 | import { cookies } from 'next/headers';
6 | import { redirect } from 'next/navigation';
7 |
8 | const workos = new WorkOS(process.env.WORKOS_API_KEY);
9 | const clientId = process.env.WORKOS_CLIENT_ID;
10 | const redirectUri = process.env.WORKOS_REDIRECT_URI;
11 |
12 | export function getJwtSecretKey() {
13 | const secret = process.env.JWT_SECRET_KEY;
14 |
15 | return new Uint8Array(Buffer.from(secret, 'base64'));
16 | }
17 |
18 | export async function verifyJwtToken(token: string) {
19 | try {
20 | const { payload } = await jwtVerify(token, getJwtSecretKey());
21 | return E.right(payload);
22 | } catch (error) {
23 | return E.left('Error while verifying JWT token');
24 | }
25 | }
26 |
27 | export async function getUser() {
28 | const token = cookies().get('token')?.value;
29 |
30 | if (!token) {
31 | return E.left('Not authenticated');
32 | }
33 |
34 | const verifiedTokenResult = await verifyJwtToken(token);
35 |
36 | if (E.isLeft(verifiedTokenResult)) {
37 | return verifiedTokenResult;
38 | }
39 |
40 | return E.right({
41 | isAuthenticated: true,
42 | user: verifiedTokenResult.right.user as User,
43 | });
44 | }
45 |
46 | export async function signOut() {
47 | cookies().delete('token');
48 | redirect('/');
49 | }
50 |
51 | export function getAuthorizationUrl() {
52 | return workos.userManagement.getAuthorizationUrl({
53 | provider: 'authkit',
54 | redirectUri,
55 | clientId,
56 | });
57 | }
58 |
--------------------------------------------------------------------------------
/src/app/lib/className.ts:
--------------------------------------------------------------------------------
1 | import clsx, { ClassValue } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | /**
5 | * Safely merge classes for Tailwind usage
6 | */
7 | export function className(...inputs: ClassValue[]) {
8 | return twMerge(clsx(inputs));
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/lib/envSchema.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from '@sentry/node';
2 | import z from 'zod';
3 |
4 | const envVariableSchema = z.string().trim().min(1);
5 | const envServerSchema = z.object({
6 | WORKOS_API_KEY: envVariableSchema,
7 | WORKOS_CLIENT_ID: envVariableSchema,
8 | WORKOS_REDIRECT_URI: envVariableSchema,
9 | JWT_SECRET_KEY: envVariableSchema,
10 | REPLICATE_API_TOKEN: envVariableSchema,
11 | REPLICATE_WEBHOOK_SECRET: envVariableSchema,
12 | KV_REST_API_READ_ONLY_TOKEN: envVariableSchema,
13 | KV_REST_API_TOKEN: envVariableSchema,
14 | KV_REST_API_URL: envVariableSchema,
15 | KV_URL: envVariableSchema,
16 | BLOB_READ_WRITE_TOKEN: envVariableSchema,
17 | NEXT_PUBLIC_SENTRY_DSN: envVariableSchema,
18 | REPLICATE_WEBHOOK_URL: envVariableSchema,
19 | RATE_LIMIT_ENABLED: envVariableSchema.optional(),
20 | });
21 |
22 | type EnvServerSchema = z.infer;
23 |
24 | const envServerParsed = envServerSchema.safeParse({
25 | WORKOS_API_KEY: process.env.WORKOS_API_KEY,
26 | WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID,
27 | WORKOS_REDIRECT_URI: process.env.WORKOS_REDIRECT_URI,
28 | JWT_SECRET_KEY: process.env.JWT_SECRET_KEY,
29 | REPLICATE_API_TOKEN: process.env.REPLICATE_API_TOKEN,
30 | REPLICATE_WEBHOOK_SECRET: process.env.REPLICATE_WEBHOOK_SECRET,
31 | KV_REST_API_READ_ONLY_TOKEN: process.env.KV_REST_API_READ_ONLY_TOKEN,
32 | KV_REST_API_TOKEN: process.env.KV_REST_API_TOKEN,
33 | KV_REST_API_URL: process.env.KV_REST_API_URL,
34 | KV_URL: process.env.KV_URL,
35 | BLOB_READ_WRITE_TOKEN: process.env.BLOB_READ_WRITE_TOKEN,
36 | NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
37 | REPLICATE_WEBHOOK_URL: process.env.REPLICATE_WEBHOOK_URL,
38 | RATE_LIMIT_ENABLED: process.env.RATE_LIMIT_ENABLED,
39 | } satisfies EnvServerSchema);
40 |
41 | if (!envServerParsed.success) {
42 | const errorMessage = 'Error when parsing environment variables';
43 |
44 | Sentry.captureException(errorMessage, {
45 | extra: {
46 | issues: envServerParsed.error.issues,
47 | },
48 | });
49 |
50 | throw new Error(errorMessage);
51 | }
52 |
53 | export const envServerData = envServerParsed.data;
54 |
55 | type EnvServerSchemaType = z.infer;
56 |
57 | declare global {
58 | namespace NodeJS {
59 | interface ProcessEnv extends EnvServerSchemaType {}
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/app/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | import { Inter, Playfair_Display } from 'next/font/google';
2 |
3 | export const playfairFont = Playfair_Display({
4 | subsets: ['latin'],
5 | display: 'swap',
6 | variable: '--font-playfair',
7 | });
8 |
9 | export const interFont = Inter({
10 | subsets: ['latin'],
11 | variable: '--font-inter',
12 | display: 'swap',
13 | });
14 |
--------------------------------------------------------------------------------
/src/app/lib/hooks/useEnterSubmit.ts:
--------------------------------------------------------------------------------
1 | import { useRef, type RefObject, ElementRef } from 'react';
2 |
3 | /**
4 | * A custom React hook that facilitates form submission via the Enter key.
5 | *
6 | * It provides a form reference and a keydown event handler to trigger form submission.
7 | *
8 | * The submission is only triggered when the Enter key is pressed without the Shift key,
9 | * and the input field is not composing (e.g., during IME composition).
10 | */
11 | export default function useEnterSubmit(): {
12 | formRef: RefObject;
13 | onKeyDown: (event: React.KeyboardEvent) => void;
14 | } {
15 | const formRef = useRef>(null);
16 |
17 | const handleKeyDown = (
18 | event: React.KeyboardEvent,
19 | ): void => {
20 | const value = event.currentTarget.value.trim();
21 |
22 | if (
23 | event.key === 'Enter' &&
24 | !event.shiftKey &&
25 | !event.nativeEvent.isComposing
26 | ) {
27 | if (value.length === 0) {
28 | event.preventDefault();
29 | return;
30 | }
31 |
32 | formRef.current?.requestSubmit();
33 | event.preventDefault();
34 | }
35 | };
36 |
37 | return { formRef, onKeyDown: handleKeyDown };
38 | }
39 |
--------------------------------------------------------------------------------
/src/app/lib/hooks/useMediaQuery.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | type Device = 'mobile' | 'tablet' | 'desktop';
4 |
5 | interface Dimensions {
6 | width: number;
7 | height: number;
8 | }
9 |
10 | export function useMediaQuery() {
11 | const [device, setDevice] = useState();
12 | const [dimensions, setDimensions] = useState();
13 |
14 | useEffect(() => {
15 | const breakpoints: Record, MediaQueryList> = {
16 | mobile: window.matchMedia('(max-width: 640px)'),
17 | tablet: window.matchMedia('(min-width: 641px) and (max-width: 1024px)'),
18 | };
19 |
20 | function checkDevice() {
21 | if (breakpoints.mobile.matches) {
22 | return setDevice('mobile');
23 | }
24 |
25 | if (breakpoints.tablet.matches) {
26 | return setDevice('tablet');
27 | }
28 |
29 | return setDevice('desktop');
30 | }
31 |
32 | setDimensions({ width: window.innerWidth, height: window.innerHeight });
33 |
34 | checkDevice();
35 |
36 | window.addEventListener('resize', checkDevice);
37 |
38 | return () => {
39 | window.removeEventListener('resize', checkDevice);
40 | };
41 | }, []);
42 |
43 | return {
44 | device,
45 | width: dimensions?.width,
46 | height: dimensions?.height,
47 | isMobile: device === 'mobile',
48 | isTablet: device === 'tablet',
49 | isDesktop: device === 'desktop',
50 | };
51 | }
52 |
--------------------------------------------------------------------------------
/src/app/lib/hooks/usePrefersReducedMotion.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | const QUERY = '(prefers-reduced-motion: no-preference)';
4 | const isRenderingOnServer = typeof window === 'undefined';
5 |
6 | const getInitialState = () => {
7 | // For our initial server render, we won't know if the user
8 | // prefers reduced motion, but it doesn't matter. This value
9 | // will be overwritten on the client, before any animations
10 | // occur.
11 | return isRenderingOnServer ? true : !window.matchMedia(QUERY).matches;
12 | };
13 |
14 | export function usePrefersReducedMotion() {
15 | const [prefersReducedMotion, setPrefersReducedMotion] =
16 | useState(getInitialState);
17 |
18 | useEffect(() => {
19 | const mediaQueryList = window.matchMedia(QUERY);
20 |
21 | const listener = (event: MediaQueryListEvent) => {
22 | setPrefersReducedMotion(!event.matches);
23 | };
24 |
25 | if (mediaQueryList.addEventListener) {
26 | mediaQueryList.addEventListener('change', listener);
27 | } else {
28 | mediaQueryList.addListener(listener);
29 | }
30 |
31 | return () => {
32 | if (mediaQueryList.removeEventListener) {
33 | mediaQueryList.removeEventListener('change', listener);
34 | } else {
35 | mediaQueryList.removeListener(listener);
36 | }
37 | };
38 | }, []);
39 |
40 | return prefersReducedMotion;
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/lib/hooks/useRandomInterval.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from 'react';
2 |
3 | import { getRandomNumber } from '@/app/lib/numbers';
4 |
5 | export function useRandomInterval(
6 | callback: () => void,
7 | minDelay?: number,
8 | maxDelay?: number,
9 | ) {
10 | const timeoutId = useRef();
11 | const savedCallback = useRef(callback);
12 |
13 | useEffect(() => {
14 | savedCallback.current = callback;
15 | }, [callback]);
16 |
17 | useEffect(() => {
18 | const isEnabled =
19 | typeof minDelay === 'number' && typeof maxDelay === 'number';
20 |
21 | if (isEnabled) {
22 | const handleTick = () => {
23 | const nextTickAt = getRandomNumber(minDelay, maxDelay);
24 |
25 | timeoutId.current = window.setTimeout(() => {
26 | savedCallback.current();
27 | handleTick();
28 | }, nextTickAt);
29 | };
30 |
31 | handleTick();
32 | }
33 |
34 | return () => window.clearTimeout(timeoutId.current);
35 | }, [minDelay, maxDelay]);
36 |
37 | const cancel = useCallback(function () {
38 | window.clearTimeout(timeoutId.current);
39 | }, []);
40 |
41 | return cancel;
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/lib/nanoid.ts:
--------------------------------------------------------------------------------
1 | import { customAlphabet } from 'nanoid';
2 |
3 | export const nanoid = customAlphabet(
4 | '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
5 | 7,
6 | );
7 |
--------------------------------------------------------------------------------
/src/app/lib/numbers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns a random number based on a given range
3 | */
4 | export function getRandomNumber(min: number, max: number): number {
5 | return Math.floor(Math.random() * (max - min)) + min;
6 | }
7 |
8 | /**
9 | * Returns a sequence given on a given range
10 | */
11 | export function getNumberSequence(start: number, end: number, step = 1) {
12 | const output = [];
13 |
14 | if (typeof end === 'undefined') {
15 | end = start;
16 | start = 0;
17 | }
18 |
19 | for (let i = start; i < end; i += step) {
20 | output.push(i);
21 | }
22 |
23 | return output;
24 | }
25 |
--------------------------------------------------------------------------------
/src/app/lib/rateLimiter.ts:
--------------------------------------------------------------------------------
1 | import { Ratelimit } from '@upstash/ratelimit';
2 | import { kv } from '@vercel/kv';
3 | import { User } from '@workos-inc/node';
4 | import * as E from 'fp-ts/lib/Either';
5 | import { unstable_noStore } from 'next/cache';
6 |
7 | export const rateLimiter = new Ratelimit({
8 | redis: kv,
9 | // Allows 5 requests per day
10 | limiter: Ratelimit.fixedWindow(5, '1440 m'),
11 | });
12 |
13 | export async function performRateLimitByUser(user: User) {
14 | unstable_noStore();
15 |
16 | const identifier = user.email;
17 | const result = await rateLimiter.limit(identifier!);
18 |
19 | const diff = Math.abs(
20 | new Date(result.reset).getTime() - new Date().getTime(),
21 | );
22 | const hours = Math.floor(diff / 1000 / 60 / 60);
23 | const minutes = Math.floor(diff / 1000 / 60) - hours * 60;
24 |
25 | if (!result.success) {
26 | return E.left(
27 | `Rate limit exceeded. Your generations will renew in ${hours} hours and ${minutes} minutes.`,
28 | );
29 | }
30 |
31 | return E.right(result);
32 | }
33 |
--------------------------------------------------------------------------------
/src/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 | const maxKeys = 50000;
8 |
9 | while (ids.length < maxKeys) {
10 | const [nextCursor, keys] = await kv.scan(cursor, {
11 | match: '*',
12 | count: 1000,
13 | });
14 | if (!keys.length || nextCursor === 0) break;
15 |
16 | ids.push(...keys.slice(0, maxKeys - ids.length));
17 | cursor = nextCursor;
18 | }
19 |
20 | return [
21 | {
22 | url: 'https://my-starry.com',
23 | lastModified: new Date().toISOString(),
24 | },
25 | ...ids.map((id) => ({
26 | url: `https://my-starry.com/generate-image/result/${id}`,
27 | lastModified: new Date().toISOString(),
28 | })),
29 | ];
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/ui/Button/index.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps, PropsWithChildren } from 'react';
2 |
3 | import styles from './styles.module.css';
4 | import { className } from '@/app/lib/className';
5 |
6 | interface ButtonProps
7 | extends Pick, 'onClick' | 'disabled'> {}
8 |
9 | export function Button({ children, onClick }: PropsWithChildren) {
10 | return (
11 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/ui/Button/styles.module.css:
--------------------------------------------------------------------------------
1 | .pushable {
2 | background: hsl(340deg 100% 32%);
3 | border-radius: 12px;
4 | border: none;
5 | padding: 0;
6 | cursor: pointer;
7 | outline-offset: 4px;
8 | }
9 |
10 | .front {
11 | display: block;
12 | padding: 8px 20px;
13 | border-radius: 12px;
14 | font-size: 1.1rem;
15 | background: hsl(345deg 100% 47%);
16 | color: white;
17 | transform: translateY(-6px);
18 | }
19 |
20 | .pushable:active .front {
21 | transform: translateY(-2px);
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/ui/Icons/ArrowLeftIcon.tsx:
--------------------------------------------------------------------------------
1 | interface ArrowLeftIconProps {
2 | className: string;
3 | }
4 |
5 | export function ArrowLeftIcon({ className }: ArrowLeftIconProps) {
6 | return (
7 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/ui/Icons/BuyMeACoffeeIcon.tsx:
--------------------------------------------------------------------------------
1 | interface BuyMeACoffeeProps {
2 | className?: string;
3 | }
4 |
5 | export function BuyMeACoffeeIcon({ className }: BuyMeACoffeeProps) {
6 | return (
7 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/src/app/ui/Icons/CopyIcon.tsx:
--------------------------------------------------------------------------------
1 | interface CopyIconProps {
2 | className: string;
3 | }
4 |
5 | export function CopyIcon({ className }: CopyIconProps) {
6 | return (
7 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/ui/Icons/DownloadIcon.tsx:
--------------------------------------------------------------------------------
1 | interface DownloadIconProps {
2 | className: string;
3 | }
4 |
5 | export function DownloadIcon({ className }: DownloadIconProps) {
6 | return (
7 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/ui/Icons/LoadingIcon.tsx:
--------------------------------------------------------------------------------
1 | export function LoadingCircleIcon() {
2 | return (
3 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/ui/Icons/SendIcon.tsx:
--------------------------------------------------------------------------------
1 | interface SendIconProps {
2 | className?: string;
3 | }
4 |
5 | export function SendIcon({ className }: SendIconProps) {
6 | return (
7 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/ui/Icons/SparkleIcon.tsx:
--------------------------------------------------------------------------------
1 | interface SparkleIconProps {
2 | className?: string;
3 | }
4 |
5 | export function SparkleIcon({ className }: SparkleIconProps) {
6 | return (
7 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/ui/Icons/UploadIcon.tsx:
--------------------------------------------------------------------------------
1 | export function UploadIcon() {
2 | return (
3 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/ui/Sparkles/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { CSSProperties, PropsWithChildren, SVGProps, useState } from 'react';
4 |
5 | import styles from './styles.module.css';
6 | import { className } from '@/app/lib/className';
7 | import { usePrefersReducedMotion } from '@/app/lib/hooks/usePrefersReducedMotion';
8 | import { useRandomInterval } from '@/app/lib/hooks/useRandomInterval';
9 | import { getNumberSequence } from '@/app/lib/numbers';
10 |
11 | interface SparkleShape {
12 | id: string;
13 | size: number;
14 | color: string;
15 | createdAt: number;
16 | style: Pick;
17 | }
18 |
19 | const DEFAULT_COLOR = 'hsl(50deg, 100%, 50%)';
20 |
21 | /**
22 | * Returns a random number based on a given range
23 | */
24 | function getRandomNumber(min: number, max: number): number {
25 | return Math.floor(Math.random() * (max - min)) + min;
26 | }
27 |
28 | function generateSparkle(
29 | sizeRange: [number, number] = [10, 20],
30 | color = DEFAULT_COLOR,
31 | ): SparkleShape {
32 | return {
33 | id: String(getRandomNumber(10000, 99999)),
34 | createdAt: Date.now(),
35 | color,
36 | size: getRandomNumber(...sizeRange),
37 | style: {
38 | top: getRandomNumber(0, 100) + '%',
39 | left: getRandomNumber(0, 100) + '%',
40 | zIndex: 20,
41 | },
42 | };
43 | }
44 |
45 | interface SparkleIconProps
46 | extends Pick, 'style' | 'fill' | 'width' | 'height'> {}
47 |
48 | function SparkleIcon({ height, width, style, fill }: SparkleIconProps) {
49 | return (
50 |
56 |
70 |
71 | );
72 | }
73 |
74 | interface SparkleProps extends PropsWithChildren {
75 | sizeRange?: [mininum: number, larger: number];
76 | }
77 |
78 | export function Sparkles({ children, sizeRange }: SparkleProps) {
79 | const [sparkles, setSparkles] = useState(() => {
80 | return getNumberSequence(1, 4).map(() => generateSparkle(sizeRange));
81 | });
82 |
83 | const prefersReducedMotion = usePrefersReducedMotion();
84 |
85 | useRandomInterval(
86 | () => {
87 | const now = Date.now();
88 | const sparkle = generateSparkle(sizeRange);
89 | const nextSparkles = sparkles.filter((sparkle) => {
90 | const delta = now - sparkle.createdAt;
91 | return delta < 1000;
92 | });
93 |
94 | nextSparkles.push(sparkle);
95 | setSparkles(nextSparkles);
96 | },
97 | prefersReducedMotion ? undefined : 50,
98 | prefersReducedMotion ? undefined : 500,
99 | );
100 |
101 | return (
102 |
103 | {sparkles.map((sparkle) => (
104 |
111 | ))}
112 |
113 | {children}
114 |
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/src/app/ui/Sparkles/styles.module.css:
--------------------------------------------------------------------------------
1 | @keyframes growAndShrink {
2 | 0% {
3 | transform: scale(0);
4 | }
5 | 50% {
6 | transform: scale(1);
7 | }
8 | 100% {
9 | transform: scale(0);
10 | }
11 | }
12 |
13 | .growAndShrink {
14 | animation: grow-and-shrink 600ms ease-in-out forwards;
15 | }
16 |
17 | @media (prefers-reduced-motion: no-preference) {
18 | .growAndShrink {
19 | animation: grow-and-shrink 600ms ease-in-out forwards;
20 | }
21 | }
22 |
23 | @keyframes spin {
24 | from {
25 | transform: rotate(0deg);
26 | }
27 | to {
28 | transform: rotate(180deg);
29 | }
30 | }
31 |
32 | .spin {
33 | animation: spin 600ms linear forwards;
34 | }
35 |
36 | @media (prefers-reduced-motion: no-preference) {
37 | .spin {
38 | animation: spin 600ms linear forwards;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 |
3 | export function middleware(request: NextRequest) {
4 | const isOnLandingPage = request.nextUrl.pathname === '/';
5 |
6 | if (!isOnLandingPage && !request.cookies.has('token')) {
7 | return NextResponse.redirect(new URL('/', request.nextUrl));
8 | }
9 |
10 | return NextResponse.next();
11 | }
12 |
13 | export const config = {
14 | matcher: ['/((?!api|_next/static|_next/image|.*\\.png$|callback).*)'],
15 | };
16 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | const config: Config = {
4 | content: ['./src/app/**/*.{js,ts,jsx,tsx}'],
5 | theme: {
6 | extend: {
7 | fontFamily: {
8 | display: ['var(--font-playfair)'],
9 | default: ['var(--font-inter)', 'system-ui', 'sans-serif'],
10 | },
11 | },
12 | },
13 | plugins: [],
14 | };
15 | export default config;
16 |
--------------------------------------------------------------------------------
/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 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------