├── .env.example
├── .gitignore
├── .prettierignore
├── LICENSE.md
├── PR.md
├── README.md
├── app
├── actions
│ ├── billing.ts
│ ├── checkout.ts
│ ├── deleteAccount.ts
│ ├── upload.ts
│ └── uploadAgePredict.ts
├── api
│ ├── auth
│ │ └── callback
│ │ │ └── route.ts
│ └── webhooks
│ │ ├── replicate
│ │ └── [id]
│ │ │ └── route.ts
│ │ ├── stripe
│ │ └── route.ts
│ │ └── supabase
│ │ └── customer
│ │ └── route.ts
├── favicon.ico
├── gallery
│ ├── gallery-page.tsx
│ └── page.tsx
├── layout.tsx
├── p
│ └── [id]
│ │ ├── not-found.tsx
│ │ ├── page.tsx
│ │ └── photo-page.tsx
└── page.tsx
├── components.json
├── components
├── Banner.tsx
├── aceternity-ui
│ └── background-gradient.tsx
├── age-predict-modal.tsx
├── analytics
│ ├── consent-banner.tsx
│ └── index.tsx
├── home-page.tsx
├── home
│ ├── faq.tsx
│ ├── photo-booth.tsx
│ └── upload-dialog.tsx
├── layout
│ ├── checkout-dialog.tsx
│ ├── delete-account-dialog.tsx
│ ├── footer.tsx
│ ├── navbar.tsx
│ ├── sign-in-dialog.tsx
│ ├── terms-and-privacy.tsx
│ └── user-dropdown.tsx
├── shared
│ ├── counting-numbers.tsx
│ ├── icons
│ │ ├── expanding-arrow.tsx
│ │ ├── github.tsx
│ │ ├── google.tsx
│ │ ├── index.tsx
│ │ ├── loading-circle.tsx
│ │ ├── loading-dots.module.css
│ │ ├── loading-dots.tsx
│ │ ├── loading-spinner.module.css
│ │ ├── loading-spinner.tsx
│ │ └── twitter.tsx
│ ├── leaflet.tsx
│ ├── modal.tsx
│ ├── popover.tsx
│ ├── switch.tsx
│ └── tooltip.tsx
└── ui
│ ├── accordion.tsx
│ ├── avatar.tsx
│ ├── badge.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── carousel.tsx
│ ├── dialog.tsx
│ ├── drawer.tsx
│ ├── dropdown-menu.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── separator.tsx
│ └── sonner.tsx
├── lib
├── constants.ts
├── dub.ts
├── hooks
│ ├── use-intersection-observer.ts
│ ├── use-local-storage.ts
│ ├── use-media-query.tsx
│ ├── use-scroll.ts
│ └── use-window-size.ts
├── supabase
│ ├── admin.ts
│ ├── client.ts
│ ├── middleware.ts
│ ├── server.ts
│ └── types_db.ts
├── types.ts
└── utils.ts
├── middleware.ts
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── public
├── extrapolate-privacy-policy.pdf
├── extrapolate-terms-of-service.pdf
├── logo.png
├── vercel-logotype.svg
└── vercel.svg
├── stripe
├── products.json
└── webhook.json
├── styles
├── ClashDisplay-Bold.otf
├── ClashDisplay-Semibold.otf
└── globals.css
├── supabase
├── config.toml
├── migrations
│ └── 20240514064141_init.sql
├── schema.sql
└── seed.sql
├── tailwind.config.ts
├── tsconfig.json
└── vercel.json
/.env.example:
--------------------------------------------------------------------------------
1 | # Get this from replicate: https://replicate.com/
2 | REPLICATE_API_TOKEN=
3 |
4 | # Get these from Supabase: https://supabase.com/
5 | NEXT_PUBLIC_SUPABASE_URL=
6 | NEXT_PUBLIC_SUPABASE_ANON_KEY=
7 | SUPABASE_SERVICE_ROLE_KEY=
8 |
9 | # Get these from Stripe: https://stripe.com/
10 | STRIPE_SECRET_KEY=
11 | STRIPE_WEBHOOK_SECRET=
12 | STRIPE_SECRET_KEY_TEST=
13 | STRIPE_WEBHOOK_SECRET_TEST=
14 |
15 | # Get this from here: https://1password.com/password-generator/
16 | CRON_SECRET=
17 |
18 | # Get these from Cloudflare: https://try.cloudflare.com/
19 | TUNNEL_URL=
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 |
4 | # next.js
5 | /.next
6 | next-env.d.ts
7 |
8 | # misc
9 | .DS_Store
10 | *.pem
11 |
12 | # local env files
13 | .env.local
14 | .env
15 |
16 | # vercel
17 | .vercel
18 |
19 | # supabase
20 | /supabase/.branches
21 | /supabase/.temp
22 | /supabase/.env
23 | .env*.local
24 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .next
3 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Steven Tey
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/PR.md:
--------------------------------------------------------------------------------
1 | ## Features
2 |
3 | - [Supabase](https://supabase.com) for Database, Auth, Storage, and Realtime
4 | - [Database](https://supabase.com/database) - Replaces Upstash KV to store image data.
5 | - [Auth](https://supabase.com/auth) - Handle user accounts and link to stripe alongside database.
6 | - [Storage](https://supabase.com/storage) - Replaces Cloudflare R2 to store original images.
7 | - [Realtime](https://supabase.com/realtime) - Replaces polling to get output by listening to postgres changes.
8 | - More - RLS, Database Functions, etc
9 |
10 |
11 | - [Stripe](https://stripe.com) for Billing
12 | - [Checkout](https://stripe.com/docs/payments/checkout) to buy credits for image generation.
13 | - [Customer Portal](https://stripe.com/docs/billing/subscriptions/customer-portal) to manage billing and view invoices.
14 | - [Webhooks](https://docs.stripe.com/webhooks) to automatically sync product offerings with database.
15 |
16 |
17 | - Next.js [App Router](https://nextjs.org/docs/app)
18 | - Server Actions - Image uploads & creating Stripe sessions
19 | - Route Handlers - Supabase Auth, Vercel Cron Jobs, and Webhooks for Replicate, Stripe, and Supabase
20 | - Metadata - File-based & Config-based
21 | - Dynamic Routes - SSR 🚀
22 | - Vercel Cron Jobs - Remove all data and images older than 1 day
23 |
24 |
25 | ## Setup / Migration Guide
26 |
27 | ### Pre-requisites
28 | - Vercel [Account](https://vercel.com/login) & [CLI](https://vercel.com/docs/cli)
29 | - Supabase [Account](https://supabase.com/dashboard/sign-in?) & [CLI](https://supabase.com/docs/guides/cli/getting-started?queryGroups=platform&platform=npx)
30 | - Stripe [Account](https://dashboard.stripe.com/login) & [CLI](https://docs.stripe.com/stripe-cli)
31 |
32 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fajayvignesh01%2Fextrapolate-new&env=REPLICATE_API_TOKEN,STRIPE_SECRET_KEY,CRON_SECRET&envDescription=API%20Keys%20needed%20for%20the%20application&envLink=https%3A%2F%2Fgithub.com%2Fajayvignesh01%2Fextrapolate-new%2Fblob%2Fmain%2F.env.example&project-name=extrapolate-new&repository-name=extrapolate-new&demo-title=Extrapolate%20New%20Demo&demo-url=https%3A%2F%2Fextrapolate-new.vercel.app&demo-image=https%3A%2F%2Fextrapolate-new.vercel.app%2Fopengraph-image&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6&external-id=https%3A%2F%2Fgithub.com%2Fajayvignesh01%2Fextrapolate-new%2Ftree%2Fmain)
33 |
34 | ### Steps
35 | - Use the Deploy with Vercel button above. This will:
36 | 1. Create a new git repository for the project.
37 | 2. Set up the necessary Supabase environment variables and run the [SQL migrations](https://github.com/ajayvignesh01/extrapolate-new/tree/main/supabase/migrations) to set up the Database schema on a new project.
38 | - If for some reason the migrations weren't automatically run by the integration, or you are manually setting up Supabase, you can manually run them in the [SQL Editor](https://app.supabase.com/project/_/sql).
39 | - The integration should have also handled adding the site url and approved redirect urls for auth. But in case it didn't, you manually do so [here](https://app.supabase.com/project/_/auth/url-configuration).
40 | 3. Set up some environment variables.
41 |
42 | - There are two more things we need to manually set up on Supabase
43 | 1. We need to create a [Database Webhook](https://supabase.com/dashboard/project/_/database/hooks)
44 | - This will trigger the creation of a customer on stripe when a user creates an account.
45 | - Call the webhook "customer", have it trigger from insert and delete on public.users and send an HTTP POST request to `https://[YOUR_DOMAIN_HERE]/api/webhooks/supabase/customer`
46 | 2. The final thing we have to do on Supabase is to enable Google OAuth.
47 | - You can follow the instructions from the official documentation [here](https://supabase.com/dashboard/project/_/database/hooks).
48 |
49 | - Now, we can configure Stripe
50 | 1. Edit the webhook url in the stripe [webhook.json](https://github.com/ajayvignesh01/extrapolate-new/blob/main/stripe/webhook.json) file to match your domain.
51 | 2. Run `pnpm fixtures:webhook`. This will set up a webhook to sync products/prices between Stripe & Supabase.
52 |
53 | - Back on Vercel
54 | 1. Add the `STRIPE_WEBHOOK_SECRET` environment variable. You can find this in your [Stripe Webhooks Dashboard](https://dashboard.stripe.com/test/webhooks) under `Signing secret` for the specific webhook.
55 | 2. Then add `TUNNEL_URL` env variable and make it an empty string. You will edit this in your `.env.local` when developing locally as needed.
56 | 3. If you haven't already added your `REPLICATE_API_TOKEN` env variable, you can do that now as well.
57 | 4. Now redeploy your app on Vercel, and wait for the deployment to complete before moving onto the next step.
58 |
59 | - Lastly,
60 | 1. Run `pnpm fixtures:products`. This will generate the default products/prices on your Stripe account.
61 | 2. You can verify this worked by checking your [Stripe Dashboard](https://dashboard.stripe.com/test/products?active=true) & the products/prices table on [Supabase](https://supabase.com/dashboard/project/_/editor)
62 | 3. You should be all set to go now!
63 |
64 | ### Additional
65 |
66 | - You can use the Stripe CLI to test webhooks locally. More info [here](https://docs.stripe.com/webhooks#test-webhook).
67 | - You can pull env variables from Vercel using `pnpm dlx vercel env pull`
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Extrapolate
4 |
5 |
6 |
7 | See how well you age with AI
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Introduction ·
21 | Features ·
22 | Deploy Your Own ·
23 | Author
24 |
25 |
26 |
27 | --NEW README COMING SOON--
28 |
29 | ## Introduction
30 |
31 | Extrapolate is an app for you to see how well you age by transforming your face with Artificial Intelligence.
32 |
33 | https://user-images.githubusercontent.com/28986134/213781048-d215894d-2286-4176-a200-f745b255ecbe.mp4
34 |
35 | ## Features
36 |
37 | - 3s GIF of your face as it ages through time 🧓
38 | - Store & retrieve photos from [Cloudflare R2](https://www.cloudflare.com/lp/pg-r2/) using Workers
39 |
40 | ## Deploy Your Own
41 |
42 | You can deploy this template to Vercel with the button below:
43 |
44 | [](https://vercel.com/new/clone?demo-title=Extrapolate%20%E2%80%93%C2%A0See%20how%20well%20you%20age%20with%20AI&demo-description=Age%20transformation%20AI%20app%20powered%20by%20Next.js%2C%20Replicate%2C%20Upstash%2C%20and%20Cloudflare%20R2%20%2B%20Workers.&demo-url=https%3A%2F%2Fextrapolate.app%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4B2RUQ7DTvPgpf3Ra9jSC2%2Fda2571b055081a670ac9649d3ac0ac7a%2FCleanShot_2023-01-20_at_12.04.08.png&project-name=Extrapolate%20%E2%80%93%C2%A0See%20how%20well%20you%20age%20with%20AI&repository-name=extrapolate&repository-url=https%3A%2F%2Fgithub.com%2Fsteven-tey%2Fextrapolate&from=templates&integration-ids=oac_V3R1GIpkoJorr6fqyiwdhl17&env=REPLICATE_API_TOKEN%2CREPLICATE_WEBHOOK_TOKEN%2CCLOUDFLARE_WORKER_SECRET%2CPOSTMARK_TOKEN&envDescription=How%20to%20get%20these%20env%20variables%3A%20&envLink=https%3A%2F%2Fgithub.com%2Fsteven-tey%2Fextrapolate%2Fblob%2Fmain%2F.env.example)
45 |
46 | Note that you'll need to:
47 |
48 | - Set up a [ReplicateHQ](https://replicate.com) account to get the `REPLICATE_API_TOKEN` env var.
49 | - Set up an [Upstash](https://upstash.com) account to get the Upstash Redis env vars.
50 | - Create a [Cloudflare R2 instance](https://www.cloudflare.com/lp/pg-r2/) and set up a [Cloudflare Worker](https://workers.cloudflare.com/) to handle uploads & reads (instructions below).
51 |
52 | ### Cloudflare R2 setup instructions
53 |
54 | 1. Go to Cloudflare and create an [R2 bucket](https://www.cloudflare.com/lp/pg-r2/).
55 | 2. Create a [Cloudflare Worker](https://workers.cloudflare.com/) using the code snippet below.
56 | 3. Bind your worker to your R2 instance under **Settings > R2 Bucket Bindings**.
57 | 4. For extra security, set an `AUTH_KEY_SECRET` variable under **Settings > Environment Variables** (you can generate a random secret [here](https://generate-secret.vercel.app/)).
58 | 5. Replace all instances of `images.extrapolate.workers.dev` in the codebase with your Cloudflare Worker endpoint.
59 |
60 |
61 | Cloudflare Worker Code
62 |
63 | ```ts
64 | // Check requests for a pre-shared secret
65 | const hasValidHeader = (request, env) => {
66 | return request.headers.get("X-CF-Secret") === env.AUTH_KEY_SECRET;
67 | };
68 |
69 | function authorizeRequest(request, env, key) {
70 | switch (request.method) {
71 | case "PUT":
72 | case "DELETE":
73 | return hasValidHeader(request, env);
74 | case "GET":
75 | return true;
76 | default:
77 | return false;
78 | }
79 | }
80 |
81 | export default {
82 | async fetch(request, env) {
83 | const url = new URL(request.url);
84 | const key = url.pathname.slice(1);
85 |
86 | if (!authorizeRequest(request, env, key)) {
87 | return new Response("Forbidden", { status: 403 });
88 | }
89 |
90 | switch (request.method) {
91 | case "PUT":
92 | await env.MY_BUCKET.put(key, request.body);
93 | return new Response(`Put ${key} successfully!`);
94 | case "GET":
95 | const object = await env.MY_BUCKET.get(key);
96 |
97 | if (object === null) {
98 | return new Response("Object Not Found", { status: 404 });
99 | }
100 |
101 | const headers = new Headers();
102 | object.writeHttpMetadata(headers);
103 | headers.set("etag", object.httpEtag);
104 |
105 | return new Response(object.body, {
106 | headers,
107 | });
108 | case "DELETE":
109 | await env.MY_BUCKET.delete(key);
110 | return new Response("Deleted!");
111 |
112 | default:
113 | return new Response("Method Not Allowed", {
114 | status: 405,
115 | headers: {
116 | Allow: "PUT, GET, DELETE",
117 | },
118 | });
119 | }
120 | },
121 | };
122 | ```
123 |
124 |
125 |
126 | ## Author
127 |
128 | - Steven Tey ([@steventey](https://twitter.com/steventey))
129 |
--------------------------------------------------------------------------------
/app/actions/billing.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { cookies } from "next/headers";
4 | import Stripe from "stripe";
5 | import { getDomain } from "@/lib/utils";
6 | import { redirect } from "next/navigation";
7 | import { createClient } from "@/lib/supabase/server";
8 |
9 | export async function billing() {
10 | const cookieStore = cookies();
11 | const supabase = createClient(cookieStore);
12 |
13 | const stripe = new Stripe(
14 | process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
15 | ? process.env.STRIPE_SECRET_KEY!
16 | : process.env.STRIPE_SECRET_KEY_TEST!,
17 | );
18 |
19 | const { data: userData, error } = await supabase
20 | .from("users")
21 | .select("*")
22 | .single();
23 | if (error) {
24 | return { message: "Unable to get user data", status: 400 };
25 | }
26 |
27 | const stripeBillingSession = await stripe.billingPortal.sessions.create({
28 | customer:
29 | process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
30 | ? userData.stripe_id!
31 | : userData.stripe_id_dev!,
32 | return_url: getDomain(),
33 | });
34 |
35 | redirect(stripeBillingSession.url);
36 | }
37 |
--------------------------------------------------------------------------------
/app/actions/checkout.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { cookies } from "next/headers";
4 | import { createClient } from "@/lib/supabase/server";
5 | import Stripe from "stripe";
6 | import { getDomain } from "@/lib/utils";
7 | import { redirect } from "next/navigation";
8 |
9 | export async function checkout({
10 | price_id,
11 | credits,
12 | }: {
13 | price_id: string;
14 | credits: number;
15 | }) {
16 | const cookieStore = cookies();
17 | const supabase = createClient(cookieStore);
18 |
19 | const stripe = new Stripe(
20 | process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
21 | ? process.env.STRIPE_SECRET_KEY!
22 | : process.env.STRIPE_SECRET_KEY_TEST!,
23 | );
24 |
25 | const { data: userData, error } = await supabase
26 | .from("users")
27 | .select("*")
28 | .single();
29 | if (error) {
30 | return { message: "Unable to get user data", status: 400 };
31 | }
32 |
33 | const stripeCheckoutSession = await stripe.checkout.sessions.create({
34 | customer:
35 | process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
36 | ? userData.stripe_id!
37 | : userData.stripe_id_dev!,
38 | client_reference_id: userData?.id,
39 | // TODO: modal to show result
40 | success_url: getDomain(`/?success=true&credits=${credits}`),
41 | cancel_url: getDomain(`/?success=false&credits=${credits}`),
42 | line_items: [
43 | {
44 | price: price_id,
45 | quantity: 1,
46 | },
47 | ],
48 | metadata: {
49 | credits: credits,
50 | dubCustomerId: userData.id,
51 | },
52 | invoice_creation: {
53 | enabled: true,
54 | },
55 | mode: "payment",
56 | });
57 |
58 | redirect(stripeCheckoutSession.url!);
59 | }
60 |
--------------------------------------------------------------------------------
/app/actions/deleteAccount.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { createAdminClient } from "@/lib/supabase/admin";
4 | import { createClient } from "@/lib/supabase/server";
5 | import { cookies } from "next/headers";
6 |
7 | type FormState = {
8 | message: string;
9 | status: number;
10 | };
11 |
12 | export async function deleteAccount(prevState: FormState, formData: FormData) {
13 | const cookieStore = cookies();
14 | const supabase = createClient(cookieStore);
15 | const supabaseAdmin = createAdminClient();
16 |
17 | const confirmation = formData.get("deleteConfirmation") as string;
18 | if (confirmation !== "delete my account")
19 | return {
20 | message: `Confirmation failed`,
21 | status: 400,
22 | };
23 |
24 | // get user object
25 | const {
26 | data: { user },
27 | error: userError,
28 | } = await supabase.auth.getUser();
29 | if (userError)
30 | return {
31 | message: `Unable to get user object: ${userError.message}`,
32 | status: 400,
33 | };
34 | if (!user) return { message: `Unable to get user object`, status: 400 };
35 |
36 | // delete user input storage items
37 | const { error: storageInputError } = await supabaseAdmin.storage
38 | .from("input")
39 | .remove([`${user.id}`]);
40 | if (storageInputError)
41 | return {
42 | message: `Unable to delete user input images: ${storageInputError.message}`,
43 | status: 400,
44 | };
45 |
46 | // delete user output storage items
47 | const { error: storageOutputError } = await supabaseAdmin.storage
48 | .from("output")
49 | .remove([`${user.id}`]);
50 | if (storageOutputError)
51 | return {
52 | message: `Unable to delete user output images: ${storageOutputError.message}`,
53 | status: 400,
54 | };
55 |
56 | // delete user data
57 | const { error } = await supabase.from("data").delete().eq("user_id", user.id);
58 |
59 | // delete user
60 | const { data: deleteData, error: deleteError } =
61 | await supabaseAdmin.auth.admin.deleteUser(user.id);
62 | if (error)
63 | return {
64 | message: `Unable to delete user: ${deleteError?.message}`,
65 | status: 400,
66 | };
67 |
68 | return {
69 | message: `Successfully deleted account for ${deleteData.user?.email}. Sign out or refresh the page`,
70 | status: 200,
71 | };
72 | }
73 |
--------------------------------------------------------------------------------
/app/actions/upload.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import Replicate from "replicate";
4 | import { createAdminClient } from "@/lib/supabase/admin";
5 | import { cookies } from "next/headers";
6 | import { createClient } from "@/lib/supabase/server";
7 | import { nanoid } from "nanoid";
8 | import { redirect } from "next/navigation";
9 | import { getDomain } from "@/lib/utils";
10 | // import { waitUntil } from "@vercel/functions";
11 |
12 | export async function upload(previousState: any, formData: FormData) {
13 | const replicate = new Replicate({
14 | // get your token from https://replicate.com/account
15 | auth: process.env.REPLICATE_API_TOKEN || "",
16 | });
17 |
18 | // Authenticate
19 | const cookieStore = cookies();
20 | const supabase = createClient(cookieStore);
21 | const {
22 | data: { user },
23 | } = await supabase.auth.getUser();
24 | const user_id = user?.id;
25 | if (!user_id) return { message: "Please sign in to continue", status: 401 };
26 |
27 | // Check credits
28 | const credits = await getCredits(user_id);
29 | if (!credits || credits < 10)
30 | return { message: "Not enough credits, please buy more", status: 402 };
31 |
32 | const supabaseAdmin = createAdminClient();
33 |
34 | const image = formData.get("image") as File;
35 | if (!image) {
36 | return { message: "Missing image", status: 400 };
37 | }
38 |
39 | // Handle request
40 | // Generate key and insert id to supabase
41 | const { key } = await setRandomKey(user_id);
42 | const input = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/input/${user_id}/${key}`;
43 |
44 | const buffer = await image.arrayBuffer();
45 |
46 | const { data: storageData, error: storageError } = await supabaseAdmin.storage
47 | .from("input")
48 | .upload(`/${user_id}/${key}`, buffer, {
49 | contentType: image.type,
50 | cacheControl: "3600",
51 | upsert: true,
52 | });
53 | if (storageError)
54 | return {
55 | message: "Unexpected error uploading image, please try again",
56 | status: 400,
57 | };
58 |
59 | try {
60 | const prediction = await replicate.predictions.create({
61 | version:
62 | "9222a21c181b707209ef12b5e0d7e94c994b58f01c7b2fec075d2e892362f13c",
63 | input: {
64 | image: input,
65 | target_age: "default",
66 | },
67 | webhook: getDomain(`/api/webhooks/replicate/${key}`),
68 | webhook_events_filter: ["completed"],
69 | });
70 |
71 | if (
72 | prediction.error ||
73 | prediction.status === "failed" ||
74 | prediction.status === "canceled"
75 | ) {
76 | return { message: "Prediction error generating gif", status: 500 };
77 | }
78 | } catch (e) {
79 | return {
80 | message: "Unexpected error generating gif, please try again",
81 | status: 500,
82 | };
83 | }
84 |
85 | await updateCredits(user_id, -10);
86 |
87 | redirect(`/p/${key}`);
88 | }
89 |
90 | // Generates new key that doesn't already exist in db
91 | async function setRandomKey(user_id: string): Promise<{ key: string }> {
92 | const cookieStore = cookies();
93 | const supabase = createClient(cookieStore);
94 |
95 | /* recursively set link till successful */
96 | const key = nanoid();
97 | const { error } = await supabase.from("data").insert({
98 | id: key,
99 | input: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/input/${user_id}/${key}`,
100 | });
101 | if (error) {
102 | // by the off chance that key already exists
103 | return setRandomKey(user_id);
104 | } else {
105 | return { key };
106 | }
107 | }
108 |
109 | async function getCredits(user_id: string) {
110 | const supabaseAdmin = createAdminClient();
111 |
112 | const { data } = await supabaseAdmin
113 | .from("users")
114 | .select("credits")
115 | .eq("id", user_id)
116 | .single();
117 | return data?.credits;
118 | }
119 |
120 | async function updateCredits(user_id: string, credit_amount: number) {
121 | const supabaseAdmin = createAdminClient();
122 |
123 | await supabaseAdmin.rpc("update_credits", {
124 | user_id: user_id,
125 | credit_amount: credit_amount,
126 | });
127 | }
128 |
--------------------------------------------------------------------------------
/app/actions/uploadAgePredict.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import Replicate, { Prediction } from "replicate";
4 | import { createAdminClient } from "@/lib/supabase/admin";
5 | import { cookies } from "next/headers";
6 | import { createClient } from "@/lib/supabase/server";
7 | import { nanoid } from "nanoid";
8 | import { waitUntil } from "@vercel/functions";
9 |
10 | export async function uploadAgePredict(previousState: any, formData: FormData) {
11 | const replicate = new Replicate({
12 | // get your token from https://replicate.com/account
13 | auth: process.env.REPLICATE_API_TOKEN || "",
14 | });
15 |
16 | // Authenticate
17 | const cookieStore = cookies();
18 | const supabase = createClient(cookieStore);
19 | const {
20 | data: { user },
21 | } = await supabase.auth.getUser();
22 | const user_id = user?.id;
23 | if (!user_id) return { message: "Please sign in to continue", status: 401 };
24 |
25 | // get image
26 | const image = formData.get("image") as File;
27 | if (!image) {
28 | return { message: "Missing image", status: 400 };
29 | }
30 |
31 | const buffer = await image.arrayBuffer();
32 |
33 | const supabaseAdmin = createAdminClient();
34 |
35 | const { data: storageData, error: storageError } = await supabaseAdmin.storage
36 | .from("temp")
37 | .upload(`/${user_id}/${nanoid()}`, buffer, {
38 | contentType: image.type,
39 | cacheControl: "3600",
40 | upsert: true,
41 | });
42 | if (storageError)
43 | return {
44 | message: "Unexpected error uploading image, please try again",
45 | status: 400,
46 | };
47 |
48 | try {
49 | const prediction = await replicate.predictions.create({
50 | version:
51 | "0c3080879f50097e4f7847c68a22d9586fd69196bed06239b85688d02c93d2eb",
52 | input: {
53 | image: `https://zufrwdcmaojotovkjeww.supabase.co/storage/v1/object/public/temp/${storageData?.path}`,
54 | },
55 | });
56 |
57 | if (
58 | prediction.error ||
59 | prediction.status === "failed" ||
60 | prediction.status === "canceled"
61 | ) {
62 | return { message: "Prediction error generating age", status: 500 };
63 | }
64 |
65 | const get = prediction.urls.get;
66 | const output = pollExtrapolate({ url: get, timeout: 5000 });
67 | waitUntil(deleteImage({ path: storageData?.path }));
68 | return output;
69 | } catch (e) {
70 | console.log("e", e);
71 | return {
72 | message: "Unexpected error generating age, please try again",
73 | status: 500,
74 | };
75 | }
76 | }
77 |
78 | async function deleteImage({ path }: { path: string }) {
79 | const supabaseAdmin = createAdminClient();
80 | await supabaseAdmin.storage.from("temp").remove([path]);
81 | }
82 |
83 | async function pollExtrapolate({
84 | url,
85 | timeout,
86 | }: {
87 | url: string;
88 | timeout: number;
89 | }) {
90 | const startTime = new Date().getTime();
91 |
92 | for (let i = 0; ; i++) {
93 | try {
94 | const response = await fetch(url, {
95 | headers: {
96 | Authorization: `Bearer ${process.env.REPLICATE_API_TOKEN}`,
97 | },
98 | });
99 | const data: Prediction = await response.json();
100 | if (data.status === "succeeded") {
101 | return {
102 | message: `You look like you are ${data.output} years old.`,
103 | status: 200,
104 | };
105 | }
106 | } catch (error) {
107 | return { message: "Unexpected error occurred", status: 500 };
108 | }
109 |
110 | // Check for timeout
111 | const currentTime = new Date().getTime();
112 | if (currentTime - startTime > timeout) {
113 | return { message: "Function timed out", status: 504 };
114 | }
115 |
116 | // Wait 0.5 seconds before polling again
117 | await new Promise((resolve) => setTimeout(resolve, 500));
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/app/api/auth/callback/route.ts:
--------------------------------------------------------------------------------
1 | import { cookies } from "next/headers";
2 | import { NextResponse } from "next/server";
3 | import { createClient } from "@/lib/supabase/server";
4 | import { waitUntil } from "@vercel/functions";
5 | import { dub } from "@/lib/dub";
6 |
7 | export async function GET(request: Request) {
8 | const { searchParams, origin } = new URL(request.url);
9 | const code = searchParams.get("code");
10 | // if "next" is in param, use it as the redirect URL
11 | const next = searchParams.get("next") ?? "/";
12 |
13 | if (code) {
14 | const supabase = createClient(cookies());
15 | const { data, error } = await supabase.auth.exchangeCodeForSession(code);
16 | if (!error) {
17 | const { user } = data;
18 | const clickId =
19 | cookies().get("dub_id")?.value || cookies().get("dclid")?.value;
20 | const isNewUser =
21 | new Date(user.created_at) > new Date(Date.now() - 10 * 60 * 1000);
22 | // if the user is new and has a clickId cookie, track the lead
23 | if (clickId && isNewUser) {
24 | waitUntil(
25 | dub.track.lead({
26 | clickId,
27 | eventName: "Sign Up",
28 | customerId: user.id,
29 | customerName: user.user_metadata.name,
30 | customerEmail: user.email,
31 | customerAvatar: user.user_metadata.avatar_url,
32 | }),
33 | );
34 | // delete the clickId cookie
35 | cookies().delete("dub_id");
36 | cookies().delete("dclid");
37 | }
38 | return NextResponse.redirect(`${origin}${next}`);
39 | }
40 | }
41 |
42 | // return the user to an error page with instructions
43 | return NextResponse.redirect(`${origin}/auth/auth-code-error`);
44 | }
45 |
--------------------------------------------------------------------------------
/app/api/webhooks/replicate/[id]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server";
2 | import { createAdminClient } from "@/lib/supabase/admin";
3 |
4 | export const runtime = "edge";
5 |
6 | export async function POST(req: NextRequest) {
7 | const id = req.nextUrl.pathname.split("/")[4];
8 | const { output, status } = await req.json();
9 |
10 | const supabase = createAdminClient();
11 |
12 | // get user_id
13 | const { data, error } = await supabase
14 | .from("data")
15 | .select("user_id")
16 | .eq("id", id)
17 | .single();
18 | if (error)
19 | return new Response(`Error getting user_id: ${error.message}`, {
20 | status: 400,
21 | });
22 |
23 | // Prediction successful --> upload output to supabase storage --> update db output url --> user receives output via supabase realtime
24 | if (status === "succeeded") {
25 | const blob = await fetch(output).then((res) => res.blob());
26 | const { data: storageData, error: storageError } = await supabase.storage
27 | .from("output")
28 | .upload(`/${data?.user_id}/${id}`, blob, {
29 | contentType: blob.type,
30 | cacheControl: "3600",
31 | upsert: true,
32 | });
33 | if (storageError)
34 | new Response(`Error saving output: ${storageError.message}`, {
35 | status: 400,
36 | });
37 | const outputURL = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/output/${storageData?.path}`;
38 |
39 | const { error } = await supabase
40 | .from("data")
41 | .update({
42 | output: outputURL,
43 | })
44 | .eq("id", id);
45 | if (error)
46 | new Response(`Error updating output url: ${error.message}`, {
47 | status: 400,
48 | });
49 | }
50 |
51 | // Prediction failed --> update db --> user receives failed via supabase realtime --> user gets credits returned
52 | else if (status === "failed" || status === "cancelled") {
53 | // update db
54 | const { error } = await supabase
55 | .from("data")
56 | .update({ failed: true })
57 | .eq("id", id);
58 | if (error)
59 | new Response(`Error updating failed: ${error.message}`, { status: 400 });
60 |
61 | // get user_id for that prediction
62 | const { data, error: user_id_error } = await supabase
63 | .from("data")
64 | .select("user_id")
65 | .eq("id", id)
66 | .single();
67 | if (user_id_error || !data.user_id)
68 | new Response(`Error getting user_id: ${user_id_error?.message}`, {
69 | status: 400,
70 | });
71 |
72 | // if user_id exists, add 10 credits since prediction failed
73 | if (data?.user_id) {
74 | const { error } = await supabase.rpc("update_credits", {
75 | user_id: data.user_id,
76 | credit_amount: 10,
77 | });
78 | if (error)
79 | new Response(`Error returning credits: ${error.message}`, {
80 | status: 400,
81 | });
82 | }
83 | }
84 |
85 | return new Response("OK", { status: 200 });
86 | }
87 |
--------------------------------------------------------------------------------
/app/api/webhooks/stripe/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server";
2 | import Stripe from "stripe";
3 | import { createAdminClient } from "@/lib/supabase/admin";
4 | import type { StripePrice, StripeProduct } from "@/lib/types";
5 | import type { PostgrestError } from "@supabase/supabase-js";
6 |
7 | export const runtime = "edge";
8 |
9 | export async function POST(req: NextRequest) {
10 | const body = await req.text();
11 | let event: Stripe.Event;
12 |
13 | const supabase = createAdminClient();
14 | const stripe = new Stripe(
15 | process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
16 | ? process.env.STRIPE_SECRET_KEY!
17 | : process.env.STRIPE_SECRET_KEY_TEST!,
18 | );
19 |
20 | // verify webhook
21 | const webhookSecret =
22 | process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
23 | ? process.env.STRIPE_WEBHOOK_SECRET
24 | : process.env.STRIPE_WEBHOOK_SECRET_TEST;
25 | const signature = req.headers.get("stripe-signature");
26 | try {
27 | if (!webhookSecret || !signature) {
28 | console.log("Webhook secret not found.");
29 | return new Response("Webhook secret not found.", { status: 400 });
30 | }
31 | event = await stripe.webhooks.constructEventAsync(
32 | body,
33 | signature,
34 | webhookSecret,
35 | );
36 | } catch (error) {
37 | console.log(`Webhook Error: ${error}`);
38 | return new Response(`Webhook Error: ${error}`, { status: 400 });
39 | }
40 |
41 | const product = event.data.object as StripeProduct;
42 | const price = event.data.object as StripePrice;
43 | const checkout = event.data.object as Stripe.Checkout.Session;
44 |
45 | let error: PostgrestError | null = null;
46 |
47 | // Handle the event
48 | switch (event.type) {
49 | // Product
50 | case "product.created":
51 | const { error: product_insert_error } = await supabase
52 | .from("products")
53 | .insert(product);
54 | error = product_insert_error;
55 | break;
56 | case "product.updated":
57 | const { error: product_update_error } = await supabase
58 | .from("products")
59 | .update(product)
60 | .eq("id", product.id);
61 | error = product_update_error;
62 | break;
63 | case "product.deleted":
64 | const { error: product_delete_error } = await supabase
65 | .from("products")
66 | .delete()
67 | .eq("id", product.id);
68 | error = product_delete_error;
69 | break;
70 |
71 | // Price
72 | case "price.created":
73 | const { error: price_insert_error } = await supabase
74 | .from("prices")
75 | .insert(price);
76 | error = price_insert_error;
77 | break;
78 | case "price.updated":
79 | const { error: price_update_error } = await supabase
80 | .from("prices")
81 | .update(price)
82 | .eq("id", price.id);
83 | error = price_update_error;
84 | break;
85 | case "price.deleted":
86 | const { error: price_delete_error } = await supabase
87 | .from("prices")
88 | .delete()
89 | .eq("id", price.id);
90 | error = price_delete_error;
91 | break;
92 |
93 | // Checkout
94 | case "checkout.session.completed":
95 | if (
96 | checkout.status === "complete" &&
97 | checkout.payment_status === "paid"
98 | ) {
99 | const { error: checkout_error } = await supabase.rpc("update_credits", {
100 | user_id: checkout.client_reference_id!,
101 | credit_amount: Number(checkout.metadata?.credits),
102 | });
103 | error = checkout_error;
104 | }
105 | break;
106 | default:
107 | // Unexpected event type
108 | console.log(`Unhandled event type ${event.type}.`);
109 | error = null;
110 | break;
111 | }
112 |
113 | if (error) {
114 | console.log(`Database Sync Error: ${error.message}`);
115 | return new Response(`Database Sync Error: ${error.message}`, {
116 | status: 400,
117 | });
118 | }
119 |
120 | return new Response("OK", { status: 200 });
121 | }
122 |
--------------------------------------------------------------------------------
/app/api/webhooks/supabase/customer/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server";
2 | import Stripe from "stripe";
3 | import { createAdminClient } from "@/lib/supabase/admin";
4 | import { UserData } from "@/lib/types";
5 |
6 | export const runtime = "edge";
7 |
8 | type SupabaseWebhook = {
9 | type: "INSERT" | "UPDATE" | "DELETE";
10 | table: string;
11 | record: { [key: string]: any } | null;
12 | schema: string;
13 | old_record: { [key: string]: any } | null;
14 | };
15 |
16 | export async function POST(req: NextRequest) {
17 | const body = (await req.json()) as SupabaseWebhook;
18 |
19 | const supabase = createAdminClient();
20 | const stripe = new Stripe(
21 | process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
22 | ? process.env.STRIPE_SECRET_KEY!
23 | : process.env.STRIPE_SECRET_KEY_TEST!,
24 | );
25 |
26 | const record = body.record as UserData;
27 | const old_record = body.old_record as UserData;
28 |
29 | switch (body.type) {
30 | case "INSERT":
31 | // create stripe customer
32 | const customer = await stripe.customers.create({
33 | name: record.name,
34 | email: record.email,
35 | metadata: {
36 | user_id: record.id,
37 | },
38 | });
39 |
40 | // add stripe_id to user on supabase
41 | await supabase
42 | .from("users")
43 | .update({ stripe_id: customer.id })
44 | .eq("id", record.id);
45 |
46 | // TODO: send welcome email?
47 |
48 | break;
49 |
50 | case "DELETE":
51 | // delete stripe customer
52 | await stripe.customers.del(old_record?.stripe_id!);
53 |
54 | // TODO: send bye email?
55 |
56 | break;
57 | }
58 |
59 | return new Response("OK", { status: 200 });
60 | }
61 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steven-tey/extrapolate/90031932a6492e15ee92ae16a9d9e9271440f2c3/app/favicon.ico
--------------------------------------------------------------------------------
/app/gallery/gallery-page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Balancer from "react-wrap-balancer";
4 | import PhotoBooth from "@/components/home/photo-booth";
5 | import { useRouter } from "next/navigation";
6 | import { DataProps } from "@/lib/types";
7 |
8 | export function GalleryPage({ data }: { data: DataProps[] | null }) {
9 | const router = useRouter();
10 | return (
11 |
12 |
13 | Gallery
14 |
15 |
16 | {data?.map((row) => (
17 |
router.push(`/p/${row.id}`)}
21 | >
22 |
30 |
31 | ))}
32 |
33 | {data?.length === 0 && (
34 |
35 |
Upload a photo to see your gallery!
36 |
37 | )}
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/app/gallery/page.tsx:
--------------------------------------------------------------------------------
1 | import { createClient } from "@/lib/supabase/server";
2 | import { cookies } from "next/headers";
3 | import { GalleryPage } from "@/app/gallery/gallery-page";
4 |
5 | export default async function Gallery() {
6 | const cookieStore = cookies();
7 | const supabase = createClient(cookieStore);
8 | const {
9 | data: { session },
10 | } = await supabase.auth.getSession();
11 | const { data } = await supabase
12 | .from("data")
13 | .select("*")
14 | .order("created_at", { ascending: false })
15 | .match({ user_id: session?.user.id || "", failed: false });
16 |
17 | return ;
18 | }
19 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "@/styles/globals.css";
2 | import localFont from "next/font/local";
3 | import { Inter } from "next/font/google";
4 | import { Metadata, Viewport } from "next";
5 | import cx from "classnames";
6 | import Navbar from "@/components/layout/navbar";
7 | import Script from "next/script";
8 | import { Analytics } from "@/components/analytics";
9 | import { Toaster } from "sonner";
10 | import { Analytics as DubAnalytics } from "@dub/analytics/react";
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 | metadataBase: new URL("https://extrapolate-new.vercel.app"),
24 | title: "Extrapolate - Transform your face with Artificial Intelligence",
25 | description:
26 | "Extrapolate is an app for you to see how well you age by transforming your face with Artificial Intelligence.",
27 | openGraph: {
28 | title: "Extrapolate - Transform your face with Artificial Intelligence",
29 | description:
30 | "Extrapolate is an app for you to see how well you age by transforming your face with Artificial Intelligence.",
31 | images: "https://ref.extrapolate.app/og",
32 | },
33 | twitter: {
34 | card: "summary_large_image",
35 | site: "@vercel",
36 | creator: "@steventey",
37 | title: "Extrapolate - Transform your face with Artificial Intelligence",
38 | description:
39 | "Extrapolate is an app for you to see how well you age by transforming your face with Artificial Intelligence.",
40 | images: "https://ref.extrapolate.app/og",
41 | },
42 | };
43 |
44 | export const viewport: Viewport = {
45 | width: "device-width",
46 | initialScale: 1,
47 | };
48 |
49 | const CRISP_SCRIPT = `window.$crisp=[];window.CRISP_WEBSITE_ID="90ba947c-995a-46b5-a829-437e81c72cfa";(function(){d=document;s=d.createElement("script");s.src="https://client.crisp.chat/l.js";s.async=1;d.getElementsByTagName("head")[0].appendChild(s);})();`;
50 |
51 | export default function RootLayout({
52 | children,
53 | }: Readonly<{
54 | children: React.ReactNode;
55 | }>) {
56 | return (
57 |
58 |
65 |
66 |
67 |
68 |
69 | {children}
70 |
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/app/p/[id]/not-found.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { motion } from "framer-motion";
4 | import { Upload } from "lucide-react";
5 | import { FADE_DOWN_ANIMATION_VARIANTS } from "@/lib/constants";
6 | import Link from "next/link";
7 | import { Button } from "@/components/ui/button";
8 |
9 | export default function NotFound() {
10 | return (
11 |
12 |
27 |
31 | Your Results
32 |
33 |
34 |
38 |
39 | Photo not found, please upload a new one.
40 |
41 |
42 |
43 |
44 | Upload another photo
45 |
46 |
47 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/app/p/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import PhotoPage from "@/app/p/[id]/photo-page";
2 | import { createClient } from "@/lib/supabase/server";
3 | import { cookies } from "next/headers";
4 | import { notFound } from "next/navigation";
5 |
6 | // export const revalidate = 1;
7 |
8 | // export async function generateStaticParams() {
9 | // return [];
10 | // }
11 |
12 | async function getData(id: string) {
13 | const cookieStore = cookies();
14 | const supabase = createClient(cookieStore);
15 | const { data } = await supabase
16 | .from("data")
17 | .select("*")
18 | .eq("id", id)
19 | .single();
20 |
21 | if (!data) return notFound();
22 |
23 | return data;
24 | }
25 |
26 | export default async function Photo({ params }: { params: { id: string } }) {
27 | const { id } = params;
28 | const fallbackData = await getData(id);
29 |
30 | return ;
31 | }
32 |
--------------------------------------------------------------------------------
/app/p/[id]/photo-page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { DataProps } from "@/lib/types";
4 | import { motion } from "framer-motion";
5 | import { FADE_DOWN_ANIMATION_VARIANTS } from "@/lib/constants";
6 | import PhotoBooth from "@/components/home/photo-booth";
7 | import { createClient } from "@/lib/supabase/client";
8 | import { useState } from "react";
9 |
10 | export default function PhotoPage({
11 | id,
12 | data: fallbackData,
13 | }: {
14 | id: string;
15 | data: DataProps;
16 | }) {
17 | const [data, setData] = useState(fallbackData);
18 |
19 | const supabase = createClient();
20 | const realtime = supabase.channel(id);
21 |
22 | if (!fallbackData?.output && !fallbackData?.failed) {
23 | realtime
24 | .on(
25 | "postgres_changes",
26 | {
27 | event: "UPDATE",
28 | schema: "public",
29 | table: "data",
30 | filter: `id=eq.${id}`,
31 | },
32 | async (payload) => {
33 | setData(payload.new as DataProps);
34 | await realtime.unsubscribe();
35 | await supabase.removeChannel(realtime);
36 | },
37 | )
38 | .subscribe();
39 | }
40 |
41 | return (
42 |
43 |
58 |
62 | Your Results
63 |
64 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import HomePage from "@/components/home-page";
2 | import { createClient } from "@/lib/supabase/server";
3 | import { cookies } from "next/headers";
4 |
5 | export const revalidate = 60;
6 |
7 | async function getCount() {
8 | const cookieStore = cookies();
9 | const supabase = createClient(cookieStore);
10 |
11 | const { count } = await supabase
12 | .from("data")
13 | .select("*", { count: "estimated", head: true });
14 | return count;
15 | }
16 |
17 | export default async function Home() {
18 | const count = await getCount();
19 |
20 | return ;
21 | }
22 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "styles/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/components/Banner.tsx:
--------------------------------------------------------------------------------
1 | export function Banner() {
2 | return (
3 |
6 |
7 |
8 |
🎉 Black Friday & Cyber Monday: Get 50% OFF with code BFCM2024! 🛍️
9 |
10 |
11 |
12 | )
13 | }
--------------------------------------------------------------------------------
/components/aceternity-ui/background-gradient.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { motion } from "framer-motion";
3 | import { cn } from "@/lib/utils";
4 |
5 | export const BackgroundGradient = ({
6 | children,
7 | className,
8 | containerClassName,
9 | animate = true,
10 | }: {
11 | children?: React.ReactNode;
12 | className?: string;
13 | containerClassName?: string;
14 | animate?: boolean;
15 | }) => {
16 | const variants = {
17 | initial: {
18 | backgroundPosition: "0 50%",
19 | },
20 | animate: {
21 | backgroundPosition: ["0, 50%", "100% 50%", "0 50%"],
22 | },
23 | };
24 | return (
25 |
26 |
47 |
68 |
69 |
{children}
70 |
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/components/age-predict-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogHeader,
7 | DialogTitle,
8 | } from "@/components/ui/dialog";
9 | import {
10 | Drawer,
11 | DrawerClose,
12 | DrawerContent,
13 | DrawerFooter,
14 | DrawerHeader,
15 | DrawerTitle,
16 | } from "@/components/ui/drawer";
17 | import { useMediaQuery } from "@/lib/hooks/use-media-query";
18 | import { Button } from "@/components/ui/button";
19 | import { create } from "zustand";
20 | import Image from "next/image";
21 | import { Separator } from "@/components/ui/separator";
22 | import { ChangeEvent, useCallback, useMemo, useState } from "react";
23 | import { LoadingDots } from "@/components/shared/icons";
24 | import { UploadCloud } from "lucide-react";
25 | import { useFormState, useFormStatus } from "react-dom";
26 | import { uploadAgePredict } from "@/app/actions/uploadAgePredict";
27 |
28 | type AgePredictDialogStore = {
29 | open: boolean;
30 | setOpen: (isOpen: boolean) => void;
31 | };
32 |
33 | export const useAgePredictDialog = create((set) => ({
34 | open: false,
35 | setOpen: (open) => set(() => ({ open: open })),
36 | }));
37 |
38 | export function AgePredictDialog() {
39 | const [open, setOpen] = useAgePredictDialog((s) => [s.open, s.setOpen]);
40 | const isDesktop = useMediaQuery("(min-width: 768px)");
41 |
42 | if (isDesktop) {
43 | return (
44 |
45 |
46 |
47 |
48 |
55 |
56 |
57 | Upload Photo
58 |
59 |
60 |
61 |
62 |
63 | {/* Upload */}
64 |
65 |
66 |
67 | );
68 | }
69 |
70 | return (
71 |
72 |
73 |
74 |
75 |
82 |
83 |
84 | Upload Photo
85 |
86 |
87 |
88 |
89 |
90 | {/* Upload */}
91 |
92 |
93 |
94 |
95 | Cancel
96 |
97 |
98 |
99 |
100 | );
101 | }
102 |
103 | export function UploadForm() {
104 | const [data, setData] = useState<{
105 | image: string | null;
106 | }>({
107 | image: null,
108 | });
109 |
110 | const [fileSizeTooBig, setFileSizeTooBig] = useState(false);
111 |
112 | const [dragActive, setDragActive] = useState(false);
113 |
114 | const onChangePicture = useCallback(
115 | (event: ChangeEvent) => {
116 | setFileSizeTooBig(false);
117 | const file = event.currentTarget.files && event.currentTarget.files[0];
118 | if (file) {
119 | if (file.size / 1024 / 1024 > 10) {
120 | setFileSizeTooBig(true);
121 | } else {
122 | const reader = new FileReader();
123 | reader.onload = (e) => {
124 | setData((prev) => ({ ...prev, image: e.target?.result as string }));
125 | };
126 | reader.readAsDataURL(file);
127 | }
128 | }
129 | },
130 | [setData],
131 | );
132 |
133 | // Move to useActionState in future release of Next.js
134 | const [state, uploadFormAction] = useFormState(uploadAgePredict, {
135 | message: "",
136 | status: 0,
137 | });
138 |
139 | return (
140 |