summaryrefslogtreecommitdiff
path: root/apps/web/src/app
diff options
context:
space:
mode:
authorDominik Koch <dominik@koch-bautechnik.de>2025-07-21 23:18:07 +0200
committerDominik Koch <dominik@koch-bautechnik.de>2025-07-21 23:18:07 +0200
commite21c4674890a6f4b33d5623c19957b0741cde572 (patch)
treeb11f6e9324b68e8e1f9cba73186209032c20b4c0 /apps/web/src/app
parent1b3d42b2a92a6c4ba65fbc6378d193570a530d97 (diff)
downloadopencut-e21c4674890a6f4b33d5623c19957b0741cde572.tar.gz
feat: add opencut blog
Diffstat (limited to 'apps/web/src/app')
-rw-r--r--apps/web/src/app/blog/[slug]/page.tsx145
-rw-r--r--apps/web/src/app/blog/page.tsx98
-rw-r--r--apps/web/src/app/metadata.ts3
3 files changed, 246 insertions, 0 deletions
diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx
new file mode 100644
index 00000000..ad5bb548
--- /dev/null
+++ b/apps/web/src/app/blog/[slug]/page.tsx
@@ -0,0 +1,145 @@
+import { Header } from "@/components/header";
+import Prose from "@/components/ui/prose";
+import { Separator } from "@/components/ui/separator";
+import {
+ getPosts,
+ getSinglePost,
+ processHtmlContent,
+} from "@/lib/blog-query";
+import { Metadata } from "next";
+import Image from "next/image";
+import { notFound } from "next/navigation";
+
+type PageProps = {
+ params: Promise<{ slug: string }>;
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+};
+
+export async function generateMetadata({
+ params,
+}: PageProps): Promise<Metadata> {
+ const slug = (await params).slug;
+
+ const data = await getSinglePost(slug);
+
+ if (!data || !data.post) return {};
+
+ return {
+ title: data.post.title,
+ description: data.post.description,
+ twitter: {
+ title: `${data.post.title}`,
+ description: `${data.post.description}`,
+ card: "summary_large_image",
+ images: [
+ {
+ url: data.post.coverImage,
+ width: "1200",
+ height: "630",
+ alt: data.post.title,
+ },
+ ],
+ },
+ openGraph: {
+ type: "article",
+ siteName: "",
+ images: [
+ {
+ url: data.post.coverImage,
+ width: "1200",
+ height: "630",
+ alt: data.post.title,
+ },
+ ],
+ title: data.post.title,
+ description: data.post.description,
+ publishedTime: new Date(data.post.publishedAt).toISOString(),
+ authors: [
+ ...data.post.authors.map((author: { name: string }) => author.name),
+ ],
+ },
+ };
+}
+
+export async function generateStaticParams() {
+ const data = await getPosts();
+ if (!data || !data.posts.length) return [];
+
+ return data.posts.map((post) => ({
+ slug: post.slug,
+ }));
+}
+
+async function Page({ params }: PageProps) {
+ const slug = (await params).slug;
+ const data = await getSinglePost(slug);
+ if (!data || !data.post) return notFound();
+
+ const html = await processHtmlContent(data.post.content);
+
+ const formattedDate = new Date(data.post.publishedAt).toLocaleDateString(
+ "en-US",
+ {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ }
+ );
+
+ return (
+ <div className="min-h-screen bg-background">
+ <Header />
+
+ <main className="relative">
+ <div className="absolute inset-0 overflow-hidden pointer-events-none">
+ <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
+ <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
+ </div>
+
+ <div className="relative container max-w-3xl mx-auto px-4 py-16">
+ <div className="text-center mb-6">
+ {data.post.coverImage && (
+ <div className="relative aspect-video rounded-lg overflow-hidden mb-6">
+ <Image
+ src={data.post.coverImage}
+ alt={data.post.title}
+ loading="eager"
+ fill
+ className="object-cover rounded-lg"
+ />
+ </div>
+ )}
+ <div className="flex items-center justify-center mb-6">
+ <time dateTime={data.post.publishedAt.toString()}>
+ {formattedDate}
+ </time>
+ </div>
+
+ <h1 className="text-5xl md:text-4xl font-bold tracking-tight mb-6">
+ {data.post.title}
+ </h1>
+ <div className="flex items-center justify-center gap-2">
+ <Image
+ src={data.post.authors[0].image}
+ alt={data.post.authors[0].name}
+ width={36}
+ height={36}
+ loading="eager"
+ className="aspect-square shrink-0 size-8 rounded-full"
+ />
+ <p className="text-muted-foreground">
+ {data.post.authors[0].name}
+ </p>
+ </div>
+ </div>
+ <Separator />
+ <section className="mt-14">
+ <Prose html={html} />
+ </section>
+ </div>
+ </main>
+ </div>
+ );
+}
+
+export default Page;
diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx
new file mode 100644
index 00000000..ca553a67
--- /dev/null
+++ b/apps/web/src/app/blog/page.tsx
@@ -0,0 +1,98 @@
+import { Metadata } from "next";
+import { Header } from "@/components/header";
+import { Card, CardContent } from "@/components/ui/card";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import Link from "next/link";
+import { getPosts } from "@/lib/blog-query";
+import Image from "next/image";
+
+export const metadata: Metadata = {
+ title: "Blog - OpenCut",
+ description:
+ "Read the latest news and updates about OpenCut, the free and open-source video editor.",
+ openGraph: {
+ title: "Blog - OpenCut",
+ description:
+ "Read the latest news and updates about OpenCut, the free and open-source video editor.",
+ type: "website",
+ },
+};
+
+export default async function BlogPage() {
+ const data = await getPosts();
+ if (!data || !data.posts) return <div>No posts yet</div>;
+
+ return (
+ <div className="min-h-screen bg-background">
+ <Header />
+
+ <main className="relative">
+ <div className="absolute inset-0 overflow-hidden pointer-events-none">
+ <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
+ <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
+ </div>
+
+ <div className="relative container max-w-3xl mx-auto px-4 py-16">
+ <div className="text-center mb-20">
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Blog
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ Read the latest news and updates about OpenCut, the free and
+ open-source video editor.
+ </p>
+ </div>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ {data.posts.map((post) => (
+ <Link key={post.id} href={`/blog/${post.slug}`}>
+ <Card className="h-full hover:shadow-lg transition-shadow overflow-hidden">
+ {post.coverImage && (
+ <div className="relative aspect-video">
+ <Image
+ src={post.coverImage}
+ alt={post.title}
+ fill
+ className="object-cover rounded-xl"
+ />
+ </div>
+ )}
+
+ <CardContent className="p-6">
+ {post.authors && post.authors.length > 0 && (
+ <div className="flex items-center gap-2 mb-4">
+ {post.authors.map((author, index) => (
+ <div
+ key={author.id}
+ className="flex items-center gap-2"
+ >
+ <Avatar className="w-6 h-6">
+ <AvatarImage
+ src={author.image}
+ alt={author.name}
+ />
+ <AvatarFallback className="text-xs">
+ {author.name.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <span className="text-sm text-muted-foreground">
+ {author.name}
+ </span>
+ {index < post.authors.length - 1 && (
+ <span className="text-muted-foreground">•</span>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ <h2 className="text-xl font-semibold mb-2">{post.title}</h2>
+ <p className="text-muted-foreground">{post.description}</p>
+ </CardContent>
+ </Card>
+ </Link>
+ ))}
+ </div>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts
index 47f0020b..6f9e6023 100644
--- a/apps/web/src/app/metadata.ts
+++ b/apps/web/src/app/metadata.ts
@@ -33,6 +33,9 @@ export const baseMetaData: Metadata = {
creator: "@opencutapp",
images: [twitterImageUrl],
},
+ pinterest: {
+ richPin: false,
+ },
robots: {
index: true,
follow: true,