diff options
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/.env.example | 4 | ||||
| -rw-r--r-- | apps/web/next.config.ts | 12 | ||||
| -rw-r--r-- | apps/web/package.json | 8 | ||||
| -rw-r--r-- | apps/web/src/app/blog/[slug]/page.tsx | 144 | ||||
| -rw-r--r-- | apps/web/src/app/blog/page.tsx | 98 | ||||
| -rw-r--r-- | apps/web/src/app/metadata.ts | 3 | ||||
| -rw-r--r-- | apps/web/src/components/header.tsx | 5 | ||||
| -rw-r--r-- | apps/web/src/components/ui/prose.tsx | 22 | ||||
| -rw-r--r-- | apps/web/src/components/ui/separator.tsx | 14 | ||||
| -rw-r--r-- | apps/web/src/lib/blog-query.ts | 55 | ||||
| -rw-r--r-- | apps/web/src/types/post.ts | 93 | ||||
| -rw-r--r-- | apps/web/tailwind.config.ts | 3 |
12 files changed, 454 insertions, 7 deletions
diff --git a/apps/web/.env.example b/apps/web/.env.example index d63df851..267e2cab 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 +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 f7b09de8..965338b0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", 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 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, |
