From 69e584b11f629348f41f3cac8576e407909f878b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Sep 2025 13:33:25 +0000 Subject: [PATCH] Migrate from Pages Router to App Router with Next.js 13 Co-authored-by: johan --- app/about/page.tsx | 33 ++ pages/ai.tsx => app/ai/page.tsx | 24 +- app/api/rss/route.ts | 10 + .../[day]/[slug]/SocialShareButtons.tsx | 79 ++++ .../blog/[year]/[month]/[day]/[slug]/page.tsx | 227 ++++------ app/blog/tags/[tag]/page.tsx | 97 ++++ app/contact/page.tsx | 193 ++++++++ app/customers/page.tsx | 131 ++++++ app/customers/unwritten/page.tsx | 151 +++++++ .../index.tsx => app/launchpad/page.tsx | 32 +- app/layout.tsx | 136 ++++++ app/not-found.tsx | 14 + pages/index.tsx => app/page.tsx | 53 +-- pages/pricing.tsx => app/pricing/page.tsx | 71 ++- app/providers/PostHogProvider.tsx | 39 ++ app/starters/page.tsx | 66 +++ components/blog/BlogFAQ.tsx | 2 + components/elements/CopyButton.tsx | 2 + components/elements/HeightMagic.tsx | 2 + components/sections/Blog.tsx | 8 +- components/sections/FeaturedBlogPost.tsx | 2 + components/sections/FeaturedBlogPosts.tsx | 2 + .../sections/FrequentlyAskedQuestions.tsx | 2 + components/sections/GetStarted.tsx | 2 + components/sections/HowItWorks.tsx | 2 + components/sections/Navigation/index.tsx | 6 +- components/sections/Pricing.tsx | 2 + components/sections/StarOnGithub.tsx | 8 +- next-env.d.ts | 3 +- next.config.js | 8 + pages/404.tsx | 16 - pages/about.tsx | 34 -- pages/api/rss.ts | 10 - pages/blog/tags/[tag].tsx | 93 ---- pages/contact.tsx | 130 ------ pages/customers/index.tsx | 133 ------ pages/customers/unwritten.tsx | 419 ------------------ pages/starters.tsx | 78 ---- providers/IntercomProvider.tsx | 25 +- tsconfig.json | 24 +- 40 files changed, 1182 insertions(+), 1187 deletions(-) create mode 100644 app/about/page.tsx rename pages/ai.tsx => app/ai/page.tsx (78%) create mode 100644 app/api/rss/route.ts create mode 100644 app/blog/[year]/[month]/[day]/[slug]/SocialShareButtons.tsx rename pages/blog/[year]/[month]/[day]/[slug].tsx => app/blog/[year]/[month]/[day]/[slug]/page.tsx (55%) create mode 100644 app/blog/tags/[tag]/page.tsx create mode 100644 app/contact/page.tsx create mode 100644 app/customers/page.tsx create mode 100644 app/customers/unwritten/page.tsx rename pages/launchpad/index.tsx => app/launchpad/page.tsx (70%) create mode 100644 app/layout.tsx create mode 100644 app/not-found.tsx rename pages/index.tsx => app/page.tsx (70%) rename pages/pricing.tsx => app/pricing/page.tsx (61%) create mode 100644 app/providers/PostHogProvider.tsx create mode 100644 app/starters/page.tsx delete mode 100644 pages/404.tsx delete mode 100644 pages/about.tsx delete mode 100644 pages/api/rss.ts delete mode 100644 pages/blog/tags/[tag].tsx delete mode 100644 pages/contact.tsx delete mode 100644 pages/customers/index.tsx delete mode 100644 pages/customers/unwritten.tsx delete mode 100644 pages/starters.tsx diff --git a/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 00000000..597c1342 --- /dev/null +++ b/app/about/page.tsx @@ -0,0 +1,33 @@ +import { + AboutHero, + BackedBy, + JobOpenings, + Mission, + WorkingWithUs, +} from "components/sections"; +import { Metadata } from "next"; +import Image from "next/image"; + +export const metadata: Metadata = { + title: "About | Shuttle", + description: "Meet the innovators at Shuttle, the cloud development platform tailored for Rust. Read about our mission to empower developers worldwide.", +}; + +export default function About() { + return ( +
+ bg + + + + + +
+ ); +} \ No newline at end of file diff --git a/pages/ai.tsx b/app/ai/page.tsx similarity index 78% rename from pages/ai.tsx rename to app/ai/page.tsx index 124b874c..b56619ed 100644 --- a/pages/ai.tsx +++ b/app/ai/page.tsx @@ -1,22 +1,16 @@ import { Hero, Info, Steps } from "components/sections/ShuttleAI"; import React from "react"; -import { Page } from "components/templates"; -import { ReactNode } from "react"; import Image from "next/image"; import { Waitlist } from "components/sections/ShuttleAI/Waitlist"; import { NextSeo } from "next-seo"; -import { - initTwitter, - sendTwitterConversion, - shuttleAiPageview, -} from "lib/useTwitter"; -import Script from "next/script"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Shuttle AI - Deploy Apps from Prompts | Cloud Development Tools", + description: "Build and deploy cloud applications effortlessly using Shuttle AI. Discover the power of single-prompt app deployment in our Rust-centric product.", +}; export default function ShuttleAIPage() { - React.useEffect(() => { - initTwitter(); - sendTwitterConversion(shuttleAiPageview); - }, []); return ( <> @@ -52,8 +46,4 @@ export default function ShuttleAIPage() { ); -} - -ShuttleAIPage.getLayout = (children: ReactNode) => ( - {children} -); +} \ No newline at end of file diff --git a/app/api/rss/route.ts b/app/api/rss/route.ts new file mode 100644 index 00000000..99d47433 --- /dev/null +++ b/app/api/rss/route.ts @@ -0,0 +1,10 @@ +import { exportedPosts } from "lib/blog/make-rss"; + +export async function GET() { + return new Response(exportedPosts, { + status: 200, + headers: { + "Content-Type": "text/xml", + }, + }); +} \ No newline at end of file diff --git a/app/blog/[year]/[month]/[day]/[slug]/SocialShareButtons.tsx b/app/blog/[year]/[month]/[day]/[slug]/SocialShareButtons.tsx new file mode 100644 index 00000000..bd46bf28 --- /dev/null +++ b/app/blog/[year]/[month]/[day]/[slug]/SocialShareButtons.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { SITE_URL } from "lib/constants"; +import { Post } from "lib/blog/posts"; +import { LinkedInLogo, TwitterLogo } from "components/svgs"; +import MastodonLogo from "components/svgs/MastodonLogo"; +import HNLogo from "components/svgs/HNLogo"; +import { trackEvent } from "lib/posthog"; + +interface Props { + blog: Post; +} + +export function SocialShareButtons({ blog }: Props) { + return ( +
+ Share article + { + trackEvent(`blog_article_${blog.title}_hackernews`); + }} + > + + + { + trackEvent(`blog_article_${blog.title}_twitter`); + }} + > + + + { + trackEvent(`blog_article_${blog.title}_linkedin`); + }} + > + + + { + e.preventDefault(); + + trackEvent(`blog_article_${blog.title}_mastodon`); + + const instance = window.prompt( + "Enter your Mastodon instance (ex. mastodon.social):", + ); + if (instance) { + window.location.href = `https://${instance}/share?text=${encodeURIComponent( + `${blog.title} ${SITE_URL}blog/${blog.slug}`, + )}`; + } + }} + className="flex items-center rounded-xl border border-black/10 bg-black p-3 dark:border-white/10" + target="_blank" + rel="noreferrer" + > + + +
+ ); +} \ No newline at end of file diff --git a/pages/blog/[year]/[month]/[day]/[slug].tsx b/app/blog/[year]/[month]/[day]/[slug]/page.tsx similarity index 55% rename from pages/blog/[year]/[month]/[day]/[slug].tsx rename to app/blog/[year]/[month]/[day]/[slug]/page.tsx index b37b02f8..ac2a6bde 100644 --- a/pages/blog/[year]/[month]/[day]/[slug].tsx +++ b/app/blog/[year]/[month]/[day]/[slug]/page.tsx @@ -2,7 +2,6 @@ import matter from "gray-matter"; import { serialize } from "next-mdx-remote/serialize"; import { NextSeo } from "next-seo"; import Image from "next/image"; -import { useRouter } from "next/router"; import { generateReadingTime } from "lib/helpers"; import { getAllPostSlugs, @@ -14,12 +13,10 @@ import { MDXRemote, MDXRemoteProps } from "next-mdx-remote"; import gfm from "remark-gfm"; import slug from "rehype-slug"; -// @ts-ignore -import toc from "markdown-toc"; +// Dynamic import for markdown-toc to handle server-side compatibility +const toc = require("markdown-toc"); import rehypePrism from "@mapbox/rehype-prism"; import { DISCORD_URL, SITE_URL } from "lib/constants"; -import { GetStaticPropsContext, GetStaticPropsResult } from "next"; -import { ParsedUrlQuery } from "querystring"; import Link from "components/elements/Link"; import clsx from "clsx"; import { @@ -34,31 +31,32 @@ import { TwitterTweetEmbed } from "react-twitter-embed"; import { Pre } from "components/blog/Pre"; import MastodonLogo from "components/svgs/MastodonLogo"; import HNLogo from "components/svgs/HNLogo"; -import { trackEvent } from "lib/posthog"; -import { BlogFAQ } from "../../../../../components/blog/BlogFAQ"; -import { NewConsoleCTA } from "../../../../../components/blog/NewConsoleCTA"; +import { BlogFAQ } from "../../../../../../components/blog/BlogFAQ"; +import { NewConsoleCTA } from "../../../../../../components/blog/NewConsoleCTA"; +import { Metadata } from "next"; +import { SocialShareButtons } from "./SocialShareButtons"; -export async function getStaticPaths() { +export async function generateStaticParams() { const paths = getAllPostSlugs(); - return { - paths: paths, - fallback: false, - }; + return paths.map((path) => ({ + year: path.params.year, + month: path.params.month, + day: path.params.day, + slug: path.params.slug, + })); } -interface Params extends ParsedUrlQuery { - readonly year: string; - readonly month: string; - readonly day: string; - readonly slug: string; +interface Props { + params: { + year: string; + month: string; + day: string; + slug: string; + }; } -export async function getStaticProps({ - params, -}: GetStaticPropsContext): Promise> { - if (!params) throw new Error("No params found"); - +async function getBlogPostData(params: Props["params"]) { const filePath = `${params.year}-${params.month}-${params.day}-${params.slug}`; const postContent = await getPostData(filePath); const readingTime = generateReadingTime(postContent); @@ -116,25 +114,50 @@ export async function getStaticProps({ const pageTitle = data.pageTitle ?? data.title; return { - props: { - prevPost, - nextPost, - relatedPosts, - blog: { - slug: `${params.year}/${params.month}/${params.day}/${params.slug}`, - content: mdxPost, - pageTitle, - contentTOC, - ...data, - toc: mdxTOC, - readingTime, - date: data.date, - dateReadable: formattedPublishDate, - updated_on: data.updated_on ?? null, - updated_on_readable: formattedUpdatedDate, - modified: data.modified ?? data.date, - modifiedReadable: formattedModifiedDate, - } as Post, + prevPost, + nextPost, + relatedPosts, + blog: { + slug: `${params.year}/${params.month}/${params.day}/${params.slug}`, + content: mdxPost, + pageTitle, + contentTOC, + ...data, + toc: mdxTOC, + readingTime, + date: data.date, + dateReadable: formattedPublishDate, + updated_on: data.updated_on ?? null, + updated_on_readable: formattedUpdatedDate, + modified: data.modified ?? data.date, + modifiedReadable: formattedModifiedDate, + } as Post, + }; +} + +export async function generateMetadata({ params }: Props): Promise { + const { blog } = await getBlogPostData(params); + const title = blog.pageTitle ?? blog.title; + + return { + title: `${title} | Shuttle`, + description: blog.description, + openGraph: { + title: `${title} | Shuttle`, + description: blog.description, + url: `${SITE_URL}blog/${blog.slug}`, + type: "article", + publishedTime: blog.date, + modifiedTime: blog.updated_on ?? blog.modified, + authors: [blog.author_url || ""], + tags: (blog.tags || []).map((cat: string) => { + return cat; + }), + images: [ + { + url: `${SITE_URL}/images/blog/${blog.thumb}`, + }, + ], }, }; } @@ -210,45 +233,38 @@ const mdxComponents: MDXRemoteProps["components"] = { }, }; -interface Props { - readonly prevPost?: Post; - readonly nextPost?: Post; - readonly relatedPosts: Post[]; - readonly blog: Post; -} - -export default function BlogPostPage(props: Props) { - const { basePath } = useRouter(); +export default async function BlogPostPage({ params }: Props) { + const { prevPost, nextPost, relatedPosts, blog } = await getBlogPostData(params); - const title = props.blog.pageTitle ?? props.blog.title; + const title = blog.pageTitle ?? blog.title; return ( <> { + authors: [blog.author_url || ""], + tags: (blog.tags || []).map((cat: string) => { return cat; }), }, images: [ { - url: `${SITE_URL}${basePath}/images/blog/${props.blog.thumb}`, + url: `${SITE_URL}/images/blog/${blog.thumb}`, }, ], }} @@ -257,32 +273,32 @@ export default function BlogPostPage(props: Props) {
{/* Content */}
- +
- {(props.blog.thumb ?? props.blog.cover) && ( + {(blog.thumb ?? blog.cover) && (
Cover image - {props.blog.caption && ( + {blog.caption && ( - {props.blog.caption} + {blog.caption} )}
)} {/* - {props.blog.contentTOC.json.length > 0 ? ( - + {blog.contentTOC.json.length > 0 ? ( + ) : null} */} - {props.blog.content && ( + {blog.content && (
- +
)} {/* Powered By */} {/* */} {/* */} - - + +
{/* Sidebar */}
@@ -377,4 +332,4 @@ export default function BlogPostPage(props: Props) { ); -} +} \ No newline at end of file diff --git a/app/blog/tags/[tag]/page.tsx b/app/blog/tags/[tag]/page.tsx new file mode 100644 index 00000000..86c9b0db --- /dev/null +++ b/app/blog/tags/[tag]/page.tsx @@ -0,0 +1,97 @@ +import { NextSeo } from "next-seo"; +import { SITE_URL } from "lib/constants"; +import { getAllTags, getSortedPosts } from "lib/blog/posts"; +import { Blog, FeaturedBlogPost } from "components/sections"; +import { Metadata } from "next"; + +export async function generateStaticParams() { + const tags = getAllTags(); + + return tags.map((tag) => ({ + tag, + })); +} + +interface Props { + params: { + tag: string; + }; +} + +export async function generateMetadata({ params }: Props): Promise { + const tag = params.tag !== "all" ? params.tag : ""; + const tagReadable = tag.replaceAll("-", " "); + + const meta_title = tagReadable + ? `Articles tagged: "${tagReadable}" - Shuttle Blog` + : `Shuttle Blog`; + const meta_description = tagReadable + ? `Learn more about ${tagReadable} through reading the Shuttle Blog.` + : "Dive into the Shuttle blog for insights on Rust programming, tutorials, web development tips, and exclusive thought leadership articles."; + + return { + title: meta_title, + description: meta_description, + openGraph: { + title: meta_title, + description: meta_description, + url: `${SITE_URL}blog/tags/${params.tag}`, + images: [ + { + url: `${SITE_URL}images/og/og-image.jpg`, + }, + ], + }, + alternates: { + types: { + "application/rss+xml": `${SITE_URL}rss.xml`, + }, + }, + }; +} + +export default function BlogPage({ params }: Props) { + const tag = params.tag !== "all" ? params.tag : ""; + const tagReadable = tag.replaceAll("-", " "); + const posts = getSortedPosts(0, tag ? [tag] : undefined); + const tags = getAllTags(); + + const [headPost, ...tailPosts] = posts; + + const meta_title = tagReadable + ? `Articles tagged: "${tagReadable}" - Shuttle Blog` + : `Shuttle Blog`; + const meta_description = tagReadable + ? `Learn more about ${tagReadable} through reading the Shuttle Blog.` + : "Dive into the Shuttle blog for insights on Rust programming, tutorials, web development tips, and exclusive thought leadership articles."; + + return ( + <> + + + + + + + ); +} \ No newline at end of file diff --git a/app/contact/page.tsx b/app/contact/page.tsx new file mode 100644 index 00000000..96f9ea99 --- /dev/null +++ b/app/contact/page.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { Button } from "components/elements"; +import { NextSeo } from "next-seo"; +import { FormEvent, MouseEvent, useState } from "react"; +import { Metadata } from "next"; + +type FormTargetOption = "support@shuttle.rs" | "hello@shuttle.rs"; + +export default function Contact() { + const [isOpen, setIsOpen] = useState(false); + const [target, setTarget] = useState("support@shuttle.rs"); + const [name, setName] = useState(""); + const [subject, setSubject] = useState(""); + const [body, setBody] = useState(""); + + const handleSubmit = ( + e: FormEvent | MouseEvent, + ) => { + if (!name || !subject || !body) return; + + e.preventDefault(); + + window.location.href = `mailto:${target}?subject=${name} - ${subject}&body=${body}`; + }; + + const options = [ + { value: "support@shuttle.rs", label: "Support" }, + { value: "hello@shuttle.rs", label: "General Inquiry" }, + ]; + + return ( +
+ +
+

+ Contact +

+
+
+
+ +
+ setName(e.target.value)} + className="block w-full rounded-md border-gray-300 bg-[#E9E9E9] py-3 px-4 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-black" + /> +
+
+
+ +
+ + + {isOpen && ( +
    + {options.map((option) => ( +
  • { + setTarget(option.value as FormTargetOption); + setIsOpen(false); + }} + > + + {option.label} + + + {target === option.value && ( + + + + )} +
  • + ))} +
+ )} +
+
+
+
+ +
+ setSubject(e.target.value)} + className="block w-full rounded-md border-gray-300 bg-[#E9E9E9] py-3 px-4 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-black" + /> +
+
+
+ +
+