summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/.env.example4
-rw-r--r--apps/web/next.config.ts12
-rw-r--r--apps/web/package.json8
-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/editor/timeline/index.tsx15
-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
-rw-r--r--apps/web/tailwind.config.ts3
13 files changed, 460 insertions, 16 deletions
diff --git a/apps/web/.env.example b/apps/web/.env.example
index d63df851..b21f9a92 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -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
+NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com \ No newline at end of file
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 02261068..980c5827 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -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",
+ },
],
},
};
diff --git a/apps/web/package.json b/apps/web/package.json
index f4d975cd..c409136c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -23,6 +23,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",
@@ -54,14 +55,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",
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/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index 993aa6ad..5316355d 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -15,8 +15,7 @@ import {
Video,
Music,
TypeIcon,
- Lock,
- LockOpen,
+ Magnet,
Link,
ZoomIn,
ZoomOut,
@@ -279,7 +278,7 @@ export function Timeline() {
Math.min(
duration,
(mouseX + scrollLeft) /
- (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
+ (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
)
);
@@ -599,21 +598,19 @@ export function Timeline() {
return (
<div
key={i}
- className={`absolute top-0 bottom-0 ${
- isMainMarker
+ className={`absolute top-0 bottom-0 ${isMainMarker
? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20"
- }`}
+ }`}
style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
>
<span
- className={`absolute top-1 left-1 text-[0.6rem] ${
- isMainMarker
+ className={`absolute top-1 left-1 text-[0.6rem] ${isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
- }`}
+ }`}
>
{(() => {
const formatTime = (seconds: number) => {
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..430f58bf
--- /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.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com";
+const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46";
+
+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);
+}
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
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index 049add76..d992b7b4 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -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,