mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge pull request #402 from mezotv/feat/opencut-blog
feat: add blog powered by marble
This commit is contained in:
@@ -13,3 +13,7 @@ NODE_ENV=development
|
||||
# Redis
|
||||
UPSTASH_REDIS_REST_URL=http://localhost:8079
|
||||
UPSTASH_REDIS_REST_TOKEN=example_token
|
||||
|
||||
# Marble Blog
|
||||
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
|
||||
MARBLE_API_URL=https://api.marblecms.com
|
||||
@@ -18,6 +18,18 @@ const nextConfig: NextConfig = {
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.marblecms.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "lh3.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/auth": "workspace:*",
|
||||
"@opencut/db": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@t3-oss/env-core": "^0.13.8",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@upstash/ratelimit": "^2.0.5",
|
||||
@@ -52,14 +53,21 @@
|
||||
"react-phone-number-input": "^3.4.11",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.14.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-parse": "^9.0.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"vaul": "^1.1.1",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/bun": "latest",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/react": "^18.2.48",
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export const baseMetaData: Metadata = {
|
||||
creator: "@opencutapp",
|
||||
images: [twitterImageUrl],
|
||||
},
|
||||
pinterest: {
|
||||
richPin: false,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
@@ -106,6 +108,7 @@ export default {
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/typography"),
|
||||
require("tailwindcss-animate"),
|
||||
function ({
|
||||
addUtilities,
|
||||
|
||||
Reference in New Issue
Block a user