From 4aecadb7ad5415149dade89143da42ddec6dba4b Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 29 Jun 2023 10:50:44 -0500 Subject: [PATCH] App Router Migration (#221) --- .env.example | 25 +- .gitignore | 1 + .vscode/settings.json | 4 - README.md | 12 +- app/[domain]/[slug]/opengraph-image.tsx | 82 + app/[domain]/[slug]/page.tsx | 131 + app/[domain]/cta.tsx | 80 + app/[domain]/layout.tsx | 132 + app/[domain]/not-found.tsx | 19 + app/[domain]/page.tsx | 101 + app/api/auth/[...nextauth]/route.ts | 6 + app/api/domain/[slug]/verify/route.ts | 48 + app/api/generate/route.ts | 77 + app/api/migrate/route.ts | 55 + app/api/upload/route.ts | 26 + app/app/(auth)/layout.tsx | 14 + app/app/(auth)/login/login-button.tsx | 51 + app/app/(auth)/login/page.tsx | 36 + app/app/(dashboard)/layout.tsx | 16 + app/app/(dashboard)/loading.tsx | 12 + app/app/(dashboard)/page.tsx | 53 + app/app/(dashboard)/post/[id]/layout.tsx | 5 + app/app/(dashboard)/post/[id]/loading.tsx | 10 + app/app/(dashboard)/post/[id]/not-found.tsx | 18 + app/app/(dashboard)/post/[id]/page.tsx | 28 + .../(dashboard)/post/[id]/settings/page.tsx | 58 + app/app/(dashboard)/settings/page.tsx | 44 + .../(dashboard)/site/[id]/analytics/page.tsx | 46 + app/app/(dashboard)/site/[id]/layout.tsx | 9 + app/app/(dashboard)/site/[id]/loading.tsx | 16 + app/app/(dashboard)/site/[id]/not-found.tsx | 18 + app/app/(dashboard)/site/[id]/page.tsx | 53 + .../site/[id]/settings/appearance/page.tsx | 53 + .../site/[id]/settings/domains/page.tsx | 47 + .../(dashboard)/site/[id]/settings/layout.tsx | 53 + .../(dashboard)/site/[id]/settings/nav.tsx | 48 + .../(dashboard)/site/[id]/settings/page.tsx | 49 + app/app/(dashboard)/sites/page.tsx | 32 + app/favicon.ico | Bin 0 -> 535 bytes pages/home/index.tsx => app/home/page.tsx | 8 +- app/layout.tsx | 29 + app/providers.tsx | 14 + components/Cloudinary.tsx | 61 - components/Logo.tsx | 28 - components/LogoSmall.tsx | 26 - components/Modal.tsx | 59 - components/analytics.tsx | 153 + components/app/Layout.tsx | 204 - components/app/Loader.tsx | 13 - components/{BlogCard.tsx => blog-card.tsx} | 4 +- components/{BlurImage.tsx => blur-image.tsx} | 13 +- components/create-post-button.tsx | 34 + components/create-site-button.tsx | 20 + .../editor/bubble-menu/color-selector.tsx | 106 + components/editor/bubble-menu/index.tsx | 116 + .../editor/bubble-menu/node-selector.tsx | 135 + components/editor/default-content.tsx | 203 + components/editor/extensions/index.tsx | 125 + .../editor/extensions/slash-command.tsx | 426 + components/editor/index.tsx | 213 + components/editor/props.ts | 44 + components/editor/utils.ts | 116 + components/form/delete-post-form.tsx | 70 + components/form/delete-site-form.tsx | 70 + .../DomainCard.tsx => form/domain-card.tsx} | 77 +- components/form/domain-configuration.tsx | 143 + components/form/domain-status.tsx | 23 + components/form/index.tsx | 146 + components/form/loading-spinner.module.css | 79 + components/form/loading-spinner.tsx | 20 + components/form/uploader.tsx | 145 + components/form/use-domain-status.ts | 20 + components/icons/link.tsx | 31 - components/icons/loading-circle.tsx | 22 + .../{app => icons}/loading-dots.module.css | 0 components/{app => icons}/loading-dots.tsx | 0 components/icons/magic.tsx | 30 + components/icons/not-found.tsx | 30 - components/icons/plus.tsx | 24 - components/icons/search.tsx | 26 - components/icons/twitter.tsx | 23 - components/icons/x.tsx | 28 - components/logout-button.tsx | 15 + components/{mdx/Card.tsx => mdx.tsx} | 48 +- components/mdx/Examples.tsx | 18 - components/mdx/Tweet.tsx | 428 - components/modal/create-site.tsx | 105 + components/modal/index.tsx | 80 + components/modal/leaflet.tsx | 68 + components/modal/provider.tsx | 43 + components/nav.tsx | 213 + components/overview-sites-cta.tsx | 30 + components/overview-stats.tsx | 52 + components/placeholder-card.tsx | 12 + components/post-card.tsx | 61 + components/posts.tsx | 52 + components/profile.tsx | 36 + components/site-card.tsx | 56 + components/sites.tsx | 44 + components/sites/Layout.tsx | 170 - components/sites/Loader.tsx | 15 - components/uploader.tsx | 205 + lib/actions.ts | 362 + lib/api/domain.ts | 107 - lib/api/index.ts | 3 - lib/api/post.ts | 312 - lib/api/site.ts | 245 - lib/auth.ts | 125 + lib/domains.ts | 116 + lib/fetcher.ts | 10 - lib/fetchers.ts | 174 +- lib/hooks/use-window-size.ts | 38 + lib/remark-plugins.tsx | 79 +- lib/revalidate.ts | 28 - lib/twitter-media.ts | 37 - lib/twitter.ts | 112 - lib/types.ts | 58 + lib/useRequireAuth.ts | 21 - lib/utils.ts | 35 +- middleware.ts | 52 +- next.config.js | 10 +- package-lock.json | 6690 ---------------- package.json | 74 +- pages/_app.tsx | 23 - pages/_sites/[site]/[slug].tsx | 295 - pages/_sites/[site]/index.tsx | 211 - pages/api/auth/[...nextauth].ts | 58 - pages/api/domain/check.ts | 61 - pages/api/domain/index.ts | 25 - pages/api/post.ts | 31 - pages/api/request-delegation.ts | 28 - pages/api/revalidate.ts | 22 - pages/api/save-settings.ts | 34 - pages/api/site.ts | 31 - pages/app/index.tsx | 247 - pages/app/login.tsx | 108 - pages/app/post/[id]/index.tsx | 292 - pages/app/post/[id]/settings.tsx | 268 - pages/app/settings.tsx | 154 - pages/app/site/[id]/drafts.tsx | 158 - pages/app/site/[id]/index.tsx | 155 - pages/app/site/[id]/settings.tsx | 481 -- pnpm-lock.yaml | 7106 +++++++++++++++++ postcss.config.js | 4 +- .../20220113212622_init/migration.sql | 94 - .../20220118224901_init/migration.sql | 2 - .../20220119060612_init/migration.sql | 12 - .../20220126001309_init/migration.sql | 2 - .../20220126002115_init/migration.sql | 2 - prisma/migrations/migration_lock.toml | 3 - prisma/schema.prisma | 26 +- public/examples/cal.png | Bin 45527 -> 0 bytes public/examples/dub.png | Bin 381547 -> 0 bytes public/examples/hashnode.png | Bin 31805 -> 0 bytes public/examples/instatus.png | Bin 28626 -> 0 bytes public/examples/makeswift.jpeg | Bin 227563 -> 0 bytes public/examples/mirror.png | Bin 112947 -> 0 bytes public/examples/og/hashnode.png | Bin 55818 -> 0 bytes public/examples/og/makeswift.jpeg | Bin 168651 -> 0 bytes public/examples/og/mirror.png | Bin 82139 -> 0 bytes public/examples/og/readcv.png | Bin 260054 -> 0 bytes public/examples/og/super.jpeg | Bin 25045 -> 0 bytes public/examples/og/typedream.png | Bin 403601 -> 0 bytes public/examples/readcv.png | Bin 134095 -> 0 bytes public/examples/super.jpeg | Bin 301539 -> 0 bytes public/examples/typedream.png | Bin 104630 -> 0 bytes styles/CalSans-SemiBold.otf | Bin 0 -> 68292 bytes styles/CalSans-SemiBold.woff2 | Bin 26896 -> 0 bytes styles/fonts.ts | 6 +- styles/globals.css | 89 + tailwind.config.js | 134 +- tsconfig.json | 60 +- types/_site.ts | 21 - types/cloudinary.ts | 90 - types/common.ts | 12 - types/http.ts | 11 - types/icon.ts | 7 - types/index.ts | 9 - types/mdx.ts | 7 - types/next-auth.d.ts | 61 - types/seo.ts | 7 - types/settings.ts | 3 - types/twitter.ts | 198 - 183 files changed, 13445 insertions(+), 12367 deletions(-) delete mode 100644 .vscode/settings.json create mode 100644 app/[domain]/[slug]/opengraph-image.tsx create mode 100644 app/[domain]/[slug]/page.tsx create mode 100644 app/[domain]/cta.tsx create mode 100644 app/[domain]/layout.tsx create mode 100644 app/[domain]/not-found.tsx create mode 100644 app/[domain]/page.tsx create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/domain/[slug]/verify/route.ts create mode 100644 app/api/generate/route.ts create mode 100644 app/api/migrate/route.ts create mode 100644 app/api/upload/route.ts create mode 100644 app/app/(auth)/layout.tsx create mode 100644 app/app/(auth)/login/login-button.tsx create mode 100644 app/app/(auth)/login/page.tsx create mode 100644 app/app/(dashboard)/layout.tsx create mode 100644 app/app/(dashboard)/loading.tsx create mode 100644 app/app/(dashboard)/page.tsx create mode 100644 app/app/(dashboard)/post/[id]/layout.tsx create mode 100644 app/app/(dashboard)/post/[id]/loading.tsx create mode 100644 app/app/(dashboard)/post/[id]/not-found.tsx create mode 100644 app/app/(dashboard)/post/[id]/page.tsx create mode 100644 app/app/(dashboard)/post/[id]/settings/page.tsx create mode 100644 app/app/(dashboard)/settings/page.tsx create mode 100644 app/app/(dashboard)/site/[id]/analytics/page.tsx create mode 100644 app/app/(dashboard)/site/[id]/layout.tsx create mode 100644 app/app/(dashboard)/site/[id]/loading.tsx create mode 100644 app/app/(dashboard)/site/[id]/not-found.tsx create mode 100644 app/app/(dashboard)/site/[id]/page.tsx create mode 100644 app/app/(dashboard)/site/[id]/settings/appearance/page.tsx create mode 100644 app/app/(dashboard)/site/[id]/settings/domains/page.tsx create mode 100644 app/app/(dashboard)/site/[id]/settings/layout.tsx create mode 100644 app/app/(dashboard)/site/[id]/settings/nav.tsx create mode 100644 app/app/(dashboard)/site/[id]/settings/page.tsx create mode 100644 app/app/(dashboard)/sites/page.tsx create mode 100644 app/favicon.ico rename pages/home/index.tsx => app/home/page.tsx (61%) create mode 100644 app/layout.tsx create mode 100644 app/providers.tsx delete mode 100644 components/Cloudinary.tsx delete mode 100644 components/Logo.tsx delete mode 100644 components/LogoSmall.tsx delete mode 100644 components/Modal.tsx create mode 100644 components/analytics.tsx delete mode 100644 components/app/Layout.tsx delete mode 100644 components/app/Loader.tsx rename components/{BlogCard.tsx => blog-card.tsx} (92%) rename components/{BlurImage.tsx => blur-image.tsx} (56%) create mode 100644 components/create-post-button.tsx create mode 100644 components/create-site-button.tsx create mode 100644 components/editor/bubble-menu/color-selector.tsx create mode 100644 components/editor/bubble-menu/index.tsx create mode 100644 components/editor/bubble-menu/node-selector.tsx create mode 100644 components/editor/default-content.tsx create mode 100644 components/editor/extensions/index.tsx create mode 100644 components/editor/extensions/slash-command.tsx create mode 100644 components/editor/index.tsx create mode 100644 components/editor/props.ts create mode 100644 components/editor/utils.ts create mode 100644 components/form/delete-post-form.tsx create mode 100644 components/form/delete-site-form.tsx rename components/{app/DomainCard.tsx => form/domain-card.tsx} (66%) create mode 100644 components/form/domain-configuration.tsx create mode 100644 components/form/domain-status.tsx create mode 100644 components/form/index.tsx create mode 100644 components/form/loading-spinner.module.css create mode 100644 components/form/loading-spinner.tsx create mode 100644 components/form/uploader.tsx create mode 100644 components/form/use-domain-status.ts delete mode 100644 components/icons/link.tsx create mode 100644 components/icons/loading-circle.tsx rename components/{app => icons}/loading-dots.module.css (100%) rename components/{app => icons}/loading-dots.tsx (100%) create mode 100644 components/icons/magic.tsx delete mode 100644 components/icons/not-found.tsx delete mode 100644 components/icons/plus.tsx delete mode 100644 components/icons/search.tsx delete mode 100644 components/icons/twitter.tsx delete mode 100644 components/icons/x.tsx create mode 100644 components/logout-button.tsx rename components/{mdx/Card.tsx => mdx.tsx} (59%) delete mode 100644 components/mdx/Examples.tsx delete mode 100644 components/mdx/Tweet.tsx create mode 100644 components/modal/create-site.tsx create mode 100644 components/modal/index.tsx create mode 100644 components/modal/leaflet.tsx create mode 100644 components/modal/provider.tsx create mode 100644 components/nav.tsx create mode 100644 components/overview-sites-cta.tsx create mode 100644 components/overview-stats.tsx create mode 100644 components/placeholder-card.tsx create mode 100644 components/post-card.tsx create mode 100644 components/posts.tsx create mode 100644 components/profile.tsx create mode 100644 components/site-card.tsx create mode 100644 components/sites.tsx delete mode 100644 components/sites/Layout.tsx delete mode 100644 components/sites/Loader.tsx create mode 100644 components/uploader.tsx create mode 100644 lib/actions.ts delete mode 100644 lib/api/domain.ts delete mode 100644 lib/api/index.ts delete mode 100644 lib/api/post.ts delete mode 100644 lib/api/site.ts create mode 100644 lib/auth.ts create mode 100644 lib/domains.ts delete mode 100644 lib/fetcher.ts create mode 100644 lib/hooks/use-window-size.ts delete mode 100644 lib/revalidate.ts delete mode 100644 lib/twitter-media.ts delete mode 100644 lib/twitter.ts create mode 100644 lib/types.ts delete mode 100644 lib/useRequireAuth.ts delete mode 100644 package-lock.json delete mode 100644 pages/_app.tsx delete mode 100644 pages/_sites/[site]/[slug].tsx delete mode 100644 pages/_sites/[site]/index.tsx delete mode 100644 pages/api/auth/[...nextauth].ts delete mode 100644 pages/api/domain/check.ts delete mode 100644 pages/api/domain/index.ts delete mode 100644 pages/api/post.ts delete mode 100644 pages/api/request-delegation.ts delete mode 100644 pages/api/revalidate.ts delete mode 100644 pages/api/save-settings.ts delete mode 100644 pages/api/site.ts delete mode 100644 pages/app/index.tsx delete mode 100644 pages/app/login.tsx delete mode 100644 pages/app/post/[id]/index.tsx delete mode 100644 pages/app/post/[id]/settings.tsx delete mode 100644 pages/app/settings.tsx delete mode 100644 pages/app/site/[id]/drafts.tsx delete mode 100644 pages/app/site/[id]/index.tsx delete mode 100644 pages/app/site/[id]/settings.tsx create mode 100644 pnpm-lock.yaml delete mode 100644 prisma/migrations/20220113212622_init/migration.sql delete mode 100644 prisma/migrations/20220118224901_init/migration.sql delete mode 100644 prisma/migrations/20220119060612_init/migration.sql delete mode 100644 prisma/migrations/20220126001309_init/migration.sql delete mode 100644 prisma/migrations/20220126002115_init/migration.sql delete mode 100644 prisma/migrations/migration_lock.toml delete mode 100644 public/examples/cal.png delete mode 100644 public/examples/dub.png delete mode 100644 public/examples/hashnode.png delete mode 100644 public/examples/instatus.png delete mode 100644 public/examples/makeswift.jpeg delete mode 100644 public/examples/mirror.png delete mode 100644 public/examples/og/hashnode.png delete mode 100644 public/examples/og/makeswift.jpeg delete mode 100644 public/examples/og/mirror.png delete mode 100644 public/examples/og/readcv.png delete mode 100644 public/examples/og/super.jpeg delete mode 100644 public/examples/og/typedream.png delete mode 100644 public/examples/readcv.png delete mode 100644 public/examples/super.jpeg delete mode 100644 public/examples/typedream.png create mode 100644 styles/CalSans-SemiBold.otf delete mode 100644 styles/CalSans-SemiBold.woff2 delete mode 100644 types/_site.ts delete mode 100644 types/cloudinary.ts delete mode 100644 types/common.ts delete mode 100644 types/http.ts delete mode 100644 types/icon.ts delete mode 100644 types/index.ts delete mode 100644 types/mdx.ts delete mode 100644 types/next-auth.d.ts delete mode 100644 types/seo.ts delete mode 100644 types/settings.ts delete mode 100644 types/twitter.ts diff --git a/.env.example b/.env.example index 85788a0..263fb91 100644 --- a/.env.example +++ b/.env.example @@ -1,27 +1,28 @@ -# Rename to .env +# DON'T FORGET TO RENAME TO .env OR .env.local BEFORE PUSHING TO GIT ### DEVELOPMENT ONLY VARIABLES -# These variables need to be set -# for local development only +# These variables need to be set for local development only # Mandatory next-auth URL for localhost NEXTAUTH_URL=http://app.localhost:3000 ### PRODUCTION & DEVELOPMENT VARIABLES -# These variables need to be set -# for local development and when deployed on Vercel +# These variables need to be set for local development and when deployed on Vercel -# MySQL database URL for Prisma -DATABASE_URL=mysql://root@127.0.0.1:3309/platforms +# Change this to your own domain +NEXT_PUBLIC_ROOT_DOMAIN=vercel.pub -# GitHub OAuth https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app +# PostgreSQL database URL – get one here: https://vercel.com/docs/storage/vercel-postgres/quickstart +POSTGRES_PRISMA_URL= + +# Vercel Blob Storage for image uploads – get one here: https://vercel.com/docs/storage/blob/quickstart +BLOB_READ_WRITE_TOKEN= + +# GitHub OAuth for auth & login – get one here: https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app GITHUB_ID= GITHUB_SECRET= -# Twitter Auth Bearer token (for static tweets) -TWITTER_AUTH_TOKEN= - -# Secret key (generate one here: https://generate-secret.vercel.app/32) +# Secret key for NextAuth (generate one here: https://generate-secret.vercel.app/32) NEXTAUTH_SECRET= # https://vercel.com/account/tokens diff --git a/.gitignore b/.gitignore index aeb2b35..826798b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ **.env **.DS_Store **.vercel +.vscode **.git .vercel diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index d067910..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true -} \ No newline at end of file diff --git a/README.md b/README.md index 7d8cefc..837668c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ ## Introduction -Multi-tenant applications serve multiple customers across different subdomains/custom domains with a single unified codebase. +Multi-tenant applications serve multiple customers across different subdomains/custom domains with a single unified codebase. For example, our demo is a multi-tenant application: @@ -51,7 +51,7 @@ Forget manually setting up CNAME records, wrestling with DNS, or making custom s - **Custom domains**: Subdomain and custom domains support with [Edge Functions](https://vercel.com/features/edge-functions) and the [Vercel Domains API](https://domains-api.vercel.app/). - **Static generation with ISR**: Performance without sacrificing personalization, by combining [Incremental Static Regeneration](https://vercel.com/docs/concepts/next.js/incremental-static-regeneration) (ISR) and [Middleware](https://vercel.com/docs/concepts/functions/edge-functions#middleware). ISR allows you to create new content (with custom domains) on demand without needing to redeploy your application. -- **Uploading custom images**: Allow your customers to upload custom thumbnail images with our Cloudinary integration. +- **Uploading custom images**: Allow your customers to upload custom thumbnail images with [Vercel Blob](https://vercel.com/docs/storage/vercel-blob). - **Static tweets**: Avoid [Cumulative Layout Shift](https://vercel.com/blog/core-web-vitals) (CLS) from the native Twitter embed by using our [static tweets implementation](https://static-tweets-tailwind.vercel.app/) (supports image, video, gif, poll, retweets, quote retweets, and more). ## Examples of platforms @@ -60,7 +60,7 @@ Vercel customers like [Hashnode](https://vercel.com/customers/hashnode), [Super] ### 1. Content creation platforms -These are content-heavy platforms (blogs) with simple, standardized page layouts and route structure. +These are content-heavy platforms (blogs) with simple, standardized page layouts and route structure. > “With Vercel, we spend less time managing our infrastructure and more time delivering value to our users.” — Sandeep Panda, Co-founder, Hashnode @@ -70,7 +70,7 @@ These are content-heavy platforms (blogs) with simple, standardized page layouts ### 2. Website & e-commerce store builders -No-code site builders with customizable pages. +No-code site builders with customizable pages. By using Next.js and Vercel, [Super](https://super.so/) has fast, globally distributed websites with a no-code editor (Notion). Their customers get all the benefits of Next.js (like [Image Optimization](https://nextjs.org/docs/basic-features/image-optimization)) without touching any code. @@ -82,7 +82,7 @@ By using Next.js and Vercel, [Super](https://super.so/) has fast, globally distr Multi-tenant authentication, login, and access controls. -With Vercel and Next.js, platforms like [Instatus](https://instatus.com) are able to create status pages that are *10x faster* than competitors. +With Vercel and Next.js, platforms like [Instatus](https://instatus.com) are able to create status pages that are _10x faster_ than competitors. 1. [Instatus](https://instatus.com/) 2. [Cal.com](https://cal.com/) @@ -111,12 +111,10 @@ We also have another [example](https://github.com/vercel/examples/tree/main/solu The beauty about a serverless setup is you won’t have to worry about load since each request invokes a separate serverless function, and once it’s cached, you don’t invoke the server anymore (the page is served directly from the Vercel edge). Read more about the [Vercel Edge Network](https://vercel.com/docs/concepts/edge-network/overview) and [how caching works](https://vercel.com/docs/concepts/edge-network/caching). - ## Caveats - This template does not work with i18n, which is an [advanced feature in Next.js](https://nextjs.org/docs/advanced-features/i18n-routing). - ## Contributing - [Start a discussion](https://github.com/vercel/platforms/discussions) with a question, piece of feedback, or idea you want to share with the team. diff --git a/app/[domain]/[slug]/opengraph-image.tsx b/app/[domain]/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..def357a --- /dev/null +++ b/app/[domain]/[slug]/opengraph-image.tsx @@ -0,0 +1,82 @@ +/* eslint-disable @next/next/no-img-element */ + +import { truncate } from "@/lib/utils"; +import { ImageResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export const runtime = "edge"; + +export default async function PostOG({ + params, +}: { + params: { domain: string; slug: string }; +}) { + const { domain, slug } = params; + + const subdomain = domain.endsWith(`.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`) + ? domain.replace(`.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`, "") + : null; + + const response = await sql` + SELECT post.title, post.description, post.image, "user".name as "authorName", "user".image as "authorImage" + FROM "Post" AS post + INNER JOIN "Site" AS site ON post."siteId" = site.id + INNER JOIN "User" AS "user" ON site."userId" = "user".id + WHERE + ( + site.subdomain = ${subdomain} + OR site."customDomain" = ${domain} + ) + AND post.slug = ${slug} + LIMIT 1; +`; + + const data = response.rows[0]; + + if (!data) { + return new Response("Not found", { status: 404 }); + } + + const clashData = await fetch( + new URL("@/styles/CalSans-SemiBold.otf", import.meta.url) + ).then((res) => res.arrayBuffer()); + + return new ImageResponse( + ( +
+
+

+ {data.title} +

+

+ {truncate(data.description, 120)} +

+
+ {data.authorName} +

by {data.authorName}

+
+ {data.title} +
+
+ ), + { + width: 1200, + height: 600, + fonts: [ + { + name: "Clash", + data: clashData, + }, + ], + emoji: "blobmoji", + } + ); +} diff --git a/app/[domain]/[slug]/page.tsx b/app/[domain]/[slug]/page.tsx new file mode 100644 index 0000000..317ab2b --- /dev/null +++ b/app/[domain]/[slug]/page.tsx @@ -0,0 +1,131 @@ +import { notFound } from "next/navigation"; +import { getPostData } from "@/lib/fetchers"; +import BlogCard from "@/components/blog-card"; +import BlurImage from "@/components/blur-image"; +import MDX from "@/components/mdx"; +import { placeholderBlurhash, toDateString } from "@/lib/utils"; + +export async function generateMetadata({ + params, +}: { + params: { domain: string; slug: string }; +}) { + const { domain, slug } = params; + const data = await getPostData(domain, slug); + if (!data) { + notFound(); + } + const { title, description } = data; + + return { + title, + description, + openGraph: { + title, + description, + }, + twitter: { + card: "summary_large_image", + title, + description, + creator: "@vercel", + }, + }; +} + +export default async function SitePostPage({ + params, +}: { + params: { domain: string; slug: string }; +}) { + const { domain, slug } = params; + const data = await getPostData(domain, slug); + + if (!data) { + notFound(); + } + + return ( + <> +
+
+

+ {toDateString(data.createdAt)} +

+

+ {data.title} +

+

+ {data.description} +

+
+ +
+
+ {data.site?.user?.image ? ( + + ) : ( +
+ ? +
+ )} +
+
+ by {data.site?.user?.name} +
+
+
+
+
+ +
+ + + + {data.adjacentPosts.length > 0 && ( +
+ + )} + {data.adjacentPosts && ( +
+ {data.adjacentPosts.map((data, index) => ( + + ))} +
+ )} + + ); +} diff --git a/app/[domain]/cta.tsx b/app/[domain]/cta.tsx new file mode 100644 index 0000000..76298d7 --- /dev/null +++ b/app/[domain]/cta.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; + +export default function CTA() { + const [closeCTA, setCloseCTA] = useState(false); + return ( +
+ +
+

+ Platforms Starter Kit Demo +

+

+ This is a demo site showcasing how to build a multi-tenant application + with{" "} + + custom domain + {" "} + support. +

+
+
+ + Create your publication + + + Clone and deploy + +
+
+ ); +} diff --git a/app/[domain]/layout.tsx b/app/[domain]/layout.tsx new file mode 100644 index 0000000..ef2e2d8 --- /dev/null +++ b/app/[domain]/layout.tsx @@ -0,0 +1,132 @@ +import Image from "next/image"; +import Link from "next/link"; +import { ReactNode } from "react"; +import prisma from "@/lib/prisma"; +import CTA from "./cta"; +import { notFound, redirect } from "next/navigation"; +import { getSiteData } from "@/lib/fetchers"; +import { fontMapper } from "@/styles/fonts"; +import { Metadata } from "next"; + +export async function generateMetadata({ + params, +}: { + params: { domain: string }; +}): Promise { + const data = await getSiteData(params.domain); + if (!data) { + notFound(); + } + const { + name: title, + description, + image, + logo, + } = data as { + name: string; + description: string; + image: string; + logo: string; + }; + + return { + title, + description, + openGraph: { + title, + description, + images: [image], + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [image], + creator: "@vercel", + }, + icons: [logo], + }; +} + +export async function generateStaticParams() { + const [subdomains, customDomains] = await Promise.all([ + prisma.site.findMany({ + select: { + subdomain: true, + }, + }), + prisma.site.findMany({ + where: { + NOT: { + customDomain: null, + }, + }, + select: { + customDomain: true, + }, + }), + ]); + + const allPaths = [ + ...subdomains.map(({ subdomain }) => subdomain), + ...customDomains.map(({ customDomain }) => customDomain), + ].filter((path) => path) as Array; + + return allPaths.map((domain) => ({ + params: { + domain, + }, + })); +} + +export default async function SiteLayout({ + params, + children, +}: { + params: { domain: string }; + children: ReactNode; +}) { + const { domain } = params; + const data = await getSiteData(domain); + + if (!data) { + notFound(); + } + + // Optional: Redirect to custom domain if it exists + if ( + domain.endsWith(`.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`) && + data.customDomain && + process.env.REDIRECT_TO_CUSTOM_DOMAIN_IF_EXISTS === "true" + ) { + return redirect(`https://${data.customDomain}`); + } + + return ( +
+
+
+ +
+ {data.name +
+ + {data.name} + + +
+
+ +
{children}
+ + {params.domain == `demo.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}` && ( + + )} +
+ ); +} diff --git a/app/[domain]/not-found.tsx b/app/[domain]/not-found.tsx new file mode 100644 index 0000000..eb98ebd --- /dev/null +++ b/app/[domain]/not-found.tsx @@ -0,0 +1,19 @@ +import Image from "next/image"; + +export default function NotFound() { + console.log("404 on app/[domain]/not-found.tsx"); + return ( +
+

404

+ missing site +

+ Blimey! You’ve found a page that doesn’t exist. +

+
+ ); +} diff --git a/app/[domain]/page.tsx b/app/[domain]/page.tsx new file mode 100644 index 0000000..f72256c --- /dev/null +++ b/app/[domain]/page.tsx @@ -0,0 +1,101 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import BlurImage from "@/components/blur-image"; +import { placeholderBlurhash, toDateString } from "@/lib/utils"; +import BlogCard from "@/components/blog-card"; +import { getPostsForSite, getSiteData } from "@/lib/fetchers"; +import Image from "next/image"; + +export default async function SiteHomePage({ + params, +}: { + params: { domain: string }; +}) { + const [data, posts] = await Promise.all([ + getSiteData(params.domain), + getPostsForSite(params.domain), + ]); + + if (!data) { + notFound(); + } + + return ( + <> +
+ {posts.length > 0 ? ( +
+ +
+ +
+
+

+ {posts[0].title} +

+

+ {posts[0].description} +

+
+
+ {data.user?.image ? ( + + ) : ( +
+ ? +
+ )} +
+

+ {data.user?.name} +

+
+

+ {toDateString(posts[0].createdAt)} +

+
+
+ +
+ ) : ( +
+ missing post +

No posts yet.

+
+ )} +
+ + {posts.length > 1 && ( +
+

+ More stories +

+
+ {posts.slice(1).map((metadata, index) => ( + + ))} +
+
+ )} + + ); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..ca0b5b4 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import { authOptions } from "@/lib/auth"; +import NextAuth from "next-auth"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/app/api/domain/[slug]/verify/route.ts b/app/api/domain/[slug]/verify/route.ts new file mode 100644 index 0000000..8ba8ccf --- /dev/null +++ b/app/api/domain/[slug]/verify/route.ts @@ -0,0 +1,48 @@ +import { + getConfigResponse, + getDomainResponse, + verifyDomain, +} from "@/lib/domains"; +import { DomainVerificationStatusProps } from "@/lib/types"; +import { NextResponse } from "next/server"; + +export async function GET( + _req: Request, + { params }: { params: { slug: string } } +) { + const domain = params.slug; + let status: DomainVerificationStatusProps = "Valid Configuration"; + + const [domainJson, configJson] = await Promise.all([ + getDomainResponse(domain), + getConfigResponse(domain), + ]); + + if (domainJson?.error?.code === "not_found") { + // domain not found on Vercel project + status = "Domain Not Found"; + + // unknown error + } else if (domainJson.error) { + status = "Unknown Error"; + + // if domain is not verified, we try to verify now + } else if (!domainJson.verified) { + status = "Pending Verification"; + const verificationJson = await verifyDomain(domain); + + // domain was just verified + if (verificationJson && verificationJson.verified) { + status = "Valid Configuration"; + } + } else if (configJson.misconfigured) { + status = "Invalid Configuration"; + } else { + status = "Valid Configuration"; + } + + return NextResponse.json({ + status, + domainJson, + }); +} diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts new file mode 100644 index 0000000..b8be941 --- /dev/null +++ b/app/api/generate/route.ts @@ -0,0 +1,77 @@ +import { Configuration, OpenAIApi } from "openai-edge"; +import { OpenAIStream, StreamingTextResponse } from "ai"; +// import { kv } from "@vercel/kv"; +// import { Ratelimit } from "@upstash/ratelimit"; + +const config = new Configuration({ + apiKey: process.env.OPENAI_API_KEY, +}); +const openai = new OpenAIApi(config); + +export const runtime = "edge"; + +export async function POST(req: Request): Promise { + // if ( + // process.env.NODE_ENV != "development" && + // process.env.KV_REST_API_URL && + // process.env.KV_REST_API_TOKEN + // ) { + // const ip = req.headers.get("x-forwarded-for"); + // const ratelimit = new Ratelimit({ + // redis: kv, + // limiter: Ratelimit.slidingWindow(50, "1 d"), + // }); + + // const { success, limit, reset, remaining } = await ratelimit.limit( + // `platforms_ratelimit_${ip}` + // ); + + // if (!success) { + // return new Response("You have reached your request limit for the day.", { + // status: 429, + // headers: { + // "X-RateLimit-Limit": limit.toString(), + // "X-RateLimit-Remaining": remaining.toString(), + // "X-RateLimit-Reset": reset.toString(), + // }, + // }); + // } + // } + + let { prompt: content } = await req.json(); + + // remove trailing slash, + // slice the content from the end to prioritize later characters + content = content.replace(/\/$/, "").slice(-5000); + + const response = await openai.createChatCompletion({ + model: "gpt-3.5-turbo", + messages: [ + { + role: "system", + content: + "You are an AI writing assistant that continues existing text based on context from prior text. " + + "Give more weight/priority to the later characters than the beginning ones. " + + "Limit your response to no more than 200 characters, but make sure to construct complete sentences.", + // we're disabling markdown for now until we can figure out a way to stream markdown text with proper formatting: https://github.com/steven-tey/novel/discussions/7 + // "Use Markdown formatting when appropriate.", + }, + { + role: "user", + content, + }, + ], + temperature: 0.7, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + stream: true, + n: 1, + }); + + // Convert the response into a friendly text-stream + const stream = OpenAIStream(response); + + // Respond with the stream + return new StreamingTextResponse(stream); +} diff --git a/app/api/migrate/route.ts b/app/api/migrate/route.ts new file mode 100644 index 0000000..785ddc3 --- /dev/null +++ b/app/api/migrate/route.ts @@ -0,0 +1,55 @@ +/* + This was the migration script we used to migrate from + our old database to the new Vercel Postgres database. + It's not needed anymore, but I'm keeping it here for + posterity. +*/ + +import { NextResponse } from "next/server"; +// import prisma from "@/lib/prisma"; + +export async function GET() { + // Download data from old database + // const users = await prisma.user.findMany(); + // const accounts = await prisma.account.findMany(); + // const sites = await prisma.site.findMany(); + // const posts = await prisma.post.findMany(); + // const examples = await prisma.example.findMany(); + + // fs.writeFileSync("users.json", JSON.stringify(users)); + // fs.writeFileSync("accounts.json", JSON.stringify(accounts)); + // fs.writeFileSync("sites.json", JSON.stringify(sites)); + // fs.writeFileSync("posts.json", JSON.stringify(posts)); + // fs.writeFileSync("examples.json", JSON.stringify(examples)); + + // Upload data to new database + // const users = JSON.parse(fs.readFileSync("users.json", "utf8")); + // const accounts = JSON.parse(fs.readFileSync("accounts.json", "utf8")); + // const sites = JSON.parse(fs.readFileSync("sites.json", "utf8")); + // const posts = JSON.parse(fs.readFileSync("posts.json", "utf8")); + // const examples = JSON.parse(fs.readFileSync("examples.json", "utf8")); + + // const response = await Promise.all([ + // prisma.user.createMany({ + // data: users, + // skipDuplicates: true, + // }), + // prisma.account.createMany({ + // data: accounts, + // skipDuplicates: true, + // }), + // prisma.site.createMany({ + // data: sites, + // skipDuplicates: true, + // }), + // prisma.post.createMany({ + // data: posts, + // skipDuplicates: true, + // }), + // prisma.example.createMany({ + // data: examples, + // skipDuplicates: true, + // }) + + return NextResponse.json({ response: "ok" }); +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..7e56bde --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,26 @@ +import { put } from "@vercel/blob"; +import { nanoid } from "nanoid"; +import { NextResponse } from "next/server"; + +export const runtime = "edge"; + +export async function POST(req: Request) { + if (!process.env.BLOB_READ_WRITE_TOKEN) { + return new Response( + "Missing BLOB_READ_WRITE_TOKEN. Don't forget to add that to your .env file.", + { + status: 401, + } + ); + } + + const file = req.body || ""; + const contentType = req.headers.get("content-type") || "text/plain"; + const filename = `${nanoid()}.${contentType.split("/")[1]}`; + const blob = await put(filename, file, { + contentType, + access: "public", + }); + + return NextResponse.json(blob); +} diff --git a/app/app/(auth)/layout.tsx b/app/app/(auth)/layout.tsx new file mode 100644 index 0000000..64ebc92 --- /dev/null +++ b/app/app/(auth)/layout.tsx @@ -0,0 +1,14 @@ +import { Metadata } from "next"; +import { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Login | Platforms Starter Kit", +}; + +export default function AuthLayout({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/app/app/(auth)/login/login-button.tsx b/app/app/(auth)/login/login-button.tsx new file mode 100644 index 0000000..8a5e6bd --- /dev/null +++ b/app/app/(auth)/login/login-button.tsx @@ -0,0 +1,51 @@ +"use client"; + +import LoadingDots from "@/components/icons/loading-dots"; +import { signIn } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; +import { useState, useEffect } from "react"; +import { toast } from "sonner"; + +export default function LoginButton() { + const [loading, setLoading] = useState(false); + + // Get error message added by next/auth in URL. + const searchParams = useSearchParams(); + const error = searchParams?.get("error"); + + useEffect(() => { + const errorMessage = Array.isArray(error) ? error.pop() : error; + errorMessage && toast.error(errorMessage); + }, [error]); + + return ( + + ); +} diff --git a/app/app/(auth)/login/page.tsx b/app/app/(auth)/login/page.tsx new file mode 100644 index 0000000..9f1cecb --- /dev/null +++ b/app/app/(auth)/login/page.tsx @@ -0,0 +1,36 @@ +import Image from "next/image"; +import LoginButton from "./login-button"; + +export default function LoginPage() { + return ( + <> +
+ Platforms Starter Kit +

+ Platforms Starter Kit +

+

+ Build multi-tenant applications with custom domains.
Read the{" "} + + blog post + +

+
+ +
+ +
+ + ); +} diff --git a/app/app/(dashboard)/layout.tsx b/app/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..04007d1 --- /dev/null +++ b/app/app/(dashboard)/layout.tsx @@ -0,0 +1,16 @@ +import { ReactNode, Suspense } from "react"; +import Profile from "@/components/profile"; +import Nav from "@/components/nav"; + +export default function DashboardLayout({ children }: { children: ReactNode }) { + return ( +
+
}> + + + +
{children}
+
+ ); +} diff --git a/app/app/(dashboard)/loading.tsx b/app/app/(dashboard)/loading.tsx new file mode 100644 index 0000000..ab2c5b9 --- /dev/null +++ b/app/app/(dashboard)/loading.tsx @@ -0,0 +1,12 @@ +import LoadingDots from "@/components/icons/loading-dots"; + +export default function Loading() { + return ( + <> +
+
+ +
+ + ); +} diff --git a/app/app/(dashboard)/page.tsx b/app/app/(dashboard)/page.tsx new file mode 100644 index 0000000..0c11144 --- /dev/null +++ b/app/app/(dashboard)/page.tsx @@ -0,0 +1,53 @@ +import { Suspense } from "react"; +import Sites from "@/components/sites"; +import OverviewStats from "@/components/overview-stats"; +import Posts from "@/components/posts"; +import Link from "next/link"; +import PlacholderCard from "@/components/placeholder-card"; +import OverviewSitesCTA from "@/components/overview-sites-cta"; + +export default function Overview() { + return ( +
+
+

Overview

+ +
+ +
+
+

Top Sites

+ + + +
+ + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ } + > + + +
+ +
+

Recent Posts

+ + {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ } + > + + +
+
+ ); +} diff --git a/app/app/(dashboard)/post/[id]/layout.tsx b/app/app/(dashboard)/post/[id]/layout.tsx new file mode 100644 index 0000000..30d68fd --- /dev/null +++ b/app/app/(dashboard)/post/[id]/layout.tsx @@ -0,0 +1,5 @@ +import { ReactNode } from "react"; + +export default function PostLayout({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/app/app/(dashboard)/post/[id]/loading.tsx b/app/app/(dashboard)/post/[id]/loading.tsx new file mode 100644 index 0000000..c696468 --- /dev/null +++ b/app/app/(dashboard)/post/[id]/loading.tsx @@ -0,0 +1,10 @@ +// a bunch of loading divs + +export default function Loading() { + return ( + <> +
+
+ + ); +} diff --git a/app/app/(dashboard)/post/[id]/not-found.tsx b/app/app/(dashboard)/post/[id]/not-found.tsx new file mode 100644 index 0000000..b7ce120 --- /dev/null +++ b/app/app/(dashboard)/post/[id]/not-found.tsx @@ -0,0 +1,18 @@ +import Image from "next/image"; + +export default function NotFoundPost() { + return ( +
+

404

+ missing site +

+ Post does not exist, or you do not have permission to edit it +

+
+ ); +} diff --git a/app/app/(dashboard)/post/[id]/page.tsx b/app/app/(dashboard)/post/[id]/page.tsx new file mode 100644 index 0000000..7ebcda9 --- /dev/null +++ b/app/app/(dashboard)/post/[id]/page.tsx @@ -0,0 +1,28 @@ +import { getSession } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { notFound, redirect } from "next/navigation"; +import Editor from "@/components/editor"; + +export default async function PostPage({ params }: { params: { id: string } }) { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + const data = await prisma.post.findUnique({ + where: { + id: params.id, + }, + include: { + site: { + select: { + subdomain: true, + }, + }, + }, + }); + if (!data || data.userId !== session.user.id) { + notFound(); + } + + return ; +} diff --git a/app/app/(dashboard)/post/[id]/settings/page.tsx b/app/app/(dashboard)/post/[id]/settings/page.tsx new file mode 100644 index 0000000..8816026 --- /dev/null +++ b/app/app/(dashboard)/post/[id]/settings/page.tsx @@ -0,0 +1,58 @@ +import { getSession } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { notFound, redirect } from "next/navigation"; +import Form from "@/components/form"; +import { updatePostMetadata } from "@/lib/actions"; +import DeletePostForm from "@/components/form/delete-post-form"; + +export default async function PostSettings({ + params, +}: { + params: { id: string }; +}) { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + const data = await prisma.post.findUnique({ + where: { + id: params.id, + }, + }); + if (!data || data.userId !== session.user.id) { + notFound(); + } + return ( +
+
+

Post Settings

+
+ + + + +
+
+ ); +} diff --git a/app/app/(dashboard)/settings/page.tsx b/app/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..e2afa68 --- /dev/null +++ b/app/app/(dashboard)/settings/page.tsx @@ -0,0 +1,44 @@ +import { ReactNode } from "react"; +import Form from "@/components/form"; +import { getSession } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { editUser } from "@/lib/actions"; + +export default async function SettingsPage() { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + return ( +
+
+

Settings

+ + +
+
+ ); +} diff --git a/app/app/(dashboard)/site/[id]/analytics/page.tsx b/app/app/(dashboard)/site/[id]/analytics/page.tsx new file mode 100644 index 0000000..efdefdf --- /dev/null +++ b/app/app/(dashboard)/site/[id]/analytics/page.tsx @@ -0,0 +1,46 @@ +import { getSession } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { notFound, redirect } from "next/navigation"; +import AnalyticsMockup from "@/components/analytics"; + +export default async function SiteAnalytics({ + params, +}: { + params: { id: string }; +}) { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + const data = await prisma.site.findUnique({ + where: { + id: params.id, + }, + }); + if (!data || data.userId !== session.user.id) { + notFound(); + } + + const url = `${data.subdomain}.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`; + + return ( + <> +
+
+

+ Analytics for {data.name} +

+ + {url} ↗ + +
+
+ + + ); +} diff --git a/app/app/(dashboard)/site/[id]/layout.tsx b/app/app/(dashboard)/site/[id]/layout.tsx new file mode 100644 index 0000000..cece104 --- /dev/null +++ b/app/app/(dashboard)/site/[id]/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from "react"; + +export default function SiteLayout({ children }: { children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/app/app/(dashboard)/site/[id]/loading.tsx b/app/app/(dashboard)/site/[id]/loading.tsx new file mode 100644 index 0000000..36d8280 --- /dev/null +++ b/app/app/(dashboard)/site/[id]/loading.tsx @@ -0,0 +1,16 @@ +// a bunch of loading divs + +import PlacholderCard from "@/components/placeholder-card"; + +export default function Loading() { + return ( + <> +
+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ + ); +} diff --git a/app/app/(dashboard)/site/[id]/not-found.tsx b/app/app/(dashboard)/site/[id]/not-found.tsx new file mode 100644 index 0000000..620f152 --- /dev/null +++ b/app/app/(dashboard)/site/[id]/not-found.tsx @@ -0,0 +1,18 @@ +import Image from "next/image"; + +export default function NotFoundSite() { + return ( +
+

404

+ missing site +

+ Site does not exist, or you do not have permission to view it +

+
+ ); +} diff --git a/app/app/(dashboard)/site/[id]/page.tsx b/app/app/(dashboard)/site/[id]/page.tsx new file mode 100644 index 0000000..4cbb0c8 --- /dev/null +++ b/app/app/(dashboard)/site/[id]/page.tsx @@ -0,0 +1,53 @@ +import { getSession } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { notFound, redirect } from "next/navigation"; +import Posts from "@/components/posts"; +import CreatePostButton from "@/components/create-post-button"; + +export default async function SitePosts({ + params, +}: { + params: { id: string }; +}) { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + const data = await prisma.site.findUnique({ + where: { + id: params.id, + }, + }); + + if (!data || data.userId !== session.user.id) { + notFound(); + } + + const url = `${data.subdomain}.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`; + + return ( + <> +
+
+

+ All Posts for {data.name} +

+ + {url} ↗ + +
+ +
+ + + ); +} diff --git a/app/app/(dashboard)/site/[id]/settings/appearance/page.tsx b/app/app/(dashboard)/site/[id]/settings/appearance/page.tsx new file mode 100644 index 0000000..62c39b5 --- /dev/null +++ b/app/app/(dashboard)/site/[id]/settings/appearance/page.tsx @@ -0,0 +1,53 @@ +import prisma from "@/lib/prisma"; +import Form from "@/components/form"; +import { updateSite } from "@/lib/actions"; + +export default async function SiteSettingsAppearance({ + params, +}: { + params: { id: string }; +}) { + const data = await prisma.site.findUnique({ + where: { + id: params.id, + }, + }); + + return ( +
+ + + +
+ ); +} diff --git a/app/app/(dashboard)/site/[id]/settings/domains/page.tsx b/app/app/(dashboard)/site/[id]/settings/domains/page.tsx new file mode 100644 index 0000000..5962b74 --- /dev/null +++ b/app/app/(dashboard)/site/[id]/settings/domains/page.tsx @@ -0,0 +1,47 @@ +import prisma from "@/lib/prisma"; +import Form from "@/components/form"; +import { updateSite } from "@/lib/actions"; + +export default async function SiteSettingsDomains({ + params, +}: { + params: { id: string }; +}) { + const data = await prisma.site.findUnique({ + where: { + id: params.id, + }, + }); + + return ( +
+ + +
+ ); +} diff --git a/app/app/(dashboard)/site/[id]/settings/layout.tsx b/app/app/(dashboard)/site/[id]/settings/layout.tsx new file mode 100644 index 0000000..4a4377f --- /dev/null +++ b/app/app/(dashboard)/site/[id]/settings/layout.tsx @@ -0,0 +1,53 @@ +import { ReactNode } from "react"; +import { getSession } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { notFound, redirect } from "next/navigation"; +import SiteSettingsNav from "./nav"; + +export default async function SiteAnalyticsLayout({ + params, + children, +}: { + params: { id: string }; + children: ReactNode; +}) { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + const data = await prisma.site.findUnique({ + where: { + id: params.id, + }, + }); + + if (!data || data.userId !== session.user.id) { + notFound(); + } + + const url = `${data.subdomain}.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`; + + return ( + <> +
+

+ Settings for {data.name} +

+ + {url} ↗ + +
+ + {children} + + ); +} diff --git a/app/app/(dashboard)/site/[id]/settings/nav.tsx b/app/app/(dashboard)/site/[id]/settings/nav.tsx new file mode 100644 index 0000000..4e13f9c --- /dev/null +++ b/app/app/(dashboard)/site/[id]/settings/nav.tsx @@ -0,0 +1,48 @@ +"use client"; + +import clsx from "clsx"; +import Link from "next/link"; +import { useParams, useSelectedLayoutSegment } from "next/navigation"; + +export default function SiteSettingsNav() { + const { id } = useParams() as { id?: string }; + const segment = useSelectedLayoutSegment(); + + const navItems = [ + { + name: "General", + href: `/site/${id}/settings`, + segment: null, + }, + { + name: "Domains", + href: `/site/${id}/settings/domains`, + segment: "domains", + }, + { + name: "Appearance", + href: `/site/${id}/settings/appearance`, + segment: "appearance", + }, + ]; + + return ( +
+ {navItems.map((item) => ( + + {item.name} + + ))} +
+ ); +} diff --git a/app/app/(dashboard)/site/[id]/settings/page.tsx b/app/app/(dashboard)/site/[id]/settings/page.tsx new file mode 100644 index 0000000..74afd7a --- /dev/null +++ b/app/app/(dashboard)/site/[id]/settings/page.tsx @@ -0,0 +1,49 @@ +import prisma from "@/lib/prisma"; +import Form from "@/components/form"; +import { updateSite } from "@/lib/actions"; +import DeleteSiteForm from "@/components/form/delete-site-form"; + +export default async function SiteSettingsIndex({ + params, +}: { + params: { id: string }; +}) { + const data = await prisma.site.findUnique({ + where: { + id: params.id, + }, + }); + + return ( +
+ + + + + +
+ ); +} diff --git a/app/app/(dashboard)/sites/page.tsx b/app/app/(dashboard)/sites/page.tsx new file mode 100644 index 0000000..0d7d2ce --- /dev/null +++ b/app/app/(dashboard)/sites/page.tsx @@ -0,0 +1,32 @@ +import { Suspense } from "react"; +import Sites from "@/components/sites"; +import PlacholderCard from "@/components/placeholder-card"; +import CreateSiteButton from "@/components/create-site-button"; +import CreateSiteModal from "@/components/modal/create-site"; + +export default function AllSites({ params }: { params: { id: string } }) { + return ( +
+
+
+

All Sites

+ + + +
+ + {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ } + > + {/* @ts-expect-error Server Component */} + + +
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c4826c94780efdd7e92092dc77fd4388fd8cd7f5 GIT binary patch literal 535 zcmV+y0_gpTP)5EicMx~-}zG!tkWD18q& z91g8P9LFIBawD!nqPLNzX@~*cYeVL{xh%`8IA246xMAjtM{H0O z#XaI}uPuMcaNF4!wqQV8vbJooQpy>9U|Y%-+HJ=|!$|LFOZOE>+KwX+=nO3?LP^^V zP44%5iGi*N%eh5S6#Y9mNs=4kX7c|j&=rpEgUi)lCuam%)h zAIShV9!d>19n06sgedYM_@0(1%p7L!Tml6Dp;h<7=sJ7{a+uHanD`9SpXXo1cMyJ_ Z{{e%1|CY$w7P0^U002ovPDHLkV1nrA+7|!- literal 0 HcmV?d00001 diff --git a/pages/home/index.tsx b/app/home/page.tsx similarity index 61% rename from pages/home/index.tsx rename to app/home/page.tsx index 9d063e9..88142cc 100644 --- a/pages/home/index.tsx +++ b/app/home/page.tsx @@ -1,14 +1,8 @@ -import Head from "next/head"; import Image from "next/image"; -export default function Home() { +export default function HomePage() { return (
- - Platforms on Vercel - - -
+ + + {children} + + + + + ); +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..bbe93d1 --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; +import { Toaster } from "sonner"; +import { ModalProvider } from "@/components/modal/provider"; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/components/Cloudinary.tsx b/components/Cloudinary.tsx deleted file mode 100644 index f472b44..0000000 --- a/components/Cloudinary.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-disable */ - -import Head from "next/head"; - -import type { MouseEvent, ReactNode } from "react"; -import type { - CloudinaryCallbackImage, - CloudinaryWidget, - CloudinaryWidgetResult, -} from "@/types"; - -interface ChildrenProps { - open: (e: MouseEvent) => void; -} - -interface CloudinaryUploadWidgetProps { - callback: (image: CloudinaryCallbackImage) => void; - children: (props: ChildrenProps) => ReactNode; -} - -export default function CloudinaryUploadWidget({ - callback, - children, -}: CloudinaryUploadWidgetProps) { - function showWidget() { - const widget: CloudinaryWidget = window.cloudinary.createUploadWidget( - { - cloudName: "vercel-platforms", - uploadPreset: "w0vnflc6", - cropping: true, - }, - (error: unknown | undefined, result: CloudinaryWidgetResult) => { - if (!error && result && result.event === "success") { - callback(result.info); - } - } - ); - - widget.open(); - } - - function open(e: MouseEvent) { - e.preventDefault(); - showWidget(); - } - - return ( - <> - - // this is Next.js specific, but if you're using something like Create - // React App, you could download the script in componentDidMount using - // this method: https://stackoverflow.com/a/34425083/1424568 -