Openstatus www.openstatus.dev

feat: add changelog (#359)

authored by

Maximilian Kaske and committed by
GitHub
530900c9 9a97a2bf

+374 -190
+2 -1
apps/web/contentlayer.config.ts
··· 2 2 import rehypeSlug from "rehype-slug"; 3 3 import remarkGfm from "remark-gfm"; 4 4 5 + import { Changelog } from "./src/contentlayer/documents/changelog"; 5 6 import { LegalPost } from "./src/contentlayer/documents/legal"; 6 7 import { Post } from "./src/contentlayer/documents/post"; 7 8 import autolinkHeadings from "./src/contentlayer/plugins/autolink-headings"; ··· 9 10 10 11 export default makeSource({ 11 12 contentDirPath: "src/content/", 12 - documentTypes: [Post, LegalPost], 13 + documentTypes: [Post, LegalPost, Changelog], 13 14 mdx: { 14 15 remarkPlugins: [remarkGfm], 15 16 rehypePlugins: [rehypeSlug, prettyCode, autolinkHeadings],
apps/web/public/assets/changelog/status-widget.png

This is a binary file and will not be displayed.

+76
apps/web/src/app/(content)/blog/[slug]/page.tsx
··· 1 + import type { Metadata } from "next"; 2 + import { notFound } from "next/navigation"; 3 + import { allPosts } from "contentlayer/generated"; 4 + 5 + import { 6 + defaultMetadata, 7 + ogMetadata, 8 + twitterMetadata, 9 + } from "@/app/shared-metadata"; 10 + import { Article } from "@/components/content/article"; 11 + import { Shell } from "@/components/dashboard/shell"; 12 + import { BackButton } from "@/components/layout/back-button"; 13 + 14 + export const dynamic = "force-static"; 15 + 16 + export async function generateStaticParams() { 17 + return allPosts.map((post) => ({ 18 + slug: post.slug, 19 + })); 20 + } 21 + 22 + export async function generateMetadata({ 23 + params, 24 + }: { 25 + params: { slug: string }; 26 + }): Promise<Metadata | void> { 27 + const post = allPosts.find((post) => post.slug === params.slug); 28 + if (!post) { 29 + return; 30 + } 31 + const { title, publishedAt: publishedTime, description, slug, image } = post; 32 + 33 + return { 34 + ...defaultMetadata, 35 + title, 36 + description, 37 + openGraph: { 38 + ...ogMetadata, 39 + title, 40 + description, 41 + type: "article", 42 + publishedTime, 43 + url: `https://www.openstatus.dev/blog/${slug}`, 44 + images: [ 45 + { 46 + url: `https://openstatus.dev/api/og/post?title=${title}&description=${description}&image=${image}`, 47 + }, 48 + ], 49 + }, 50 + twitter: { 51 + ...twitterMetadata, 52 + title, 53 + description, 54 + images: [ 55 + `https://openstatus.dev/api/og/post?title=${title}&description=${description}&image=${image}`, 56 + ], 57 + }, 58 + }; 59 + } 60 + 61 + export default function PostPage({ params }: { params: { slug: string } }) { 62 + const post = allPosts.find((post) => post.slug === params.slug); 63 + 64 + if (!post) { 65 + notFound(); 66 + } 67 + 68 + return ( 69 + <> 70 + <BackButton href="/blog" /> 71 + <Shell className="sm:py-8 md:py-12"> 72 + <Article post={post} /> 73 + </Shell> 74 + </> 75 + ); 76 + }
+24
apps/web/src/app/(content)/blog/page.tsx
··· 1 + import { allPosts } from "contentlayer/generated"; 2 + 3 + import { Thumbnail } from "@/components/content/thumbnail"; 4 + import { Shell } from "@/components/dashboard/shell"; 5 + 6 + export default async function Post() { 7 + const posts = allPosts.sort( 8 + (a, b) => 9 + new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), 10 + ); 11 + 12 + return ( 13 + <Shell> 14 + <div className="grid gap-8"> 15 + <h1 className="text-foreground font-cal text-4xl">Blog</h1> 16 + <div className="space-y-4"> 17 + {posts.map((post) => ( 18 + <Thumbnail key={post._id} post={post} /> 19 + ))} 20 + </div> 21 + </div> 22 + </Shell> 23 + ); 24 + }
+53
apps/web/src/app/(content)/changelog/page.tsx
··· 1 + import Image from "next/image"; 2 + import { allChangelogs } from "contentlayer/generated"; 3 + 4 + import { Mdx } from "@/components/content/mdx"; 5 + import { Shell } from "@/components/dashboard/shell"; 6 + import { formatDate } from "@/lib/utils"; 7 + 8 + export default async function Post() { 9 + const posts = allChangelogs.sort( 10 + (a, b) => 11 + new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), 12 + ); 13 + 14 + return ( 15 + <Shell> 16 + <div className="grid gap-8"> 17 + <div className="grid gap-4 md:grid-cols-5 md:gap-8"> 18 + <div className="md:col-span-1" /> 19 + <div className="grid gap-4 md:col-span-4"> 20 + <h1 className="text-foreground font-cal text-4xl">Changelog</h1> 21 + <p className="text-muted-foreground"> 22 + All the latest features, fixes and work to OpenStatus. 23 + </p> 24 + </div> 25 + </div> 26 + {posts.map((post) => ( 27 + <article 28 + key={post.slug} 29 + className="grid gap-4 md:grid-cols-5 md:gap-6" 30 + > 31 + <time className="text-muted-foreground order-2 font-mono text-sm md:order-1 md:col-span-1"> 32 + {formatDate(new Date(post.publishedAt))} 33 + </time> 34 + <div className="relative order-1 h-64 w-full md:order-2 md:col-span-4"> 35 + <Image 36 + src={post.image} 37 + fill={true} 38 + alt={post.title} 39 + className="border-border rounded-md border object-cover" 40 + /> 41 + </div> 42 + <h3 className="text-foreground font-cal order-3 text-2xl md:col-span-4 md:col-start-2"> 43 + {post.title} 44 + </h3> 45 + <div className="order-4 md:col-span-4 md:col-start-2"> 46 + <Mdx code={post.body.code} /> 47 + </div> 48 + </article> 49 + ))} 50 + </div> 51 + </Shell> 52 + ); 53 + }
-130
apps/web/src/app/blog/[slug]/page.tsx
··· 1 - import type { Metadata } from "next"; 2 - import Link from "next/link"; 3 - import { notFound } from "next/navigation"; 4 - import { allPosts } from "contentlayer/generated"; 5 - 6 - import { Avatar, AvatarFallback, AvatarImage } from "@openstatus/ui"; 7 - 8 - import { 9 - defaultMetadata, 10 - ogMetadata, 11 - twitterMetadata, 12 - } from "@/app/shared-metadata"; 13 - import { Mdx } from "@/components/content/mdx"; 14 - import { Shell } from "@/components/dashboard/shell"; 15 - import { BackButton } from "@/components/layout/back-button"; 16 - import { formatDate } from "@/lib/utils"; 17 - 18 - export const dynamic = "force-static"; 19 - 20 - export async function generateStaticParams() { 21 - return allPosts.map((post) => ({ 22 - slug: post.slug, 23 - })); 24 - } 25 - 26 - export async function generateMetadata({ 27 - params, 28 - }: { 29 - params: { slug: string }; 30 - }): Promise<Metadata | void> { 31 - const post = allPosts.find((post) => post.slug === params.slug); 32 - if (!post) { 33 - return; 34 - } 35 - const { title, publishedAt: publishedTime, description, slug, image } = post; 36 - 37 - return { 38 - ...defaultMetadata, 39 - title, 40 - description, 41 - openGraph: { 42 - ...ogMetadata, 43 - title, 44 - description, 45 - type: "article", 46 - publishedTime, 47 - url: `https://www.openstatus.dev/blog/${slug}`, 48 - images: [ 49 - { 50 - url: `https://openstatus.dev/api/og/post?title=${title}&description=${description}&image=${image}`, 51 - }, 52 - ], 53 - }, 54 - twitter: { 55 - ...twitterMetadata, 56 - title, 57 - description, 58 - images: [ 59 - `https://openstatus.dev/api/og/post?title=${title}&description=${description}&image=${image}`, 60 - ], 61 - }, 62 - }; 63 - } 64 - 65 - export default function PostPage({ params }: { params: { slug: string } }) { 66 - const post = allPosts.find((post) => post.slug === params.slug); 67 - 68 - if (!post) { 69 - notFound(); 70 - } 71 - 72 - const getNameInitials = (name: string) => { 73 - const individualNames = name.split(" "); 74 - return ( 75 - individualNames[0][0] + individualNames[individualNames.length - 1][0] 76 - ); 77 - }; 78 - 79 - return ( 80 - <> 81 - <BackButton href="/blog" /> 82 - <Shell className="sm:py-8 md:py-12"> 83 - <article className="grid gap-8"> 84 - <div className="mx-auto grid w-full max-w-prose gap-3"> 85 - <h1 className="font-cal mb-5 text-3xl">{post.title}</h1> 86 - <div className="border-border relative h-64 w-full overflow-hidden rounded-lg border"> 87 - {/* <Image 88 - src={post.image} 89 - fill={true} 90 - alt={post.title} 91 - className="object-cover" 92 - /> */} 93 - {/* HOTFIX: plain `img` */} 94 - <img 95 - src={post.image} 96 - alt={post.title} 97 - className="h-full w-full object-cover" 98 - /> 99 - </div> 100 - <div className="flex items-center gap-3"> 101 - <Avatar> 102 - <AvatarImage src={post.author.avatar} /> 103 - <AvatarFallback> 104 - {getNameInitials(post.author.name)} 105 - </AvatarFallback> 106 - </Avatar> 107 - <div className="text-muted-foreground text-sm font-light"> 108 - <Link 109 - href={post.author.url ?? "#"} 110 - target="_blank" 111 - className="cursor-pointer font-medium text-black hover:underline" 112 - > 113 - {post.author.name} 114 - </Link> 115 - <p> 116 - {formatDate(new Date(post.publishedAt))} 117 - <span className="text-muted-foreground/70 mx-1">&bull;</span> 118 - {post.readingTime} 119 - </p> 120 - </div> 121 - </div> 122 - </div> 123 - <div className="prose-pre:overflow-y-auto prose-pre:max-w-xs md:prose-pre:max-w-none mx-auto max-w-prose "> 124 - <Mdx code={post.body.code} /> 125 - </div> 126 - </article> 127 - </Shell> 128 - </> 129 - ); 130 - }
apps/web/src/app/blog/layout.tsx apps/web/src/app/(content)/layout.tsx
-45
apps/web/src/app/blog/page.tsx
··· 1 - import Link from "next/link"; 2 - import { allPosts } from "contentlayer/generated"; 3 - 4 - import { Shell } from "@/components/dashboard/shell"; 5 - import { formatDate } from "@/lib/utils"; 6 - 7 - export default async function Post() { 8 - const posts = allPosts.sort( 9 - (a, b) => 10 - new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), 11 - ); 12 - 13 - return ( 14 - <Shell> 15 - <div className="grid gap-8"> 16 - <h1 className="text-foreground font-cal text-4xl">Blog</h1> 17 - <div className="space-y-4"> 18 - {posts.map((post) => ( 19 - <div key={post.slug}> 20 - <Link href={`/blog/${post.slug}`}> 21 - <section> 22 - <p className="text-foreground font-cal text-2xl"> 23 - {post.title} 24 - </p> 25 - <p className="text-muted-foreground">{post.description}</p> 26 - <p className="text-muted-foreground mt-1 text-xs"> 27 - {post.author.name} 28 - <span className="text-muted-foreground/70 mx-1"> 29 - &bull; 30 - </span> 31 - {formatDate(new Date(post.publishedAt))} 32 - <span className="text-muted-foreground/70 mx-1"> 33 - &bull; 34 - </span> 35 - {post.readingTime} 36 - </p> 37 - </section> 38 - </Link> 39 - </div> 40 - ))} 41 - </div> 42 - </div> 43 - </Shell> 44 - ); 45 - }
+62
apps/web/src/components/content/article.tsx
··· 1 + import Image from "next/image"; 2 + import Link from "next/link"; 3 + import type { Post } from "contentlayer/generated"; 4 + 5 + import { Avatar, AvatarFallback, AvatarImage } from "@openstatus/ui"; 6 + 7 + import { Mdx } from "@/components/content/mdx"; 8 + import { formatDate } from "@/lib/utils"; 9 + 10 + export function Article({ post }: { post: Post }) { 11 + const getNameInitials = (name: string) => { 12 + const individualNames = name.split(" "); 13 + return ( 14 + individualNames[0][0] + individualNames[individualNames.length - 1][0] 15 + ); 16 + }; 17 + 18 + return ( 19 + <article className="grid gap-8"> 20 + <div className="mx-auto grid w-full max-w-prose gap-3"> 21 + <h1 className="font-cal mb-5 text-3xl">{post.title}</h1> 22 + <div className="border-border relative h-64 w-full overflow-hidden rounded-lg border"> 23 + <Image 24 + src={post.image} 25 + fill={true} 26 + alt={post.title} 27 + className="object-cover" 28 + /> 29 + {/* HOTFIX: plain `img` */} 30 + {/* <img 31 + src={post.image} 32 + alt={post.title} 33 + className="h-full w-full object-cover" 34 + /> */} 35 + </div> 36 + <div className="flex items-center gap-3"> 37 + <Avatar> 38 + <AvatarImage src={post.author.avatar} /> 39 + <AvatarFallback>{getNameInitials(post.author.name)}</AvatarFallback> 40 + </Avatar> 41 + <div className="text-muted-foreground text-sm font-light"> 42 + <Link 43 + href={post.author.url ?? "#"} 44 + target="_blank" 45 + className="cursor-pointer font-medium text-black hover:underline" 46 + > 47 + {post.author.name} 48 + </Link> 49 + <p> 50 + {formatDate(new Date(post.publishedAt))} 51 + <span className="text-muted-foreground/70 mx-1">&bull;</span> 52 + {post.readingTime} 53 + </p> 54 + </div> 55 + </div> 56 + </div> 57 + <div className="prose-pre:overflow-y-auto prose-pre:max-w-xs md:prose-pre:max-w-none mx-auto max-w-prose "> 58 + <Mdx code={post.body.code} /> 59 + </div> 60 + </article> 61 + ); 62 + }
+24
apps/web/src/components/content/thumbnail.tsx
··· 1 + import Link from "next/link"; 2 + import type { Post } from "contentlayer/generated"; 3 + 4 + import { formatDate } from "@/lib/utils"; 5 + 6 + export function Thumbnail({ post }: { post: Post }) { 7 + return ( 8 + <div key={post.slug}> 9 + <Link href={`/blog/${post.slug}`}> 10 + <section> 11 + <p className="text-foreground font-cal text-2xl">{post.title}</p> 12 + <p className="text-muted-foreground">{post.description}</p> 13 + <p className="text-muted-foreground mt-1 text-xs"> 14 + {post.author.name} 15 + <span className="text-muted-foreground/70 mx-1">&bull;</span> 16 + {formatDate(new Date(post.publishedAt))} 17 + <span className="text-muted-foreground/70 mx-1">&bull;</span> 18 + {post.readingTime} 19 + </p> 20 + </section> 21 + </Link> 22 + </div> 23 + ); 24 + }
+5 -5
apps/web/src/components/layout/marketing-footer.tsx
··· 14 14 <footer className={cn("w-full", className)}> 15 15 <Shell className="grid gap-6"> 16 16 <div className="grid grid-cols-2 gap-6 md:grid-cols-4"> 17 - <div> 17 + <div className="order-4 md:order-1"> 18 18 <StatusWidget slug="status" /> 19 19 </div> 20 - <div className="flex flex-col gap-3 text-sm"> 20 + <div className="order-1 flex flex-col gap-3 text-sm md:order-2"> 21 21 <p className="text-foreground font-semibold">Community</p> 22 22 <FooterLink href="/github" label="GitHub" external /> 23 23 <FooterLink href="/discord" label="Discord" external /> 24 24 <FooterLink href="https://twitter.com/openstatusHQ" label="X" /> 25 25 </div> 26 - <div className="flex flex-col gap-3 text-sm"> 26 + <div className="order-2 flex flex-col gap-3 text-sm md:order-3"> 27 27 <p className="text-foreground font-semibold">Resources</p> 28 - <FooterLink href="https://status.openstatus.dev" label="Status" /> 29 28 <FooterLink href="/blog" label="Blog" /> 29 + <FooterLink href="/changelog" label="Changelog" /> 30 30 <FooterLink href="https://docs.openstatus.dev" label="Docs" /> 31 31 <FooterLink href="/oss-friends" label="OSS Friends" /> 32 32 </div> 33 - <div className="flex flex-col gap-3 text-sm"> 33 + <div className="order-3 flex flex-col gap-3 text-sm md:order-4"> 34 34 <p className="text-foreground font-semibold">Legal</p> 35 35 <FooterLink href="/legal/terms" label="Terms" /> 36 36 <FooterLink href="/legal/privacy" label="Privacy" />
+17 -9
apps/web/src/components/layout/marketing-header.tsx
··· 8 8 9 9 import { cn } from "@/lib/utils"; 10 10 import { BrandName } from "./brand-name"; 11 + import { MarketingMenu } from "./marketing-menu"; 11 12 12 13 interface Props { 13 14 className?: string; ··· 18 19 19 20 return ( 20 21 <header 21 - className={cn( 22 - "flex w-full items-center justify-between gap-2", 23 - className, 24 - )} 22 + className={cn("grid w-full grid-cols-2 gap-2 md:grid-cols-5", className)} 25 23 > 26 - <BrandName /> 27 - <div className="flex items-center md:gap-3"> 24 + <div className="flex items-center md:col-span-1"> 25 + <BrandName /> 26 + </div> 27 + <div className="hidden items-center justify-center md:col-span-3 md:flex md:gap-3"> 28 + <Button variant="link" asChild className="md:mr-3"> 29 + <Link href="/blog">Blog</Link> 30 + </Button> 31 + <Button variant="link" asChild className="md:mr-3"> 32 + <Link href="/changelog">Changelog</Link> 33 + </Button> 28 34 <Button variant="link" asChild className="md:mr-3"> 29 35 <Link href="https://docs.openstatus.dev" target="_blank"> 30 36 Docs 31 37 <ArrowUpRight className="ml-1 h-4 w-4 flex-shrink-0" /> 32 38 </Link> 33 39 </Button> 34 - <Button variant="link" asChild className="md:mr-3"> 35 - <Link href="/blog">Blog</Link> 36 - </Button> 40 + </div> 41 + <div className="flex items-center justify-end gap-3 md:col-span-1"> 42 + <div className="block md:hidden"> 43 + <MarketingMenu /> 44 + </div> 37 45 <Button asChild className="rounded-full"> 38 46 {isSignedIn ? ( 39 47 <Link href="/app">Dashboard</Link>
+66
apps/web/src/components/layout/marketing-menu.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import Link from "next/link"; 5 + import { usePathname, useSearchParams } from "next/navigation"; 6 + import { Menu } from "lucide-react"; 7 + 8 + import { 9 + Button, 10 + Sheet, 11 + SheetContent, 12 + SheetHeader, 13 + SheetTitle, 14 + SheetTrigger, 15 + } from "@openstatus/ui"; 16 + 17 + import { cn } from "@/lib/utils"; 18 + 19 + const pages = [ 20 + { href: "/changelog", title: "Changelog" }, 21 + { href: "/blog", title: "Blog" }, 22 + { href: "https://docs.openstatus.dev", title: "Docs" }, 23 + ]; 24 + 25 + export function MarketingMenu() { 26 + const [open, setOpen] = React.useState(false); 27 + const pathname = usePathname(); 28 + const searchParams = useSearchParams(); 29 + 30 + React.useEffect(() => { 31 + setOpen(false); 32 + }, [pathname, searchParams]); // remove searchParams if not needed 33 + 34 + return ( 35 + <Sheet open={open} onOpenChange={(value) => setOpen(value)}> 36 + <SheetTrigger asChild> 37 + <Button size="icon" variant="outline" className="rounded-full"> 38 + <Menu className="h-6 w-6" /> 39 + </Button> 40 + </SheetTrigger> 41 + <SheetContent> 42 + <SheetHeader> 43 + <SheetTitle className="text-left">Navigation</SheetTitle> 44 + </SheetHeader> 45 + <ul className="mt-4 grid gap-1"> 46 + {pages.map(({ href, title }) => { 47 + const isActive = pathname?.startsWith(href); 48 + return ( 49 + <li key="title" className="w-full"> 50 + <Link 51 + href={href} 52 + className={cn( 53 + "hover:bg-muted/50 hover:text-foreground text-muted-foreground group flex w-full min-w-[200px] items-center rounded-md border border-transparent px-3 py-1", 54 + isActive && "bg-muted/50 border-border text-foreground", 55 + )} 56 + > 57 + {title} 58 + </Link> 59 + </li> 60 + ); 61 + })} 62 + </ul> 63 + </SheetContent> 64 + </Sheet> 65 + ); 66 + }
+15
apps/web/src/content/changelog/status-widget.mdx
··· 1 + --- 2 + title: Status Widget 3 + publishedAt: 2023-10-05 4 + image: /assets/changelog/status-widget.png 5 + --- 6 + 7 + You can now access the status of your status page via a public api endpoint. 8 + This allows you to build your own custom status widget with your unique `:slug`. 9 + 10 + ``` 11 + $ curl https://api.openstatus.dev/public/status/:slug 12 + ``` 13 + 14 + You can find the documentation 15 + [here](https://docs.openstatus.dev/api-server/status-widget).
+29
apps/web/src/contentlayer/documents/changelog.ts
··· 1 + import { defineDocumentType } from "contentlayer/source-files"; 2 + import readingTime from "reading-time"; 3 + 4 + export const Changelog = defineDocumentType(() => ({ 5 + name: "Changelog", 6 + contentType: "mdx", 7 + filePathPattern: "changelog/*.mdx", 8 + fields: { 9 + title: { 10 + type: "string", 11 + required: true, 12 + }, 13 + image: { 14 + type: "string", 15 + required: true, 16 + }, 17 + publishedAt: { type: "date", required: true }, 18 + }, 19 + computedFields: { 20 + slug: { 21 + type: "string", 22 + resolve: (post) => post._raw.sourceFileName.replace(/\.mdx$/, ""), 23 + }, 24 + readingTime: { 25 + type: "string", 26 + resolve: (post) => readingTime(post.body.raw).text, 27 + }, 28 + }, 29 + }));
+1
apps/web/src/middleware.ts
··· 72 72 "/api/checker/cron/10m", 73 73 "/blog", 74 74 "/blog/(.*)", 75 + "/changelog", 75 76 "/legal/(.*)", 76 77 "/discord", 77 78 "/github",