diff --git a/README.md b/README.md index 1378b15a..99ab706e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - Real-time preview - No watermarks or subscriptions - Analytics provided by [Databuddy](https://www.databuddy.cc?utm_source=opencut), 100% Anonymized & Non-invasive. +- Blog powered by [Marble](https://marblecms.com?utm_source=opencut), Headless CMS. ## Project Structure @@ -113,6 +114,10 @@ Before you begin, ensure you have the following installed on your system: 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 + # Development NODE_ENV="development" ``` 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 { + 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 ( +
+
+ +
+
+
+
+
+ +
+
+ {data.post.coverImage && ( +
+ {data.post.title} +
+ )} +
+ +
+ +

+ {data.post.title} +

+
+ {data.post.authors[0] && ( + <> + {data.post.authors[0].name} +

+ {data.post.authors[0].name} +

+ + )} +
+
+ +
+ +
+
+
+
+ ); +} + +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
No posts yet
; + + return ( +
+
+ +
+
+
+
+
+ +
+
+

+ Blog +

+

+ Read the latest news and updates about OpenCut, the free and + open-source video editor. +

+
+
+ {data.posts.map((post) => ( + + + {post.coverImage && ( +
+ {post.title} +
+ )} + + + {post.authors && post.authors.length > 0 && ( +
+ {post.authors.map((author, index) => ( +
+ + + + {author.name.charAt(0).toUpperCase()} + + + + {author.name} + + {index < post.authors.length - 1 && ( + + )} +
+ ))} +
+ )} +

{post.title}

+

{post.description}

+
+
+ + ))} +
+
+
+
+ ); +} 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 (
{(() => { 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 = (