summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
authoriza <59828082+izadoesdev@users.noreply.github.com>2025-07-22 00:59:57 +0300
committerGitHub <noreply@github.com>2025-07-22 00:59:57 +0300
commit8c1ea7e6806f47f14a46fd4ab6490b2911430803 (patch)
tree097f4a528c36bd4d4959d5a58969af65702c1155 /apps/web/src
parent3446fdde87795693d01c1e7a010a92d4851c0ed7 (diff)
parentddf0353d92c4ae214844e085e96d1d47b3ac236d (diff)
downloadopencut-8c1ea7e6806f47f14a46fd4ab6490b2911430803.tar.gz
Merge pull request #402 from mezotv/feat/opencut-blog
feat: add blog powered by marble
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/blog/[slug]/page.tsx144
-rw-r--r--apps/web/src/app/blog/page.tsx98
-rw-r--r--apps/web/src/app/metadata.ts3
-rw-r--r--apps/web/src/components/header.tsx5
-rw-r--r--apps/web/src/components/ui/prose.tsx22
-rw-r--r--apps/web/src/components/ui/separator.tsx14
-rw-r--r--apps/web/src/lib/blog-query.ts55
-rw-r--r--apps/web/src/types/post.ts93
8 files changed, 427 insertions, 7 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..0de1e0a8
--- /dev/null
+++ b/apps/web/src/app/blog/[slug]/page.tsx
@@ -0,0 +1,144 @@
+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",
+ 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">
+ {data.post.authors[0] && (
+ <>
+ <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,
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 720abd3d..7d11e68a 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -16,6 +16,11 @@ export function Header() {
const rightContent = (
<nav className="flex items-center gap-3">
+ <Link href="/blog">
+ <Button variant="text" className="text-sm p-0">
+ Blog
+ </Button>
+ </Link>
<Link href="/contributors">
<Button variant="text" className="text-sm p-0">
Contributors
diff --git a/apps/web/src/components/ui/prose.tsx b/apps/web/src/components/ui/prose.tsx
new file mode 100644
index 00000000..c25a5a65
--- /dev/null
+++ b/apps/web/src/components/ui/prose.tsx
@@ -0,0 +1,22 @@
+import { cn } from "@/lib/utils";
+import type React from "react";
+
+type ProseProps = React.HTMLAttributes<HTMLElement> & {
+ as?: "article";
+ html: string;
+};
+
+function Prose({ children, html, className }: ProseProps) {
+ return (
+ <article
+ className={cn(
+ "prose prose-h2:font-semibold max-w-none prose-h1:text-xl prose-a:text-blue-600 prose-p:text-justify dark:prose-invert mx-auto",
+ className
+ )}
+ >
+ {html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children}
+ </article>
+ );
+}
+
+export default Prose;
diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx
index 6138cabf..12d81c4a 100644
--- a/apps/web/src/components/ui/separator.tsx
+++ b/apps/web/src/components/ui/separator.tsx
@@ -1,9 +1,9 @@
-"use client";
+"use client"
-import * as React from "react";
-import { Separator as SeparatorPrimitive } from "radix-ui";
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
-import { cn } from "../../lib/utils";
+import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -25,7 +25,7 @@ const Separator = React.forwardRef<
{...props}
/>
)
-);
-Separator.displayName = SeparatorPrimitive.Root.displayName;
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
-export { Separator };
+export { Separator }
diff --git a/apps/web/src/lib/blog-query.ts b/apps/web/src/lib/blog-query.ts
new file mode 100644
index 00000000..f82c54fe
--- /dev/null
+++ b/apps/web/src/lib/blog-query.ts
@@ -0,0 +1,55 @@
+import type { MarbleAuthorList, MarbleCategoryList, MarblePost, MarblePostList, MarbleTagList } from '@/types/post';
+import { unified } from "unified";
+import rehypeParse from "rehype-parse";
+import rehypeStringify from "rehype-stringify";
+import rehypeSlug from "rehype-slug";
+import rehypeAutolinkHeadings from "rehype-autolink-headings";
+import rehypeSanitize from "rehype-sanitize";
+
+const url = process.env.MARBLE_API_URL || "https://api.marblecms.com";
+const key = process.env.MARBLE_WORKSPACE_KEY || "cm6ytuq9x0000i803v0isidst";
+
+async function fetchFromMarble<T>(endpoint: string): Promise<T> {
+ try {
+ const response = await fetch(`${url}/${key}/${endpoint}`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`);
+ }
+ return await response.json() as T;
+ } catch (error) {
+ console.error(`Error fetching ${endpoint}:`, error);
+ throw error;
+ }
+ }
+
+ export async function getPosts() {
+ return fetchFromMarble<MarblePostList>('posts');
+ }
+
+ export async function getTags() {
+ return fetchFromMarble<MarbleTagList>('tags');
+ }
+
+ export async function getSinglePost(slug: string) {
+ return fetchFromMarble<MarblePost>(`posts/${slug}`);
+ }
+
+ export async function getCategories() {
+ return fetchFromMarble<MarbleCategoryList>('categories');
+ }
+
+ export async function getAuthors() {
+ return fetchFromMarble<MarbleAuthorList>('authors');
+ }
+
+export async function processHtmlContent(html: string): Promise<string> {
+ const processor = unified()
+ .use(rehypeSanitize)
+ .use(rehypeParse, { fragment: true })
+ .use(rehypeSlug)
+ .use(rehypeAutolinkHeadings, { behavior: "append" })
+ .use(rehypeStringify);
+
+ const file = await processor.process(html);
+ return String(file);
+} \ No newline at end of file
diff --git a/apps/web/src/types/post.ts b/apps/web/src/types/post.ts
new file mode 100644
index 00000000..c3436847
--- /dev/null
+++ b/apps/web/src/types/post.ts
@@ -0,0 +1,93 @@
+export type Post = {
+ id: string;
+ slug: string;
+ title: string;
+ content: string;
+ description: string;
+ coverImage: string;
+ publishedAt: Date;
+ updatedAt: Date;
+ authors: {
+ id: string;
+ name: string;
+ image: string;
+ }[];
+ category: {
+ id: string;
+ slug: string;
+ name: string;
+ };
+ tags: {
+ id: string;
+ slug: string;
+ name: string;
+ }[];
+ attribution: {
+ author: string;
+ url: string;
+ } | null;
+ };
+
+ export type Pagination = {
+ limit: number;
+ currpage: number;
+ nextPage: number | null;
+ prevPage: number | null;
+ totalItems: number;
+ totalPages: number;
+ };
+
+ export type MarblePostList = {
+ posts: Post[];
+ pagination: Pagination;
+ };
+
+ export type MarblePost = {
+ post: Post;
+ };
+
+ export type Tag = {
+ id: string;
+ name: string;
+ slug: string;
+ };
+
+ export type MarbleTag = {
+ tag: Tag;
+ };
+
+ export type MarbleTagList = {
+ tags: Tag[];
+ pagination: Pagination;
+ };
+
+ export type Category = {
+ id: string;
+ name: string;
+ slug: string;
+ };
+
+ export type MarbleCategory = {
+ category: Category;
+ };
+
+ export type MarbleCategoryList = {
+ categories: Category[];
+ pagination: Pagination;
+ };
+
+ export type Author = {
+ id: string;
+ name: string;
+ image: string;
+ };
+
+ export type MarbleAuthor = {
+ author: Author;
+ };
+
+ export type MarbleAuthorList = {
+ authors: Author[];
+ pagination: Pagination;
+ };
+ \ No newline at end of file