summaryrefslogtreecommitdiff
path: root/apps/web/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/app')
-rw-r--r--apps/web/src/app/api/auth/[...all]/route.ts8
-rw-r--r--apps/web/src/app/api/health/route.ts6
-rw-r--r--apps/web/src/app/api/sounds/search/route.ts560
-rw-r--r--apps/web/src/app/base-page.tsx112
-rw-r--r--apps/web/src/app/blog/[slug]/page.tsx270
-rw-r--r--apps/web/src/app/blog/page.tsx104
-rw-r--r--apps/web/src/app/brand/page.tsx536
-rw-r--r--apps/web/src/app/changelog/[version]/page.tsx210
-rw-r--r--apps/web/src/app/changelog/components/copy-markdown-button.tsx136
-rw-r--r--apps/web/src/app/changelog/components/release.tsx212
-rw-r--r--apps/web/src/app/changelog/utils.ts86
-rw-r--r--apps/web/src/app/contributors/page.tsx484
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx226
-rw-r--r--apps/web/src/app/globals.css568
-rw-r--r--apps/web/src/app/metadata.ts172
-rw-r--r--apps/web/src/app/page.tsx42
-rw-r--r--apps/web/src/app/projects/page.tsx2030
-rw-r--r--apps/web/src/app/projects/store.ts270
-rw-r--r--apps/web/src/app/roadmap/page.tsx292
-rw-r--r--apps/web/src/app/robots.ts26
-rw-r--r--apps/web/src/app/rss.xml/route.ts92
-rw-r--r--apps/web/src/app/sitemap.ts122
-rw-r--r--apps/web/src/app/sponsors/page.tsx172
23 files changed, 3368 insertions, 3368 deletions
diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts
index e17d0cce..544457e2 100644
--- a/apps/web/src/app/api/auth/[...all]/route.ts
+++ b/apps/web/src/app/api/auth/[...all]/route.ts
@@ -1,4 +1,4 @@
-import { auth } from "@/lib/auth/server";
-import { toNextJsHandler } from "better-auth/next-js";
-
-export const { POST, GET } = toNextJsHandler(auth);
+import { auth } from "@/lib/auth/server";
+import { toNextJsHandler } from "better-auth/next-js";
+
+export const { POST, GET } = toNextJsHandler(auth);
diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts
index edb40a0d..dbe6fdb1 100644
--- a/apps/web/src/app/api/health/route.ts
+++ b/apps/web/src/app/api/health/route.ts
@@ -1,3 +1,3 @@
-export async function GET() {
- return new Response("OK", { status: 200 });
-}
+export async function GET() {
+ return new Response("OK", { status: 200 });
+}
diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts
index 339a54da..3070d152 100644
--- a/apps/web/src/app/api/sounds/search/route.ts
+++ b/apps/web/src/app/api/sounds/search/route.ts
@@ -1,280 +1,280 @@
-import { webEnv } from "@opencut/env/web";
-import { type NextRequest, NextResponse } from "next/server";
-import { z } from "zod";
-import { checkRateLimit } from "@/lib/rate-limit";
-
-const searchParamsSchema = z.object({
- q: z.string().max(500, "Query too long").optional(),
- type: z.enum(["songs", "effects"]).optional(),
- page: z.coerce.number().int().min(1).max(1000).default(1),
- page_size: z.coerce.number().int().min(1).max(150).default(20),
- sort: z
- .enum(["downloads", "rating", "created", "score"])
- .default("downloads"),
- min_rating: z.coerce.number().min(0).max(5).default(3),
- commercial_only: z.coerce.boolean().default(true),
-});
-
-const freesoundResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string().url(),
- previews: z
- .object({
- "preview-hq-mp3": z.string().url(),
- "preview-lq-mp3": z.string().url(),
- "preview-hq-ogg": z.string().url(),
- "preview-lq-ogg": z.string().url(),
- })
- .optional(),
- download: z.string().url().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- num_downloads: z.number().optional(),
- avg_rating: z.number().optional(),
- num_ratings: z.number().optional(),
-});
-
-const freesoundResponseSchema = z.object({
- count: z.number(),
- next: z.string().url().nullable(),
- previous: z.string().url().nullable(),
- results: z.array(freesoundResultSchema),
-});
-
-const transformedResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string(),
- previewUrl: z.string().optional(),
- downloadUrl: z.string().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- downloads: z.number().optional(),
- rating: z.number().optional(),
- ratingCount: z.number().optional(),
-});
-
-const apiResponseSchema = z.object({
- count: z.number(),
- next: z.string().nullable(),
- previous: z.string().nullable(),
- results: z.array(transformedResultSchema),
- query: z.string().optional(),
- type: z.string(),
- page: z.number(),
- pageSize: z.number(),
- sort: z.string(),
- minRating: z.number().optional(),
-});
-
-function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
- if (!query) return `${sort}_desc`;
- return sort === "score" ? "score" : `${sort}_desc`;
-}
-
-function applyEffectsFilters({
- params,
- min_rating,
- commercial_only,
-}: {
- params: URLSearchParams;
- min_rating: number;
- commercial_only: boolean;
-}) {
- params.append("filter", "duration:[* TO 30.0]");
- params.append("filter", `avg_rating:[${min_rating} TO *]`);
-
- if (commercial_only) {
- params.append(
- "filter",
- 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
- );
- }
-
- params.append(
- "filter",
- "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
- );
-}
-
-function transformFreesoundResult(
- result: z.infer<typeof freesoundResultSchema>,
-) {
- return {
- id: result.id,
- name: result.name,
- description: result.description,
- url: result.url,
- previewUrl:
- result.previews?.["preview-hq-mp3"] ||
- result.previews?.["preview-lq-mp3"],
- downloadUrl: result.download,
- duration: result.duration,
- filesize: result.filesize,
- type: result.type,
- channels: result.channels,
- bitrate: result.bitrate,
- bitdepth: result.bitdepth,
- samplerate: result.samplerate,
- username: result.username,
- tags: result.tags,
- license: result.license,
- created: result.created,
- downloads: result.num_downloads || 0,
- rating: result.avg_rating || 0,
- ratingCount: result.num_ratings || 0,
- };
-}
-
-export async function GET(request: NextRequest) {
- try {
- const { limited } = await checkRateLimit({ request });
- if (limited) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
-
- const { searchParams } = new URL(request.url);
-
- const validationResult = searchParamsSchema.safeParse({
- q: searchParams.get("q") || undefined,
- type: searchParams.get("type") || undefined,
- page: searchParams.get("page") || undefined,
- page_size: searchParams.get("page_size") || undefined,
- sort: searchParams.get("sort") || undefined,
- min_rating: searchParams.get("min_rating") || undefined,
- });
-
- if (!validationResult.success) {
- return NextResponse.json(
- {
- error: "Invalid parameters",
- details: validationResult.error.flatten().fieldErrors,
- },
- { status: 400 },
- );
- }
-
- const {
- q: query,
- type,
- page,
- page_size: pageSize,
- sort,
- min_rating,
- commercial_only,
- } = validationResult.data;
-
- if (type === "songs") {
- return NextResponse.json(
- {
- error: "Songs are not available yet",
- message:
- "Song search functionality is coming soon. Try searching for sound effects instead.",
- },
- { status: 501 },
- );
- }
-
- const baseUrl = "https://freesound.org/apiv2/search/text/";
-
- const sortParam = buildSortParameter({ query, sort });
-
- const params = new URLSearchParams({
- query: query || "",
- token: webEnv.FREESOUND_API_KEY,
- page: page.toString(),
- page_size: pageSize.toString(),
- sort: sortParam,
- fields:
- "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
- });
-
- const isEffectsSearch = type === "effects" || !type;
- if (isEffectsSearch) {
- applyEffectsFilters({ params, min_rating, commercial_only });
- }
-
- const response = await fetch(`${baseUrl}?${params.toString()}`);
-
- if (!response.ok) {
- const errorText = await response.text();
- console.error("Freesound API error:", response.status, errorText);
- return NextResponse.json(
- { error: "Failed to search sounds" },
- { status: response.status },
- );
- }
-
- const rawData = await response.json();
-
- const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
- if (!freesoundValidation.success) {
- console.error(
- "Invalid Freesound API response:",
- freesoundValidation.error,
- );
- return NextResponse.json(
- { error: "Invalid response from Freesound API" },
- { status: 502 },
- );
- }
-
- const data = freesoundValidation.data;
-
- const transformedResults = data.results.map(transformFreesoundResult);
-
- const responseData = {
- count: data.count,
- next: data.next,
- previous: data.previous,
- results: transformedResults,
- query: query || "",
- type: type || "effects",
- page,
- pageSize,
- sort,
- minRating: min_rating,
- };
-
- const responseValidation = apiResponseSchema.safeParse(responseData);
- if (!responseValidation.success) {
- console.error(
- "Invalid API response structure:",
- responseValidation.error,
- );
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 },
- );
- }
-
- return NextResponse.json(responseValidation.data);
- } catch (error) {
- console.error("Error searching sounds:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
- );
- }
-}
+import { webEnv } from "@opencut/env/web";
+import { type NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { checkRateLimit } from "@/lib/rate-limit";
+
+const searchParamsSchema = z.object({
+ q: z.string().max(500, "Query too long").optional(),
+ type: z.enum(["songs", "effects"]).optional(),
+ page: z.coerce.number().int().min(1).max(1000).default(1),
+ page_size: z.coerce.number().int().min(1).max(150).default(20),
+ sort: z
+ .enum(["downloads", "rating", "created", "score"])
+ .default("downloads"),
+ min_rating: z.coerce.number().min(0).max(5).default(3),
+ commercial_only: z.coerce.boolean().default(true),
+});
+
+const freesoundResultSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string().url(),
+ previews: z
+ .object({
+ "preview-hq-mp3": z.string().url(),
+ "preview-lq-mp3": z.string().url(),
+ "preview-hq-ogg": z.string().url(),
+ "preview-lq-ogg": z.string().url(),
+ })
+ .optional(),
+ download: z.string().url().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ num_downloads: z.number().optional(),
+ avg_rating: z.number().optional(),
+ num_ratings: z.number().optional(),
+});
+
+const freesoundResponseSchema = z.object({
+ count: z.number(),
+ next: z.string().url().nullable(),
+ previous: z.string().url().nullable(),
+ results: z.array(freesoundResultSchema),
+});
+
+const transformedResultSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string(),
+ previewUrl: z.string().optional(),
+ downloadUrl: z.string().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ downloads: z.number().optional(),
+ rating: z.number().optional(),
+ ratingCount: z.number().optional(),
+});
+
+const apiResponseSchema = z.object({
+ count: z.number(),
+ next: z.string().nullable(),
+ previous: z.string().nullable(),
+ results: z.array(transformedResultSchema),
+ query: z.string().optional(),
+ type: z.string(),
+ page: z.number(),
+ pageSize: z.number(),
+ sort: z.string(),
+ minRating: z.number().optional(),
+});
+
+function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
+ if (!query) return `${sort}_desc`;
+ return sort === "score" ? "score" : `${sort}_desc`;
+}
+
+function applyEffectsFilters({
+ params,
+ min_rating,
+ commercial_only,
+}: {
+ params: URLSearchParams;
+ min_rating: number;
+ commercial_only: boolean;
+}) {
+ params.append("filter", "duration:[* TO 30.0]");
+ params.append("filter", `avg_rating:[${min_rating} TO *]`);
+
+ if (commercial_only) {
+ params.append(
+ "filter",
+ 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
+ );
+ }
+
+ params.append(
+ "filter",
+ "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
+ );
+}
+
+function transformFreesoundResult(
+ result: z.infer<typeof freesoundResultSchema>,
+) {
+ return {
+ id: result.id,
+ name: result.name,
+ description: result.description,
+ url: result.url,
+ previewUrl:
+ result.previews?.["preview-hq-mp3"] ||
+ result.previews?.["preview-lq-mp3"],
+ downloadUrl: result.download,
+ duration: result.duration,
+ filesize: result.filesize,
+ type: result.type,
+ channels: result.channels,
+ bitrate: result.bitrate,
+ bitdepth: result.bitdepth,
+ samplerate: result.samplerate,
+ username: result.username,
+ tags: result.tags,
+ license: result.license,
+ created: result.created,
+ downloads: result.num_downloads || 0,
+ rating: result.avg_rating || 0,
+ ratingCount: result.num_ratings || 0,
+ };
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const { limited } = await checkRateLimit({ request });
+ if (limited) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
+
+ const { searchParams } = new URL(request.url);
+
+ const validationResult = searchParamsSchema.safeParse({
+ q: searchParams.get("q") || undefined,
+ type: searchParams.get("type") || undefined,
+ page: searchParams.get("page") || undefined,
+ page_size: searchParams.get("page_size") || undefined,
+ sort: searchParams.get("sort") || undefined,
+ min_rating: searchParams.get("min_rating") || undefined,
+ });
+
+ if (!validationResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid parameters",
+ details: validationResult.error.flatten().fieldErrors,
+ },
+ { status: 400 },
+ );
+ }
+
+ const {
+ q: query,
+ type,
+ page,
+ page_size: pageSize,
+ sort,
+ min_rating,
+ commercial_only,
+ } = validationResult.data;
+
+ if (type === "songs") {
+ return NextResponse.json(
+ {
+ error: "Songs are not available yet",
+ message:
+ "Song search functionality is coming soon. Try searching for sound effects instead.",
+ },
+ { status: 501 },
+ );
+ }
+
+ const baseUrl = "https://freesound.org/apiv2/search/text/";
+
+ const sortParam = buildSortParameter({ query, sort });
+
+ const params = new URLSearchParams({
+ query: query || "",
+ token: webEnv.FREESOUND_API_KEY,
+ page: page.toString(),
+ page_size: pageSize.toString(),
+ sort: sortParam,
+ fields:
+ "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
+ });
+
+ const isEffectsSearch = type === "effects" || !type;
+ if (isEffectsSearch) {
+ applyEffectsFilters({ params, min_rating, commercial_only });
+ }
+
+ const response = await fetch(`${baseUrl}?${params.toString()}`);
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("Freesound API error:", response.status, errorText);
+ return NextResponse.json(
+ { error: "Failed to search sounds" },
+ { status: response.status },
+ );
+ }
+
+ const rawData = await response.json();
+
+ const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
+ if (!freesoundValidation.success) {
+ console.error(
+ "Invalid Freesound API response:",
+ freesoundValidation.error,
+ );
+ return NextResponse.json(
+ { error: "Invalid response from Freesound API" },
+ { status: 502 },
+ );
+ }
+
+ const data = freesoundValidation.data;
+
+ const transformedResults = data.results.map(transformFreesoundResult);
+
+ const responseData = {
+ count: data.count,
+ next: data.next,
+ previous: data.previous,
+ results: transformedResults,
+ query: query || "",
+ type: type || "effects",
+ page,
+ pageSize,
+ sort,
+ minRating: min_rating,
+ };
+
+ const responseValidation = apiResponseSchema.safeParse(responseData);
+ if (!responseValidation.success) {
+ console.error(
+ "Invalid API response structure:",
+ responseValidation.error,
+ );
+ return NextResponse.json(
+ { error: "Internal response formatting error" },
+ { status: 500 },
+ );
+ }
+
+ return NextResponse.json(responseValidation.data);
+ } catch (error) {
+ console.error("Error searching sounds:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/src/app/base-page.tsx b/apps/web/src/app/base-page.tsx
index 1841a1ee..78768581 100644
--- a/apps/web/src/app/base-page.tsx
+++ b/apps/web/src/app/base-page.tsx
@@ -1,56 +1,56 @@
-import { Header } from "@/components/header";
-import { Footer } from "@/components/footer";
-import { cn } from "@/utils/ui";
-
-interface BasePageProps {
- children: React.ReactNode;
- className?: string;
- mainClassName?: string;
- maxWidth?: "3xl" | "6xl" | "full";
- title?: string;
- description?: React.ReactNode;
- action?: React.ReactNode;
-}
-
-export function BasePage({
- children,
- className = "",
- mainClassName = "",
- maxWidth = "3xl",
- title,
- description,
- action,
-}: BasePageProps) {
- const maxWidthClass = {
- "3xl": "max-w-3xl",
- "6xl": "max-w-6xl",
- full: "max-w-full",
- }[maxWidth];
-
- return (
- <section className={cn("bg-background min-h-screen", className)}>
- <Header />
- <main
- className={cn(
- "relative container mx-auto flex flex-col gap-12 px-6 pt-12 pb-24 md:pt-24",
- maxWidthClass,
- mainClassName,
- )}
- >
- {title && description && (
- <div className="flex flex-col gap-8 text-center">
- <h1 className="text-5xl font-bold tracking-tight md:text-6xl">
- {title}
- </h1>
- <p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed">
- {description}
- </p>
- {action}
- </div>
- )}
- {children}
- </main>
- <Footer />
- </section>
- );
-}
+import { Header } from "@/components/header";
+import { Footer } from "@/components/footer";
+import { cn } from "@/utils/ui";
+
+interface BasePageProps {
+ children: React.ReactNode;
+ className?: string;
+ mainClassName?: string;
+ maxWidth?: "3xl" | "6xl" | "full";
+ title?: string;
+ description?: React.ReactNode;
+ action?: React.ReactNode;
+}
+
+export function BasePage({
+ children,
+ className = "",
+ mainClassName = "",
+ maxWidth = "3xl",
+ title,
+ description,
+ action,
+}: BasePageProps) {
+ const maxWidthClass = {
+ "3xl": "max-w-3xl",
+ "6xl": "max-w-6xl",
+ full: "max-w-full",
+ }[maxWidth];
+
+ return (
+ <section className={cn("bg-background min-h-screen", className)}>
+ <Header />
+ <main
+ className={cn(
+ "relative container mx-auto flex flex-col gap-12 px-6 pt-12 pb-24 md:pt-24",
+ maxWidthClass,
+ mainClassName,
+ )}
+ >
+ {title && description && (
+ <div className="flex flex-col gap-8 text-center">
+ <h1 className="text-5xl font-bold tracking-tight md:text-6xl">
+ {title}
+ </h1>
+ <p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed">
+ {description}
+ </p>
+ {action}
+ </div>
+ )}
+ {children}
+ </main>
+ <Footer />
+ </section>
+ );
+}
diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx
index 90afd919..b581de95 100644
--- a/apps/web/src/app/blog/[slug]/page.tsx
+++ b/apps/web/src/app/blog/[slug]/page.tsx
@@ -1,135 +1,135 @@
-import type { Metadata } from "next";
-import Image from "next/image";
-import { notFound } from "next/navigation";
-import { BasePage } from "@/app/base-page";
-import Prose from "@/components/ui/prose";
-import { Separator } from "@/components/ui/separator";
-import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
-import type { Author, Post } from "@/lib/blog/types";
-
-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: Author) => author.name),
- },
- };
-}
-
-export async function generateStaticParams() {
- const data = await getPosts();
- if (!data || !data.posts.length) return [];
-
- return data.posts.map((post) => ({
- slug: post.slug,
- }));
-}
-
-export default async function BlogPostPage({ params }: PageProps) {
- const slug = (await params).slug;
- const data = await getSinglePost({ slug });
- if (!data || !data.post) return notFound();
-
- const html = await processHtmlContent({ html: data.post.content });
-
- return (
- <BasePage>
- <PostHeader post={data.post} />
- <Separator />
- <PostContent html={html} />
- </BasePage>
- );
-}
-
-function PostHeader({ post }: { post: Post }) {
- const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
- day: "numeric",
- month: "long",
- year: "numeric",
- });
-
- return (
- <div className="flex flex-col items-center justify-center gap-8">
- <PostMeta date={formattedDate} publishedAt={post.publishedAt} />
- <PostTitle title={post.title} />
- {post.coverImage && <PostCoverImage post={post} />}
- </div>
- );
-}
-
-function PostCoverImage({ post }: { post: Post }) {
- return (
- <div className="relative aspect-video overflow-hidden rounded-lg w-full mt-4">
- <Image
- src={post.coverImage}
- alt={post.title}
- loading="eager"
- fill
- className="rounded-lg object-cover"
- />
- </div>
- );
-}
-
-function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
- return (
- <div className="flex items-center justify-center">
- <time dateTime={publishedAt.toString()}>{date}</time>
- </div>
- );
-}
-
-function PostTitle({ title }: { title: string }) {
- return (
- <h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">
- {title}
- </h1>
- );
-}
-
-function PostContent({ html }: { html: string }) {
- return (
- <section className="">
- <Prose html={html} />
- </section>
- );
-}
+import type { Metadata } from "next";
+import Image from "next/image";
+import { notFound } from "next/navigation";
+import { BasePage } from "@/app/base-page";
+import Prose from "@/components/ui/prose";
+import { Separator } from "@/components/ui/separator";
+import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
+import type { Author, Post } from "@/lib/blog/types";
+
+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: Author) => author.name),
+ },
+ };
+}
+
+export async function generateStaticParams() {
+ const data = await getPosts();
+ if (!data || !data.posts.length) return [];
+
+ return data.posts.map((post) => ({
+ slug: post.slug,
+ }));
+}
+
+export default async function BlogPostPage({ params }: PageProps) {
+ const slug = (await params).slug;
+ const data = await getSinglePost({ slug });
+ if (!data || !data.post) return notFound();
+
+ const html = await processHtmlContent({ html: data.post.content });
+
+ return (
+ <BasePage>
+ <PostHeader post={data.post} />
+ <Separator />
+ <PostContent html={html} />
+ </BasePage>
+ );
+}
+
+function PostHeader({ post }: { post: Post }) {
+ const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ });
+
+ return (
+ <div className="flex flex-col items-center justify-center gap-8">
+ <PostMeta date={formattedDate} publishedAt={post.publishedAt} />
+ <PostTitle title={post.title} />
+ {post.coverImage && <PostCoverImage post={post} />}
+ </div>
+ );
+}
+
+function PostCoverImage({ post }: { post: Post }) {
+ return (
+ <div className="relative aspect-video overflow-hidden rounded-lg w-full mt-4">
+ <Image
+ src={post.coverImage}
+ alt={post.title}
+ loading="eager"
+ fill
+ className="rounded-lg object-cover"
+ />
+ </div>
+ );
+}
+
+function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
+ return (
+ <div className="flex items-center justify-center">
+ <time dateTime={publishedAt.toString()}>{date}</time>
+ </div>
+ );
+}
+
+function PostTitle({ title }: { title: string }) {
+ return (
+ <h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">
+ {title}
+ </h1>
+ );
+}
+
+function PostContent({ html }: { html: string }) {
+ return (
+ <section className="">
+ <Prose html={html} />
+ </section>
+ );
+}
diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx
index df0b195d..9f5a8972 100644
--- a/apps/web/src/app/blog/page.tsx
+++ b/apps/web/src/app/blog/page.tsx
@@ -1,52 +1,52 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { BasePage } from "@/app/base-page";
-import { Separator } from "@/components/ui/separator";
-import { getPosts } from "@/lib/blog/query";
-import type { Post } from "@/lib/blog/types";
-
-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().catch(() => null);
- if (!data || !data.posts) return <div>No posts yet</div>;
-
- return (
- <BasePage
- title="Blog"
- description="Read the latest news and updates about OpenCut, the free and open-source video editor."
- >
- <div className="flex flex-col">
- {data.posts.map((post) => (
- <div key={post.id} className="flex flex-col">
- <BlogPostItem post={post} />
- <Separator />
- </div>
- ))}
- </div>
- </BasePage>
- );
-}
-
-function BlogPostItem({ post }: { post: Post }) {
- return (
- <Link href={`/blog/${post.slug}`}>
- <div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
- <div className="flex flex-col gap-2">
- <h2 className="text-xl font-semibold">{post.title}</h2>
- <p className="text-muted-foreground">{post.description}</p>
- </div>
- </div>
- </Link>
- );
-}
+import type { Metadata } from "next";
+import Link from "next/link";
+import { BasePage } from "@/app/base-page";
+import { Separator } from "@/components/ui/separator";
+import { getPosts } from "@/lib/blog/query";
+import type { Post } from "@/lib/blog/types";
+
+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().catch(() => null);
+ if (!data || !data.posts) return <div>No posts yet</div>;
+
+ return (
+ <BasePage
+ title="Blog"
+ description="Read the latest news and updates about OpenCut, the free and open-source video editor."
+ >
+ <div className="flex flex-col">
+ {data.posts.map((post) => (
+ <div key={post.id} className="flex flex-col">
+ <BlogPostItem post={post} />
+ <Separator />
+ </div>
+ ))}
+ </div>
+ </BasePage>
+ );
+}
+
+function BlogPostItem({ post }: { post: Post }) {
+ return (
+ <Link href={`/blog/${post.slug}`}>
+ <div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
+ <div className="flex flex-col gap-2">
+ <h2 className="text-xl font-semibold">{post.title}</h2>
+ <p className="text-muted-foreground">{post.description}</p>
+ </div>
+ </div>
+ </Link>
+ );
+}
diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx
index 94e3f3e1..a7085154 100644
--- a/apps/web/src/app/brand/page.tsx
+++ b/apps/web/src/app/brand/page.tsx
@@ -1,268 +1,268 @@
-"use client";
-
-import type { CSSProperties } from "react";
-import Image from "next/image";
-import Link from "next/link";
-import { Check, Copy, Download } from "lucide-react";
-import { useState } from "react";
-import { BasePage } from "@/app/base-page";
-import { Button } from "@/components/ui/button";
-import { Card } from "@/components/ui/card";
-import { Separator } from "@/components/ui/separator";
-import { cn } from "@/utils/ui";
-
-function downloadAsset(src: string) {
- const filename = src.split("/").pop() ?? "asset.svg";
- const a = document.createElement("a");
- a.href = src;
- a.download = filename;
- a.click();
-}
-
-async function copyAsset(src: string) {
- const res = await fetch(src);
- const text = await res.text();
- await navigator.clipboard.writeText(text);
-}
-
-const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
-
-type AssetTheme = "dark" | "light" | "icon";
-
-interface AssetVariant {
- src: string;
- theme: AssetTheme;
- label: string;
- width: number;
- height: number;
-}
-
-interface AssetSection {
- title: string;
- description: string;
- cols: "1" | "2";
- assets: AssetVariant[];
-}
-
-const ASSET_SECTIONS: AssetSection[] = [
- {
- title: "Symbol",
- description:
- "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
- cols: "2",
- assets: [
- {
- src: "/logos/opencut/symbol.svg",
- theme: "dark",
- label: "Symbol",
- width: 400,
- height: 400,
- },
- {
- src: "/logos/opencut/symbol-light.svg",
- theme: "light",
- label: "Symbol",
- width: 400,
- height: 400,
- },
- ],
- },
- {
- title: "Lockup",
- description:
- "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
- cols: "2",
- assets: [
- {
- src: "/logos/opencut/logo.svg",
- theme: "dark",
- label: "Logo",
- width: 1809,
- height: 400,
- },
- {
- src: "/logos/opencut/logo-light.svg",
- theme: "light",
- label: "Logo",
- width: 1809,
- height: 400,
- },
- {
- src: "/logos/opencut/text.svg",
- theme: "dark",
- label: "Text",
- width: 1760,
- height: 400,
- },
- {
- src: "/logos/opencut/text-light.svg",
- theme: "light",
- label: "Text",
- width: 1760,
- height: 400,
- },
- ],
- },
-];
-
-export default function BrandPage() {
- return (
- <BasePage
- maxWidth="6xl"
- title="Brand"
- description={
- <>
- Download OpenCut brand assets for use in your projects.{" "}
- <Link
- href="#guidelines"
- className="underline underline-offset-4"
- onClick={() =>
- document
- .getElementById("guidelines")
- ?.scrollIntoView({ behavior: "smooth" })
- }
- >
- Read the brand guidelines.
- </Link>
- </>
- }
- action={
- <Button
- variant="outline"
- size="lg"
- className="mx-auto gap-2"
- onClick={() => {
- ALL_ASSETS().forEach((asset, i) => {
- setTimeout(() => downloadAsset(asset.src), i * 200);
- });
- }}
- >
- <Download />
- Download all
- </Button>
- }
- >
- <div className="flex flex-col gap-10">
- {ASSET_SECTIONS.map((section) => (
- <div key={section.title} className="flex flex-col gap-4">
- <div className="flex flex-col gap-1">
- <h2 className="font-semibold text-lg">{section.title}</h2>
- <p className="text-muted-foreground text-sm">
- {section.description}
- </p>
- </div>
- <div
- className={cn(
- "grid gap-3",
- section.cols === "2"
- ? "grid-cols-1 sm:grid-cols-2"
- : "grid-cols-1",
- )}
- >
- {section.assets.map((variant) => (
- <AssetCard key={variant.src} variant={variant} />
- ))}
- </div>
- </div>
- ))}
- </div>
-
- <Separator />
-
- <div id="guidelines" className="flex flex-col gap-8 text-sm">
- <div className="flex flex-col gap-3">
- <h2 className="font-semibold text-lg">Usage</h2>
- <p className="text-muted-foreground text-base leading-relaxed">
- OpenCut is open source — the code is free to use under its license.
- That license does not cover the name or logo. You can say you use
- OpenCut, that your project integrates with OpenCut, or that it was
- built on top of OpenCut. You cannot name your product OpenCut, imply
- we made or endorse your product, or use the marks commercially
- without asking first. For anything unclear, reach out at{" "}
- <Link
- href="mailto:brand@opencut.app"
- className="underline underline-offset-4"
- >
- brand@opencut.app
- </Link>
- .
- </p>
- </div>
-
- <div className="flex flex-col gap-3">
- <h2 className="font-semibold text-lg">What's not allowed</h2>
- <ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
- {[
- "Using OpenCut in the name of your product, service, or domain.",
- "Implying that OpenCut made, sponsors, or endorses your work.",
- "Using the logo or name on merchandise or commercial marketing.",
- "Modifying the marks.",
- ].map((item) => (
- <li key={item} className="flex gap-2">
- <span className="mt-0.5 shrink-0">-</span>
- <span>{item}</span>
- </li>
- ))}
- </ul>
- </div>
- </div>
- </BasePage>
- );
-}
-
-const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
- light: {
- backgroundImage:
- "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
- backgroundSize: "18px 18px",
- backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
- backgroundColor: "#000",
- },
- dark: {
- backgroundImage:
- "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
- backgroundSize: "18px 18px",
- backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
- backgroundColor: "#f5f5f5",
- },
-};
-
-function AssetCard({ variant }: { variant: AssetVariant }) {
- const [copied, setCopied] = useState(false);
-
- async function handleCopy() {
- await copyAsset(variant.src);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
-
- return (
- <Card
- className="group relative overflow-hidden"
- style={
- variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
- }
- >
- <div className="flex h-56 items-center justify-center px-12 py-8">
- <Image
- src={variant.src}
- alt={variant.label}
- width={variant.width}
- height={variant.height}
- className="max-h-16 w-auto select-none object-contain"
- draggable={false}
- unoptimized
- />
- </div>
-
- <Button
- variant="outline"
- size="icon"
- className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
- onClick={handleCopy}
- >
- {copied ? <Check /> : <Copy />}
- </Button>
- </Card>
- );
-}
+"use client";
+
+import type { CSSProperties } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import { Check, Copy, Download } from "lucide-react";
+import { useState } from "react";
+import { BasePage } from "@/app/base-page";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/utils/ui";
+
+function downloadAsset(src: string) {
+ const filename = src.split("/").pop() ?? "asset.svg";
+ const a = document.createElement("a");
+ a.href = src;
+ a.download = filename;
+ a.click();
+}
+
+async function copyAsset(src: string) {
+ const res = await fetch(src);
+ const text = await res.text();
+ await navigator.clipboard.writeText(text);
+}
+
+const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
+
+type AssetTheme = "dark" | "light" | "icon";
+
+interface AssetVariant {
+ src: string;
+ theme: AssetTheme;
+ label: string;
+ width: number;
+ height: number;
+}
+
+interface AssetSection {
+ title: string;
+ description: string;
+ cols: "1" | "2";
+ assets: AssetVariant[];
+}
+
+const ASSET_SECTIONS: AssetSection[] = [
+ {
+ title: "Symbol",
+ description:
+ "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
+ cols: "2",
+ assets: [
+ {
+ src: "/logos/opencut/symbol.svg",
+ theme: "dark",
+ label: "Symbol",
+ width: 400,
+ height: 400,
+ },
+ {
+ src: "/logos/opencut/symbol-light.svg",
+ theme: "light",
+ label: "Symbol",
+ width: 400,
+ height: 400,
+ },
+ ],
+ },
+ {
+ title: "Lockup",
+ description:
+ "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
+ cols: "2",
+ assets: [
+ {
+ src: "/logos/opencut/logo.svg",
+ theme: "dark",
+ label: "Logo",
+ width: 1809,
+ height: 400,
+ },
+ {
+ src: "/logos/opencut/logo-light.svg",
+ theme: "light",
+ label: "Logo",
+ width: 1809,
+ height: 400,
+ },
+ {
+ src: "/logos/opencut/text.svg",
+ theme: "dark",
+ label: "Text",
+ width: 1760,
+ height: 400,
+ },
+ {
+ src: "/logos/opencut/text-light.svg",
+ theme: "light",
+ label: "Text",
+ width: 1760,
+ height: 400,
+ },
+ ],
+ },
+];
+
+export default function BrandPage() {
+ return (
+ <BasePage
+ maxWidth="6xl"
+ title="Brand"
+ description={
+ <>
+ Download OpenCut brand assets for use in your projects.{" "}
+ <Link
+ href="#guidelines"
+ className="underline underline-offset-4"
+ onClick={() =>
+ document
+ .getElementById("guidelines")
+ ?.scrollIntoView({ behavior: "smooth" })
+ }
+ >
+ Read the brand guidelines.
+ </Link>
+ </>
+ }
+ action={
+ <Button
+ variant="outline"
+ size="lg"
+ className="mx-auto gap-2"
+ onClick={() => {
+ ALL_ASSETS().forEach((asset, i) => {
+ setTimeout(() => downloadAsset(asset.src), i * 200);
+ });
+ }}
+ >
+ <Download />
+ Download all
+ </Button>
+ }
+ >
+ <div className="flex flex-col gap-10">
+ {ASSET_SECTIONS.map((section) => (
+ <div key={section.title} className="flex flex-col gap-4">
+ <div className="flex flex-col gap-1">
+ <h2 className="font-semibold text-lg">{section.title}</h2>
+ <p className="text-muted-foreground text-sm">
+ {section.description}
+ </p>
+ </div>
+ <div
+ className={cn(
+ "grid gap-3",
+ section.cols === "2"
+ ? "grid-cols-1 sm:grid-cols-2"
+ : "grid-cols-1",
+ )}
+ >
+ {section.assets.map((variant) => (
+ <AssetCard key={variant.src} variant={variant} />
+ ))}
+ </div>
+ </div>
+ ))}
+ </div>
+
+ <Separator />
+
+ <div id="guidelines" className="flex flex-col gap-8 text-sm">
+ <div className="flex flex-col gap-3">
+ <h2 className="font-semibold text-lg">Usage</h2>
+ <p className="text-muted-foreground text-base leading-relaxed">
+ OpenCut is open source — the code is free to use under its license.
+ That license does not cover the name or logo. You can say you use
+ OpenCut, that your project integrates with OpenCut, or that it was
+ built on top of OpenCut. You cannot name your product OpenCut, imply
+ we made or endorse your product, or use the marks commercially
+ without asking first. For anything unclear, reach out at{" "}
+ <Link
+ href="mailto:brand@opencut.app"
+ className="underline underline-offset-4"
+ >
+ brand@opencut.app
+ </Link>
+ .
+ </p>
+ </div>
+
+ <div className="flex flex-col gap-3">
+ <h2 className="font-semibold text-lg">What's not allowed</h2>
+ <ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
+ {[
+ "Using OpenCut in the name of your product, service, or domain.",
+ "Implying that OpenCut made, sponsors, or endorses your work.",
+ "Using the logo or name on merchandise or commercial marketing.",
+ "Modifying the marks.",
+ ].map((item) => (
+ <li key={item} className="flex gap-2">
+ <span className="mt-0.5 shrink-0">-</span>
+ <span>{item}</span>
+ </li>
+ ))}
+ </ul>
+ </div>
+ </div>
+ </BasePage>
+ );
+}
+
+const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
+ light: {
+ backgroundImage:
+ "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
+ backgroundSize: "18px 18px",
+ backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
+ backgroundColor: "#000",
+ },
+ dark: {
+ backgroundImage:
+ "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
+ backgroundSize: "18px 18px",
+ backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
+ backgroundColor: "#f5f5f5",
+ },
+};
+
+function AssetCard({ variant }: { variant: AssetVariant }) {
+ const [copied, setCopied] = useState(false);
+
+ async function handleCopy() {
+ await copyAsset(variant.src);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+
+ return (
+ <Card
+ className="group relative overflow-hidden"
+ style={
+ variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
+ }
+ >
+ <div className="flex h-56 items-center justify-center px-12 py-8">
+ <Image
+ src={variant.src}
+ alt={variant.label}
+ width={variant.width}
+ height={variant.height}
+ className="max-h-16 w-auto select-none object-contain"
+ draggable={false}
+ unoptimized
+ />
+ </div>
+
+ <Button
+ variant="outline"
+ size="icon"
+ className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
+ onClick={handleCopy}
+ >
+ {copied ? <Check /> : <Copy />}
+ </Button>
+ </Card>
+ );
+}
diff --git a/apps/web/src/app/changelog/[version]/page.tsx b/apps/web/src/app/changelog/[version]/page.tsx
index af382822..a3647fbd 100644
--- a/apps/web/src/app/changelog/[version]/page.tsx
+++ b/apps/web/src/app/changelog/[version]/page.tsx
@@ -1,105 +1,105 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { notFound } from "next/navigation";
-import { BasePage } from "@/app/base-page";
-import { allChangelogs } from "content-collections";
-import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
-import { getSortedReleases } from "../utils";
-import {
- ReleaseArticle,
- ReleaseMeta,
- ReleaseTitle,
- ReleaseDescription,
- ReleaseChanges,
-} from "../components/release";
-import { CopyMarkdownButton } from "../components/copy-markdown-button";
-
-type Props = { params: Promise<{ version: string }> };
-
-export async function generateStaticParams() {
- return allChangelogs.map((r) => ({ version: r.version }));
-}
-
-export async function generateMetadata({ params }: Props): Promise<Metadata> {
- const { version } = await params;
- const release = allChangelogs.find((r) => r.version === version);
- if (!release) return {};
- return {
- title: `${release.title} (${release.version}) - OpenCut Changelog`,
- description: release.description,
- };
-}
-
-export default async function ReleaseDetailPage({ params }: Props) {
- const { version } = await params;
- const releases = getSortedReleases();
- const index = releases.findIndex((r) => r.version === version);
-
- if (index === -1) notFound();
-
- const release = releases[index];
- const newer = index > 0 ? releases[index - 1] : null;
- const older = index < releases.length - 1 ? releases[index + 1] : null;
-
- return (
- <BasePage>
- <div className="mx-auto w-full max-w-3xl flex flex-col gap-12">
- <Link
- href="/changelog"
- className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 w-fit"
- >
- <ChevronLeftIcon className="size-4" />
- All releases
- </Link>
-
- <ReleaseArticle variant="detail">
- <div className="flex flex-col gap-4">
- <div className="flex items-center justify-between">
- <ReleaseMeta release={release} />
- <CopyMarkdownButton
- description={release.description}
- changes={release.changes}
- />
- </div>
- <ReleaseTitle as="h1">{release.title}</ReleaseTitle>
- {release.description && (
- <ReleaseDescription>{release.description}</ReleaseDescription>
- )}
- </div>
- <ReleaseChanges release={release} />
- </ReleaseArticle>
-
- <nav className="flex items-center justify-between border-t pt-8">
- {older ? (
- <Link
- href={`/changelog/${older.version}`}
- className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group"
- >
- <ChevronLeftIcon className="size-4" />
- <div className="flex flex-col">
- <span className="text-xs text-muted-foreground/60">Older</span>
- <span className="font-medium">{older.title}</span>
- </div>
- </Link>
- ) : (
- <div />
- )}
- {newer ? (
- <Link
- href={`/changelog/${newer.version}`}
- className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group text-right"
- >
- <div className="flex flex-col">
- <span className="text-xs text-muted-foreground/60">Newer</span>
- <span className="font-medium">{newer.title}</span>
- </div>
- <ChevronRightIcon className="size-4" />
- </Link>
- ) : (
- <div />
- )}
- </nav>
- </div>
- </BasePage>
- );
-}
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { BasePage } from "@/app/base-page";
+import { allChangelogs } from "content-collections";
+import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
+import { getSortedReleases } from "../utils";
+import {
+ ReleaseArticle,
+ ReleaseMeta,
+ ReleaseTitle,
+ ReleaseDescription,
+ ReleaseChanges,
+} from "../components/release";
+import { CopyMarkdownButton } from "../components/copy-markdown-button";
+
+type Props = { params: Promise<{ version: string }> };
+
+export async function generateStaticParams() {
+ return allChangelogs.map((r) => ({ version: r.version }));
+}
+
+export async function generateMetadata({ params }: Props): Promise<Metadata> {
+ const { version } = await params;
+ const release = allChangelogs.find((r) => r.version === version);
+ if (!release) return {};
+ return {
+ title: `${release.title} (${release.version}) - OpenCut Changelog`,
+ description: release.description,
+ };
+}
+
+export default async function ReleaseDetailPage({ params }: Props) {
+ const { version } = await params;
+ const releases = getSortedReleases();
+ const index = releases.findIndex((r) => r.version === version);
+
+ if (index === -1) notFound();
+
+ const release = releases[index];
+ const newer = index > 0 ? releases[index - 1] : null;
+ const older = index < releases.length - 1 ? releases[index + 1] : null;
+
+ return (
+ <BasePage>
+ <div className="mx-auto w-full max-w-3xl flex flex-col gap-12">
+ <Link
+ href="/changelog"
+ className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 w-fit"
+ >
+ <ChevronLeftIcon className="size-4" />
+ All releases
+ </Link>
+
+ <ReleaseArticle variant="detail">
+ <div className="flex flex-col gap-4">
+ <div className="flex items-center justify-between">
+ <ReleaseMeta release={release} />
+ <CopyMarkdownButton
+ description={release.description}
+ changes={release.changes}
+ />
+ </div>
+ <ReleaseTitle as="h1">{release.title}</ReleaseTitle>
+ {release.description && (
+ <ReleaseDescription>{release.description}</ReleaseDescription>
+ )}
+ </div>
+ <ReleaseChanges release={release} />
+ </ReleaseArticle>
+
+ <nav className="flex items-center justify-between border-t pt-8">
+ {older ? (
+ <Link
+ href={`/changelog/${older.version}`}
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group"
+ >
+ <ChevronLeftIcon className="size-4" />
+ <div className="flex flex-col">
+ <span className="text-xs text-muted-foreground/60">Older</span>
+ <span className="font-medium">{older.title}</span>
+ </div>
+ </Link>
+ ) : (
+ <div />
+ )}
+ {newer ? (
+ <Link
+ href={`/changelog/${newer.version}`}
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group text-right"
+ >
+ <div className="flex flex-col">
+ <span className="text-xs text-muted-foreground/60">Newer</span>
+ <span className="font-medium">{newer.title}</span>
+ </div>
+ <ChevronRightIcon className="size-4" />
+ </Link>
+ ) : (
+ <div />
+ )}
+ </nav>
+ </div>
+ </BasePage>
+ );
+}
diff --git a/apps/web/src/app/changelog/components/copy-markdown-button.tsx b/apps/web/src/app/changelog/components/copy-markdown-button.tsx
index 2366550a..edb920f5 100644
--- a/apps/web/src/app/changelog/components/copy-markdown-button.tsx
+++ b/apps/web/src/app/changelog/components/copy-markdown-button.tsx
@@ -1,68 +1,68 @@
-"use client";
-
-import { useState } from "react";
-import { CheckIcon, ClipboardIcon } from "lucide-react";
-import { getSectionTitle, groupAndOrderChanges } from "../utils";
-import type { Change } from "../utils";
-import { cn } from "@/utils/ui";
-import { Button } from "@/components/ui/button";
-
-function buildMarkdown({
- description,
- changes,
-}: {
- description?: string;
- changes: Change[];
-}): string {
- const lines: string[] = [];
-
- if (description) {
- lines.push(description, "");
- }
-
- const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
-
- for (const type of orderedTypes) {
- lines.push(`## ${getSectionTitle(type)}`);
- for (const change of grouped[type]) {
- lines.push(`- ${change.text}`);
- }
- lines.push("");
- }
-
- return lines.join("\n").trimEnd();
-}
-
-export function CopyMarkdownButton({
- description,
- changes,
-}: {
- description?: string;
- changes: Change[];
-}) {
- const [copied, setCopied] = useState(false);
-
- const handleCopy = async () => {
- const markdown = buildMarkdown({ description, changes });
- await navigator.clipboard.writeText(markdown);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- };
-
- return (
- <Button
- size="sm"
- variant="text"
- onClick={handleCopy}
- className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
- title="Copy as markdown"
- >
- {copied ? (
- <CheckIcon className="size-4" />
- ) : (
- <ClipboardIcon className="size-4" />
- )}
- {copied ? "Copied!" : "Copy markdown"}
- </Button>
- );
-}
+"use client";
+
+import { useState } from "react";
+import { CheckIcon, ClipboardIcon } from "lucide-react";
+import { getSectionTitle, groupAndOrderChanges } from "../utils";
+import type { Change } from "../utils";
+import { cn } from "@/utils/ui";
+import { Button } from "@/components/ui/button";
+
+function buildMarkdown({
+ description,
+ changes,
+}: {
+ description?: string;
+ changes: Change[];
+}): string {
+ const lines: string[] = [];
+
+ if (description) {
+ lines.push(description, "");
+ }
+
+ const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
+
+ for (const type of orderedTypes) {
+ lines.push(`## ${getSectionTitle(type)}`);
+ for (const change of grouped[type]) {
+ lines.push(`- ${change.text}`);
+ }
+ lines.push("");
+ }
+
+ return lines.join("\n").trimEnd();
+}
+
+export function CopyMarkdownButton({
+ description,
+ changes,
+}: {
+ description?: string;
+ changes: Change[];
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ const markdown = buildMarkdown({ description, changes });
+ await navigator.clipboard.writeText(markdown);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+ <Button
+ size="sm"
+ variant="text"
+ onClick={handleCopy}
+ className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
+ title="Copy as markdown"
+ >
+ {copied ? (
+ <CheckIcon className="size-4" />
+ ) : (
+ <ClipboardIcon className="size-4" />
+ )}
+ {copied ? "Copied!" : "Copy markdown"}
+ </Button>
+ );
+}
diff --git a/apps/web/src/app/changelog/components/release.tsx b/apps/web/src/app/changelog/components/release.tsx
index eb04c1f0..2226c321 100644
--- a/apps/web/src/app/changelog/components/release.tsx
+++ b/apps/web/src/app/changelog/components/release.tsx
@@ -1,106 +1,106 @@
-import type { ReactNode } from "react";
-import Link from "next/link";
-import { cn } from "@/utils/ui";
-import { getSectionTitle, groupAndOrderChanges } from "../utils";
-import type { Release } from "../utils";
-
-export function ReleaseArticle({
- variant,
- isLatest,
- children,
-}: {
- variant: "list" | "detail";
- isLatest?: boolean;
- children: ReactNode;
-}) {
- if (variant === "list") {
- return (
- <article className="relative sm:pl-10">
- <div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
- <div
- className={cn(
- "size-[11px] rounded-full border-[1.5px]",
- isLatest
- ? "border-foreground bg-foreground"
- : "border-muted-foreground/30 bg-background",
- )}
- />
- </div>
- <div className="flex flex-col gap-5">{children}</div>
- </article>
- );
- }
-
- return <article className="flex flex-col gap-8">{children}</article>;
-}
-
-export function ReleaseMeta({ release }: { release: Release }) {
- return (
- <span className="text-sm font-medium tracking-widest text-muted-foreground">
- {release.version} — {release.date}
- </span>
- );
-}
-
-const titleSizes: Record<"h1" | "h2", string> = {
- h1: "text-4xl",
- h2: "text-2xl",
-};
-
-export function ReleaseTitle({
- as: As,
- href,
- children,
-}: {
- as: "h1" | "h2";
- href?: string;
- children: ReactNode;
-}) {
- return (
- <As className={cn("font-bold tracking-tight", titleSizes[As])}>
- {href ? (
- <Link href={href} className="hover:underline underline-offset-4">
- {children}
- </Link>
- ) : (
- children
- )}
- </As>
- );
-}
-
-export function ReleaseDescription({ children }: { children: ReactNode }) {
- return (
- <p className="text-base text-foreground leading-relaxed max-w-xl">
- {children}
- </p>
- );
-}
-
-export function ReleaseChanges({ release }: { release: Release }) {
- const { grouped, orderedTypes } = groupAndOrderChanges({
- changes: release.changes,
- });
-
- return (
- <div className="flex flex-col gap-4">
- {orderedTypes.map((type) => (
- <div key={type} className="flex flex-col gap-1.5">
- <h3 className="text-base font-semibold text-foreground">
- {getSectionTitle(type)}:
- </h3>
- <ul className="list-disc pl-5 space-y-1.5">
- {grouped[type].map((change) => (
- <li
- key={change.text}
- className="text-base text-foreground leading-relaxed"
- >
- {change.text}
- </li>
- ))}
- </ul>
- </div>
- ))}
- </div>
- );
-}
+import type { ReactNode } from "react";
+import Link from "next/link";
+import { cn } from "@/utils/ui";
+import { getSectionTitle, groupAndOrderChanges } from "../utils";
+import type { Release } from "../utils";
+
+export function ReleaseArticle({
+ variant,
+ isLatest,
+ children,
+}: {
+ variant: "list" | "detail";
+ isLatest?: boolean;
+ children: ReactNode;
+}) {
+ if (variant === "list") {
+ return (
+ <article className="relative sm:pl-10">
+ <div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
+ <div
+ className={cn(
+ "size-[11px] rounded-full border-[1.5px]",
+ isLatest
+ ? "border-foreground bg-foreground"
+ : "border-muted-foreground/30 bg-background",
+ )}
+ />
+ </div>
+ <div className="flex flex-col gap-5">{children}</div>
+ </article>
+ );
+ }
+
+ return <article className="flex flex-col gap-8">{children}</article>;
+}
+
+export function ReleaseMeta({ release }: { release: Release }) {
+ return (
+ <span className="text-sm font-medium tracking-widest text-muted-foreground">
+ {release.version} — {release.date}
+ </span>
+ );
+}
+
+const titleSizes: Record<"h1" | "h2", string> = {
+ h1: "text-4xl",
+ h2: "text-2xl",
+};
+
+export function ReleaseTitle({
+ as: As,
+ href,
+ children,
+}: {
+ as: "h1" | "h2";
+ href?: string;
+ children: ReactNode;
+}) {
+ return (
+ <As className={cn("font-bold tracking-tight", titleSizes[As])}>
+ {href ? (
+ <Link href={href} className="hover:underline underline-offset-4">
+ {children}
+ </Link>
+ ) : (
+ children
+ )}
+ </As>
+ );
+}
+
+export function ReleaseDescription({ children }: { children: ReactNode }) {
+ return (
+ <p className="text-base text-foreground leading-relaxed max-w-xl">
+ {children}
+ </p>
+ );
+}
+
+export function ReleaseChanges({ release }: { release: Release }) {
+ const { grouped, orderedTypes } = groupAndOrderChanges({
+ changes: release.changes,
+ });
+
+ return (
+ <div className="flex flex-col gap-4">
+ {orderedTypes.map((type) => (
+ <div key={type} className="flex flex-col gap-1.5">
+ <h3 className="text-base font-semibold text-foreground">
+ {getSectionTitle(type)}:
+ </h3>
+ <ul className="list-disc pl-5 space-y-1.5">
+ {grouped[type].map((change) => (
+ <li
+ key={change.text}
+ className="text-base text-foreground leading-relaxed"
+ >
+ {change.text}
+ </li>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/app/changelog/utils.ts b/apps/web/src/app/changelog/utils.ts
index 163207c0..d3405631 100644
--- a/apps/web/src/app/changelog/utils.ts
+++ b/apps/web/src/app/changelog/utils.ts
@@ -1,43 +1,43 @@
-import { allChangelogs } from "content-collections";
-
-export type Change = { type: string; text: string };
-export type Release = (typeof allChangelogs)[number];
-
-const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
-
-const knownSectionTitles: Record<string, string> = {
- new: "Features",
- improved: "Improvements",
- fixed: "Fixes",
- breaking: "Breaking Changes",
-};
-
-export function getSectionTitle(type: string): string {
- return (
- knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
- );
-}
-
-export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
- const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
- if (!acc[change.type]) acc[change.type] = [];
- acc[change.type].push(change);
- return acc;
- }, {});
-
- const customTypes = Object.keys(grouped).filter(
- (type) => !knownSectionOrder.includes(type),
- );
- const orderedTypes = [
- ...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
- ...customTypes,
- ];
-
- return { grouped, orderedTypes };
-}
-
-export function getSortedReleases() {
- return [...allChangelogs].sort((a, b) =>
- b.version.localeCompare(a.version, undefined, { numeric: true }),
- );
-}
+import { allChangelogs } from "content-collections";
+
+export type Change = { type: string; text: string };
+export type Release = (typeof allChangelogs)[number];
+
+const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
+
+const knownSectionTitles: Record<string, string> = {
+ new: "Features",
+ improved: "Improvements",
+ fixed: "Fixes",
+ breaking: "Breaking Changes",
+};
+
+export function getSectionTitle(type: string): string {
+ return (
+ knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
+ );
+}
+
+export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
+ const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
+ if (!acc[change.type]) acc[change.type] = [];
+ acc[change.type].push(change);
+ return acc;
+ }, {});
+
+ const customTypes = Object.keys(grouped).filter(
+ (type) => !knownSectionOrder.includes(type),
+ );
+ const orderedTypes = [
+ ...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
+ ...customTypes,
+ ];
+
+ return { grouped, orderedTypes };
+}
+
+export function getSortedReleases() {
+ return [...allChangelogs].sort((a, b) =>
+ b.version.localeCompare(a.version, undefined, { numeric: true }),
+ );
+}
diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx
index 21703852..64792632 100644
--- a/apps/web/src/app/contributors/page.tsx
+++ b/apps/web/src/app/contributors/page.tsx
@@ -1,242 +1,242 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Card, CardContent } from "@/components/ui/card";
-import { EXTERNAL_TOOLS } from "@/constants/site-constants";
-import { BasePage } from "../base-page";
-
-export const metadata: Metadata = {
- title: "Contributors - OpenCut",
- description:
- "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
- openGraph: {
- title: "Contributors - OpenCut",
- description:
- "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
- type: "website",
- },
-};
-
-interface Contributor {
- id: number;
- login: string;
- avatar_url: string;
- html_url: string;
- contributions: number;
- type: string;
-}
-
-async function getContributors(): Promise<Contributor[]> {
- try {
- const response = await fetch(
- "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100",
- {
- headers: {
- Accept: "application/vnd.github.v3+json",
- "User-Agent": "OpenCut-Web-App",
- },
- next: { revalidate: 600 }, // 10 minutes
- },
- );
-
- if (!response.ok) {
- console.error("Failed to fetch contributors");
- return [];
- }
-
- const contributors = (await response.json()) as Contributor[];
-
- const filteredContributors = contributors.filter(
- (contributor) => contributor.type === "User",
- );
-
- return filteredContributors;
- } catch (error) {
- console.error("Error fetching contributors:", error);
- return [];
- }
-}
-
-export default async function ContributorsPage() {
- const contributors = await getContributors();
- const topContributors = contributors.slice(0, 2);
- const otherContributors = contributors.slice(2);
- const totalContributions = contributors.reduce(
- (sum, c) => sum + c.contributions,
- 0,
- );
-
- return (
- <BasePage
- title="Contributors"
- description="Meet the amazing people who contribute to OpenCut, the free and open-source video editor."
- >
- <div className="-mt-4 flex items-center justify-center gap-8 text-sm">
- <StatItem value={contributors.length} label="contributors" />
- <StatItem value={totalContributions} label="contributions" />
- </div>
-
- <div className="mx-auto flex max-w-6xl flex-col gap-20">
- {topContributors.length > 0 && (
- <TopContributorsSection contributors={topContributors} />
- )}
- {otherContributors.length > 0 && (
- <AllContributorsSection contributors={otherContributors} />
- )}
- <ExternalToolsSection />
- <GitHubContributeSection
- title="Join the community"
- description="OpenCut is built by developers like you. Every contribution, no matter how small, helps make video editing more accessible for everyone."
- />
- </div>
- </BasePage>
- );
-}
-
-function StatItem({ value, label }: { value: number; label: string }) {
- return (
- <div className="flex items-center gap-2">
- <div className="bg-foreground size-2 rounded-full" />
- <span className="font-medium">{value}</span>
- <span className="text-muted-foreground">{label}</span>
- </div>
- );
-}
-
-function TopContributorsSection({
- contributors,
-}: {
- contributors: Contributor[];
-}) {
- return (
- <div className="flex flex-col gap-10">
- <div className="flex flex-col gap-2 text-center">
- <h2 className="text-2xl font-semibold">Top contributors</h2>
- <p className="text-muted-foreground">
- Leading the way in contributions
- </p>
- </div>
-
- <div className="mx-auto flex w-full max-w-xl flex-col justify-center gap-6 md:flex-row">
- {contributors.map((contributor) => (
- <TopContributorCard key={contributor.id} contributor={contributor} />
- ))}
- </div>
- </div>
- );
-}
-
-function TopContributorCard({ contributor }: { contributor: Contributor }) {
- return (
- <Link
- href={contributor.html_url}
- target="_blank"
- rel="noopener noreferrer"
- className="w-full"
- >
- <Card>
- <CardContent className="flex flex-col gap-6 p-8 text-center">
- <Avatar className="mx-auto size-28">
- <AvatarImage
- src={contributor.avatar_url}
- alt={`${contributor.login}'s avatar`}
- />
- <AvatarFallback className="text-lg font-semibold">
- {contributor.login.charAt(0).toUpperCase()}
- </AvatarFallback>
- </Avatar>
- <div className="flex flex-col gap-2">
- <h3 className="text-xl font-semibold">{contributor.login}</h3>
- <div className="flex items-center justify-center gap-2">
- <span className="font-medium">{contributor.contributions}</span>
- <span className="text-muted-foreground">contributions</span>
- </div>
- </div>
- </CardContent>
- </Card>
- </Link>
- );
-}
-
-function AllContributorsSection({
- contributors,
-}: {
- contributors: Contributor[];
-}) {
- return (
- <div className="flex flex-col gap-12">
- <div className="flex flex-col gap-2 text-center">
- <h2 className="text-2xl font-semibold">All contributors</h2>
- <p className="text-muted-foreground">
- Everyone who makes OpenCut better
- </p>
- </div>
-
- <div className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
- {contributors.map((contributor) => (
- <Link
- key={contributor.id}
- href={contributor.html_url}
- target="_blank"
- rel="noopener noreferrer"
- className="opacity-100 hover:opacity-70"
- >
- <div className="flex flex-col items-center gap-2 p-2">
- <Avatar className="size-16">
- <AvatarImage
- src={contributor.avatar_url}
- alt={`${contributor.login}'s avatar`}
- />
- <AvatarFallback>
- {contributor.login.charAt(0).toUpperCase()}
- </AvatarFallback>
- </Avatar>
- <div className="text-center">
- <h3 className="text-sm font-medium">{contributor.login}</h3>
- <p className="text-muted-foreground text-xs">
- {contributor.contributions}
- </p>
- </div>
- </div>
- </Link>
- ))}
- </div>
- </div>
- );
-}
-
-function ExternalToolsSection() {
- return (
- <div className="flex flex-col gap-10">
- <div className="flex flex-col gap-2 text-center">
- <h2 className="text-2xl font-semibold">External tools</h2>
- <p className="text-muted-foreground">Tools we use to build OpenCut</p>
- </div>
-
- <div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2">
- {EXTERNAL_TOOLS.map((tool, index) => (
- <Link
- key={tool.url}
- href={tool.url}
- target="_blank"
- className="block"
- style={{ animationDelay: `${index * 100}ms` }}
- >
- <Card className="h-full">
- <CardContent className="flex items-center justify-center h-full flex-col gap-4 p-6 text-center">
- <tool.icon className="size-8" />
- <div className="flex flex-1 flex-col gap-2">
- <h3 className="text-lg font-semibold">{tool.name}</h3>
- <p className="text-muted-foreground text-sm">
- {tool.description}
- </p>
- </div>
- </CardContent>
- </Card>
- </Link>
- ))}
- </div>
- </div>
- );
-}
+import type { Metadata } from "next";
+import Link from "next/link";
+import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Card, CardContent } from "@/components/ui/card";
+import { EXTERNAL_TOOLS } from "@/constants/site-constants";
+import { BasePage } from "../base-page";
+
+export const metadata: Metadata = {
+ title: "Contributors - OpenCut",
+ description:
+ "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
+ openGraph: {
+ title: "Contributors - OpenCut",
+ description:
+ "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
+ type: "website",
+ },
+};
+
+interface Contributor {
+ id: number;
+ login: string;
+ avatar_url: string;
+ html_url: string;
+ contributions: number;
+ type: string;
+}
+
+async function getContributors(): Promise<Contributor[]> {
+ try {
+ const response = await fetch(
+ "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100",
+ {
+ headers: {
+ Accept: "application/vnd.github.v3+json",
+ "User-Agent": "OpenCut-Web-App",
+ },
+ next: { revalidate: 600 }, // 10 minutes
+ },
+ );
+
+ if (!response.ok) {
+ console.error("Failed to fetch contributors");
+ return [];
+ }
+
+ const contributors = (await response.json()) as Contributor[];
+
+ const filteredContributors = contributors.filter(
+ (contributor) => contributor.type === "User",
+ );
+
+ return filteredContributors;
+ } catch (error) {
+ console.error("Error fetching contributors:", error);
+ return [];
+ }
+}
+
+export default async function ContributorsPage() {
+ const contributors = await getContributors();
+ const topContributors = contributors.slice(0, 2);
+ const otherContributors = contributors.slice(2);
+ const totalContributions = contributors.reduce(
+ (sum, c) => sum + c.contributions,
+ 0,
+ );
+
+ return (
+ <BasePage
+ title="Contributors"
+ description="Meet the amazing people who contribute to OpenCut, the free and open-source video editor."
+ >
+ <div className="-mt-4 flex items-center justify-center gap-8 text-sm">
+ <StatItem value={contributors.length} label="contributors" />
+ <StatItem value={totalContributions} label="contributions" />
+ </div>
+
+ <div className="mx-auto flex max-w-6xl flex-col gap-20">
+ {topContributors.length > 0 && (
+ <TopContributorsSection contributors={topContributors} />
+ )}
+ {otherContributors.length > 0 && (
+ <AllContributorsSection contributors={otherContributors} />
+ )}
+ <ExternalToolsSection />
+ <GitHubContributeSection
+ title="Join the community"
+ description="OpenCut is built by developers like you. Every contribution, no matter how small, helps make video editing more accessible for everyone."
+ />
+ </div>
+ </BasePage>
+ );
+}
+
+function StatItem({ value, label }: { value: number; label: string }) {
+ return (
+ <div className="flex items-center gap-2">
+ <div className="bg-foreground size-2 rounded-full" />
+ <span className="font-medium">{value}</span>
+ <span className="text-muted-foreground">{label}</span>
+ </div>
+ );
+}
+
+function TopContributorsSection({
+ contributors,
+}: {
+ contributors: Contributor[];
+}) {
+ return (
+ <div className="flex flex-col gap-10">
+ <div className="flex flex-col gap-2 text-center">
+ <h2 className="text-2xl font-semibold">Top contributors</h2>
+ <p className="text-muted-foreground">
+ Leading the way in contributions
+ </p>
+ </div>
+
+ <div className="mx-auto flex w-full max-w-xl flex-col justify-center gap-6 md:flex-row">
+ {contributors.map((contributor) => (
+ <TopContributorCard key={contributor.id} contributor={contributor} />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function TopContributorCard({ contributor }: { contributor: Contributor }) {
+ return (
+ <Link
+ href={contributor.html_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="w-full"
+ >
+ <Card>
+ <CardContent className="flex flex-col gap-6 p-8 text-center">
+ <Avatar className="mx-auto size-28">
+ <AvatarImage
+ src={contributor.avatar_url}
+ alt={`${contributor.login}'s avatar`}
+ />
+ <AvatarFallback className="text-lg font-semibold">
+ {contributor.login.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <div className="flex flex-col gap-2">
+ <h3 className="text-xl font-semibold">{contributor.login}</h3>
+ <div className="flex items-center justify-center gap-2">
+ <span className="font-medium">{contributor.contributions}</span>
+ <span className="text-muted-foreground">contributions</span>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </Link>
+ );
+}
+
+function AllContributorsSection({
+ contributors,
+}: {
+ contributors: Contributor[];
+}) {
+ return (
+ <div className="flex flex-col gap-12">
+ <div className="flex flex-col gap-2 text-center">
+ <h2 className="text-2xl font-semibold">All contributors</h2>
+ <p className="text-muted-foreground">
+ Everyone who makes OpenCut better
+ </p>
+ </div>
+
+ <div className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
+ {contributors.map((contributor) => (
+ <Link
+ key={contributor.id}
+ href={contributor.html_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="opacity-100 hover:opacity-70"
+ >
+ <div className="flex flex-col items-center gap-2 p-2">
+ <Avatar className="size-16">
+ <AvatarImage
+ src={contributor.avatar_url}
+ alt={`${contributor.login}'s avatar`}
+ />
+ <AvatarFallback>
+ {contributor.login.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <div className="text-center">
+ <h3 className="text-sm font-medium">{contributor.login}</h3>
+ <p className="text-muted-foreground text-xs">
+ {contributor.contributions}
+ </p>
+ </div>
+ </div>
+ </Link>
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function ExternalToolsSection() {
+ return (
+ <div className="flex flex-col gap-10">
+ <div className="flex flex-col gap-2 text-center">
+ <h2 className="text-2xl font-semibold">External tools</h2>
+ <p className="text-muted-foreground">Tools we use to build OpenCut</p>
+ </div>
+
+ <div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2">
+ {EXTERNAL_TOOLS.map((tool, index) => (
+ <Link
+ key={tool.url}
+ href={tool.url}
+ target="_blank"
+ className="block"
+ style={{ animationDelay: `${index * 100}ms` }}
+ >
+ <Card className="h-full">
+ <CardContent className="flex items-center justify-center h-full flex-col gap-4 p-6 text-center">
+ <tool.icon className="size-8" />
+ <div className="flex flex-1 flex-col gap-2">
+ <h3 className="text-lg font-semibold">{tool.name}</h3>
+ <p className="text-muted-foreground text-sm">
+ {tool.description}
+ </p>
+ </div>
+ </CardContent>
+ </Card>
+ </Link>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 3ec6ddb8..38a25d4c 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -1,113 +1,113 @@
-"use client";
-
-import { useParams } from "next/navigation";
-import {
- ResizablePanelGroup,
- ResizablePanel,
- ResizableHandle,
-} from "@/components/ui/resizable";
-import { AssetsPanel } from "@/components/editor/panels/assets";
-import { PropertiesPanel } from "@/components/editor/panels/properties";
-import { Timeline } from "@/components/editor/panels/timeline";
-import { PreviewPanel } from "@/components/editor/panels/preview";
-import { EditorHeader } from "@/components/editor/editor-header";
-import { EditorProvider } from "@/components/providers/editor-provider";
-import { Onboarding } from "@/components/editor/onboarding";
-import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
-import { usePanelStore } from "@/stores/panel-store";
-import { usePasteMedia } from "@/hooks/use-paste-media";
-import { MobileGate } from "@/components/editor/mobile-gate";
-
-export default function Editor() {
- const params = useParams();
- const projectId = params.project_id as string;
-
- return (
- <MobileGate>
- <EditorProvider projectId={projectId}>
- <div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
- <EditorHeader />
- <div className="min-h-0 min-w-0 flex-1">
- <EditorLayout />
- </div>
- <Onboarding />
- <MigrationDialog />
- </div>
- </EditorProvider>
- </MobileGate>
- );
-}
-
-function EditorLayout() {
- usePasteMedia();
- const { panels, setPanel } = usePanelStore();
-
- return (
- <ResizablePanelGroup
- direction="vertical"
- className="size-full gap-[0.18rem]"
- onLayout={(sizes) => {
- setPanel("mainContent", sizes[0] ?? panels.mainContent);
- setPanel("timeline", sizes[1] ?? panels.timeline);
- }}
- >
- <ResizablePanel
- defaultSize={panels.mainContent}
- minSize={30}
- maxSize={85}
- className="min-h-0"
- >
- <ResizablePanelGroup
- direction="horizontal"
- className="size-full gap-[0.19rem] px-3"
- onLayout={(sizes) => {
- setPanel("tools", sizes[0] ?? panels.tools);
- setPanel("preview", sizes[1] ?? panels.preview);
- setPanel("properties", sizes[2] ?? panels.properties);
- }}
- >
- <ResizablePanel
- defaultSize={panels.tools}
- minSize={15}
- maxSize={40}
- className="min-w-0"
- >
- <AssetsPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={panels.preview}
- minSize={30}
- className="min-h-0 min-w-0 flex-1"
- >
- <PreviewPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={panels.properties}
- minSize={15}
- maxSize={40}
- className="min-w-0"
- >
- <PropertiesPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={panels.timeline}
- minSize={15}
- maxSize={70}
- className="min-h-0 px-3 pb-3"
- >
- <Timeline />
- </ResizablePanel>
- </ResizablePanelGroup>
- );
-}
+"use client";
+
+import { useParams } from "next/navigation";
+import {
+ ResizablePanelGroup,
+ ResizablePanel,
+ ResizableHandle,
+} from "@/components/ui/resizable";
+import { AssetsPanel } from "@/components/editor/panels/assets";
+import { PropertiesPanel } from "@/components/editor/panels/properties";
+import { Timeline } from "@/components/editor/panels/timeline";
+import { PreviewPanel } from "@/components/editor/panels/preview";
+import { EditorHeader } from "@/components/editor/editor-header";
+import { EditorProvider } from "@/components/providers/editor-provider";
+import { Onboarding } from "@/components/editor/onboarding";
+import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
+import { usePanelStore } from "@/stores/panel-store";
+import { usePasteMedia } from "@/hooks/use-paste-media";
+import { MobileGate } from "@/components/editor/mobile-gate";
+
+export default function Editor() {
+ const params = useParams();
+ const projectId = params.project_id as string;
+
+ return (
+ <MobileGate>
+ <EditorProvider projectId={projectId}>
+ <div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
+ <EditorHeader />
+ <div className="min-h-0 min-w-0 flex-1">
+ <EditorLayout />
+ </div>
+ <Onboarding />
+ <MigrationDialog />
+ </div>
+ </EditorProvider>
+ </MobileGate>
+ );
+}
+
+function EditorLayout() {
+ usePasteMedia();
+ const { panels, setPanel } = usePanelStore();
+
+ return (
+ <ResizablePanelGroup
+ direction="vertical"
+ className="size-full gap-[0.18rem]"
+ onLayout={(sizes) => {
+ setPanel("mainContent", sizes[0] ?? panels.mainContent);
+ setPanel("timeline", sizes[1] ?? panels.timeline);
+ }}
+ >
+ <ResizablePanel
+ defaultSize={panels.mainContent}
+ minSize={30}
+ maxSize={85}
+ className="min-h-0"
+ >
+ <ResizablePanelGroup
+ direction="horizontal"
+ className="size-full gap-[0.19rem] px-3"
+ onLayout={(sizes) => {
+ setPanel("tools", sizes[0] ?? panels.tools);
+ setPanel("preview", sizes[1] ?? panels.preview);
+ setPanel("properties", sizes[2] ?? panels.properties);
+ }}
+ >
+ <ResizablePanel
+ defaultSize={panels.tools}
+ minSize={15}
+ maxSize={40}
+ className="min-w-0"
+ >
+ <AssetsPanel />
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={panels.preview}
+ minSize={30}
+ className="min-h-0 min-w-0 flex-1"
+ >
+ <PreviewPanel />
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={panels.properties}
+ minSize={15}
+ maxSize={40}
+ className="min-w-0"
+ >
+ <PropertiesPanel />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={panels.timeline}
+ minSize={15}
+ maxSize={70}
+ className="min-h-0 px-3 pb-3"
+ >
+ <Timeline />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ );
+}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 21219427..a2df844a 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -1,284 +1,284 @@
-@import "tailwindcss";
-
-/* Custom variant for dark mode */
-@custom-variant dark (&:where(.dark, .dark *));
-
-/* Plugins */
-@plugin "@tailwindcss/typography";
-@plugin "tailwindcss-animate";
-
-:root {
- --background: hsl(0, 0%, 100%);
- --foreground: hsl(0 0% 11%);
- --card: hsl(0, 0%, 100%);
- --card-foreground: hsl(0 0% 11%);
- --popover: hsl(0, 0%, 100%);
- --popover-hover: hsl(0, 0%, 96%);
- --popover-foreground: hsl(0 0% 2%);
- --primary: hsl(200, 90%, 52%);
- --primary-foreground: hsl(0, 0%, 100%);
- --secondary: hsl(204, 100%, 97%);
- --secondary-border: hsl(204, 100%, 94%);
- --secondary-foreground: hsl(200, 98%, 39%);
- --muted: hsl(0 0% 85.1%);
- --muted-foreground: hsl(0 0% 50%);
- --accent: hsl(0, 0%, 96%);
- --accent-foreground: hsl(0 0% 2%);
- --destructive: hsl(0, 83%, 50%);
- --destructive-foreground: hsl(0, 0%, 100%);
- --constructive: hsl(141, 71%, 48%);
- --constructive-foreground: hsl(0, 0%, 100%);
- --border: hsl(0 0% 91%);
- --input: hsl(0, 0%, 100%);
- --ring: hsl(0, 0%, 55%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar-background: hsl(0 0% 96.1%);
- --sidebar-foreground: hsl(0 0% 2%);
- --sidebar-primary: hsl(0 0% 2%);
- --sidebar-primary-foreground: hsl(0 0% 91%);
- --sidebar-accent: hsl(0 0% 85.1%);
- --sidebar-accent-foreground: hsl(0 0% 2%);
- --sidebar-border: hsl(0 0% 85.1%);
- --sidebar-ring: hsl(0 0% 16.9%);
- --sidebar: hsl(0 0% 98%);
-}
-
-.panel {
- --background: hsl(210, 20%, 98%);
- --foreground: hsl(0 0% 13%);
- --card: hsl(0, 0%, 98%);
- --card-foreground: hsl(0 0% 13%);
- --primary-foreground: hsl(0, 0%, 98%);
- --secondary: hsl(204, 100%, 95%);
- --secondary-border: hsl(204, 100%, 92%);
- --secondary-foreground: hsl(200, 98%, 37%);
- --muted: hsl(0 0% 83.1%);
- --muted-foreground: hsl(0 0% 48%);
- --accent: hsl(0, 0%, 93%);
- --accent-foreground: hsl(0 0% 5%);
- --destructive-foreground: hsl(0, 0%, 98%);
- --constructive-foreground: hsl(0, 0%, 98%);
- --border: hsl(0 0% 87%);
- --input: hsl(0 0% 93%);
- --ring: hsl(0, 0%, 53%);
-}
-
-.dark {
- --background: hsl(0, 0%, 5%);
- --foreground: hsl(0 0% 87%);
- --card: hsl(0, 0%, 5%);
- --card-foreground: hsl(0 0% 87%);
- --popover: hsl(0, 0%, 5%);
- --popover-hover: hsl(0, 0%, 13%);
- --popover-foreground: hsl(0 0% 95%);
- --secondary: hsl(204, 100%, 12%);
- --secondary-border: hsl(204, 100%, 15%);
- --secondary-foreground: hsl(200, 98%, 61%);
- --muted: hsl(0 0% 20%);
- --accent: hsl(0, 0%, 14%);
- --accent-foreground: hsl(0 0% 95%);
- --border: hsl(0 0% 16%);
- --input: hsl(0 0% 5%);
- --ring: hsl(0, 0%, 50%);
- --sidebar-background: hsl(0 0% 8%);
- --sidebar-foreground: hsl(0 0% 95%);
- --sidebar-primary: hsl(0 0% 95%);
- --sidebar-primary-foreground: hsl(0 0% 15%);
- --sidebar-accent: hsl(0 0% 20%);
- --sidebar-accent-foreground: hsl(0 0% 95%);
- --sidebar-border: hsl(0 0% 20%);
- --sidebar-ring: hsl(0 0% 83.1%);
- --sidebar: hsl(0 0% 6%);
-}
-
-.dark .panel {
- --background: hsl(0 0% 10%);
- --foreground: hsl(0 0% 85%);
- --card: hsl(0, 0%, 10%);
- --card-foreground: hsl(0 0% 85%);
- --secondary: hsl(204, 67%, 9%);
- --secondary-border: hsl(204, 100%, 14%);
- --secondary-foreground: hsl(200, 98%, 63%);
- --muted: hsl(0 0% 22%);
- --accent: hsl(0, 0%, 15%);
- --accent-foreground: hsl(0 0% 93%);
- --border: hsl(0 0% 18%);
- --input: hsl(0 0% 22%);
- --ring: hsl(0, 0%, 52%);
-}
-
-@layer base {
- /*
- The default border color has changed to `currentcolor` in Tailwind CSS v4,
- so we've added these compatibility styles to make sure everything still
- looks the same as it did with Tailwind CSS v3.
-
- If we ever want to remove these styles, we need to add an explicit border
- color utility to any element that depends on these defaults.
- */
- *,
- ::after,
- ::before,
- ::backdrop,
- ::file-selector-button {
- border-color: var(--color-gray-200, currentcolor);
- }
- /* Other default base styles */
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- /* Prevent back/forward swipe */
- overscroll-behavior-x: contain;
- }
- ::selection {
- @apply bg-primary/35 selection:text-primary-foreground;
- }
-}
-
-@theme inline {
- /* Responsive breakpoints */
- --breakpoint-xs: 30rem;
-
- /* Typography */
- --font-sans: var(--font-inter), sans-serif;
-
- /* Font sizes */
- --text-xs: 0.72rem;
- --text-sm: 0.79rem;
- --text-base: 0.92rem;
- --text-base--line-height: calc(1.5 / 0.95);
- --text-xs--line-height: calc(1 / 0.8);
-
- /* Border radius */
- --radius-lg: 0.82rem;
- --radius-md: 0.65rem;
- --radius-sm: 0.35rem;
-
- /* Palette mapped to root design tokens */
- --color-background: var(--background);
- --color-foreground: var(--foreground);
-
- --color-card: var(--card);
- --color-card-foreground: var(--card-foreground);
-
- --color-popover: var(--popover);
- --color-popover-hover: var(--popover-hover);
- --color-popover-foreground: var(--popover-foreground);
-
- --color-primary: var(--primary);
- --color-primary-foreground: var(--primary-foreground);
- --color-secondary: var(--secondary);
- --color-secondary-border: var(--secondary-border);
- --color-secondary-foreground: var(--secondary-foreground);
-
- --color-muted: var(--muted);
- --color-muted-foreground: var(--muted-foreground);
-
- --color-accent: var(--accent);
- --color-accent-foreground: var(--accent-foreground);
-
- --color-destructive: var(--destructive);
- --color-destructive-foreground: var(--destructive-foreground);
-
- --color-constructive: var(--constructive);
- --color-constructive-foreground: var(--constructive-foreground);
-
- --color-border: var(--border);
- --color-input: var(--input);
- --color-ring: var(--ring);
-
- /* Chart colors */
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
-
- /* Sidebar */
- --color-sidebar: var(--sidebar-background);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
-
- /* Animations */
- --animate-accordion-down: accordion-down 0.2s ease-out;
- --animate-accordion-up: accordion-up 0.2s ease-out;
-
- @keyframes accordion-down {
- from {
- height: 0;
- }
- to {
- height: var(--radix-accordion-content-height);
- }
- }
-
- @keyframes accordion-up {
- from {
- height: var(--radix-accordion-content-height);
- }
- to {
- height: 0;
- }
- }
-}
-
-@utility scrollbar-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar {
- display: none;
- }
-}
-
-@utility scrollbar-x-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:horizontal {
- display: none;
- }
-}
-
-@utility scrollbar-y-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:vertical {
- display: none;
- }
-}
-
-@utility scrollbar-thin {
- &::-webkit-scrollbar {
- width: 7px;
- height: 7px;
- }
- &::-webkit-scrollbar-track {
- background: transparent;
- }
- &::-webkit-scrollbar-thumb {
- background: var(--border);
- border-radius: 4px;
- }
- &::-webkit-scrollbar-thumb:hover {
- background: var(--muted-foreground);
- }
-}
-
-@layer base {
- * {
- @apply border-border outline-ring/50;
- }
- body {
- @apply bg-background text-foreground;
- }
-}
+@import "tailwindcss";
+
+/* Custom variant for dark mode */
+@custom-variant dark (&:where(.dark, .dark *));
+
+/* Plugins */
+@plugin "@tailwindcss/typography";
+@plugin "tailwindcss-animate";
+
+:root {
+ --background: hsl(0, 0%, 100%);
+ --foreground: hsl(0 0% 11%);
+ --card: hsl(0, 0%, 100%);
+ --card-foreground: hsl(0 0% 11%);
+ --popover: hsl(0, 0%, 100%);
+ --popover-hover: hsl(0, 0%, 96%);
+ --popover-foreground: hsl(0 0% 2%);
+ --primary: hsl(200, 90%, 52%);
+ --primary-foreground: hsl(0, 0%, 100%);
+ --secondary: hsl(204, 100%, 97%);
+ --secondary-border: hsl(204, 100%, 94%);
+ --secondary-foreground: hsl(200, 98%, 39%);
+ --muted: hsl(0 0% 85.1%);
+ --muted-foreground: hsl(0 0% 50%);
+ --accent: hsl(0, 0%, 96%);
+ --accent-foreground: hsl(0 0% 2%);
+ --destructive: hsl(0, 83%, 50%);
+ --destructive-foreground: hsl(0, 0%, 100%);
+ --constructive: hsl(141, 71%, 48%);
+ --constructive-foreground: hsl(0, 0%, 100%);
+ --border: hsl(0 0% 91%);
+ --input: hsl(0, 0%, 100%);
+ --ring: hsl(0, 0%, 55%);
+ --chart-1: hsl(220 70% 50%);
+ --chart-2: hsl(160 60% 45%);
+ --chart-3: hsl(30 80% 55%);
+ --chart-4: hsl(280 65% 60%);
+ --chart-5: hsl(340 75% 55%);
+ --sidebar-background: hsl(0 0% 96.1%);
+ --sidebar-foreground: hsl(0 0% 2%);
+ --sidebar-primary: hsl(0 0% 2%);
+ --sidebar-primary-foreground: hsl(0 0% 91%);
+ --sidebar-accent: hsl(0 0% 85.1%);
+ --sidebar-accent-foreground: hsl(0 0% 2%);
+ --sidebar-border: hsl(0 0% 85.1%);
+ --sidebar-ring: hsl(0 0% 16.9%);
+ --sidebar: hsl(0 0% 98%);
+}
+
+.panel {
+ --background: hsl(210, 20%, 98%);
+ --foreground: hsl(0 0% 13%);
+ --card: hsl(0, 0%, 98%);
+ --card-foreground: hsl(0 0% 13%);
+ --primary-foreground: hsl(0, 0%, 98%);
+ --secondary: hsl(204, 100%, 95%);
+ --secondary-border: hsl(204, 100%, 92%);
+ --secondary-foreground: hsl(200, 98%, 37%);
+ --muted: hsl(0 0% 83.1%);
+ --muted-foreground: hsl(0 0% 48%);
+ --accent: hsl(0, 0%, 93%);
+ --accent-foreground: hsl(0 0% 5%);
+ --destructive-foreground: hsl(0, 0%, 98%);
+ --constructive-foreground: hsl(0, 0%, 98%);
+ --border: hsl(0 0% 87%);
+ --input: hsl(0 0% 93%);
+ --ring: hsl(0, 0%, 53%);
+}
+
+.dark {
+ --background: hsl(0, 0%, 5%);
+ --foreground: hsl(0 0% 87%);
+ --card: hsl(0, 0%, 5%);
+ --card-foreground: hsl(0 0% 87%);
+ --popover: hsl(0, 0%, 5%);
+ --popover-hover: hsl(0, 0%, 13%);
+ --popover-foreground: hsl(0 0% 95%);
+ --secondary: hsl(204, 100%, 12%);
+ --secondary-border: hsl(204, 100%, 15%);
+ --secondary-foreground: hsl(200, 98%, 61%);
+ --muted: hsl(0 0% 20%);
+ --accent: hsl(0, 0%, 14%);
+ --accent-foreground: hsl(0 0% 95%);
+ --border: hsl(0 0% 16%);
+ --input: hsl(0 0% 5%);
+ --ring: hsl(0, 0%, 50%);
+ --sidebar-background: hsl(0 0% 8%);
+ --sidebar-foreground: hsl(0 0% 95%);
+ --sidebar-primary: hsl(0 0% 95%);
+ --sidebar-primary-foreground: hsl(0 0% 15%);
+ --sidebar-accent: hsl(0 0% 20%);
+ --sidebar-accent-foreground: hsl(0 0% 95%);
+ --sidebar-border: hsl(0 0% 20%);
+ --sidebar-ring: hsl(0 0% 83.1%);
+ --sidebar: hsl(0 0% 6%);
+}
+
+.dark .panel {
+ --background: hsl(0 0% 10%);
+ --foreground: hsl(0 0% 85%);
+ --card: hsl(0, 0%, 10%);
+ --card-foreground: hsl(0 0% 85%);
+ --secondary: hsl(204, 67%, 9%);
+ --secondary-border: hsl(204, 100%, 14%);
+ --secondary-foreground: hsl(200, 98%, 63%);
+ --muted: hsl(0 0% 22%);
+ --accent: hsl(0, 0%, 15%);
+ --accent-foreground: hsl(0 0% 93%);
+ --border: hsl(0 0% 18%);
+ --input: hsl(0 0% 22%);
+ --ring: hsl(0, 0%, 52%);
+}
+
+@layer base {
+ /*
+ The default border color has changed to `currentcolor` in Tailwind CSS v4,
+ so we've added these compatibility styles to make sure everything still
+ looks the same as it did with Tailwind CSS v3.
+
+ If we ever want to remove these styles, we need to add an explicit border
+ color utility to any element that depends on these defaults.
+ */
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+ /* Other default base styles */
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ /* Prevent back/forward swipe */
+ overscroll-behavior-x: contain;
+ }
+ ::selection {
+ @apply bg-primary/35 selection:text-primary-foreground;
+ }
+}
+
+@theme inline {
+ /* Responsive breakpoints */
+ --breakpoint-xs: 30rem;
+
+ /* Typography */
+ --font-sans: var(--font-inter), sans-serif;
+
+ /* Font sizes */
+ --text-xs: 0.72rem;
+ --text-sm: 0.79rem;
+ --text-base: 0.92rem;
+ --text-base--line-height: calc(1.5 / 0.95);
+ --text-xs--line-height: calc(1 / 0.8);
+
+ /* Border radius */
+ --radius-lg: 0.82rem;
+ --radius-md: 0.65rem;
+ --radius-sm: 0.35rem;
+
+ /* Palette mapped to root design tokens */
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+
+ --color-popover: var(--popover);
+ --color-popover-hover: var(--popover-hover);
+ --color-popover-foreground: var(--popover-foreground);
+
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-border: var(--secondary-border);
+ --color-secondary-foreground: var(--secondary-foreground);
+
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+
+ --color-constructive: var(--constructive);
+ --color-constructive-foreground: var(--constructive-foreground);
+
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+
+ /* Chart colors */
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+
+ /* Sidebar */
+ --color-sidebar: var(--sidebar-background);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+
+ /* Animations */
+ --animate-accordion-down: accordion-down 0.2s ease-out;
+ --animate-accordion-up: accordion-up 0.2s ease-out;
+
+ @keyframes accordion-down {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--radix-accordion-content-height);
+ }
+ }
+
+ @keyframes accordion-up {
+ from {
+ height: var(--radix-accordion-content-height);
+ }
+ to {
+ height: 0;
+ }
+ }
+}
+
+@utility scrollbar-hidden {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+@utility scrollbar-x-hidden {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:horizontal {
+ display: none;
+ }
+}
+
+@utility scrollbar-y-hidden {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:vertical {
+ display: none;
+ }
+}
+
+@utility scrollbar-thin {
+ &::-webkit-scrollbar {
+ width: 7px;
+ height: 7px;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+ }
+ &::-webkit-scrollbar-thumb:hover {
+ background: var(--muted-foreground);
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts
index 7c192d2b..72f0c2aa 100644
--- a/apps/web/src/app/metadata.ts
+++ b/apps/web/src/app/metadata.ts
@@ -1,86 +1,86 @@
-import type { Metadata } from "next";
-import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
-
-export const baseMetaData: Metadata = {
- metadataBase: new URL(SITE_URL),
- title: SITE_INFO.title,
- description: SITE_INFO.description,
- openGraph: {
- title: SITE_INFO.title,
- description: SITE_INFO.description,
- url: SITE_URL,
- siteName: SITE_INFO.title,
- locale: "en_US",
- type: "website",
- images: [
- {
- url: SITE_INFO.openGraphImage,
- width: 1200,
- height: 630,
- alt: "OpenCut Wordmark",
- },
- ],
- },
- twitter: {
- card: "summary_large_image",
- title: SITE_INFO.title,
- description: SITE_INFO.description,
- creator: "@opencutapp",
- images: [SITE_INFO.twitterImage],
- },
- pinterest: {
- richPin: false,
- },
- robots: {
- index: true,
- follow: true,
- },
- icons: {
- icon: [
- { url: "/favicon.ico" },
- { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
- { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
- { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
- ],
- apple: [
- { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
- { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
- { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
- { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
- {
- url: "/icons/apple-icon-114x114.png",
- sizes: "114x114",
- type: "image/png",
- },
- {
- url: "/icons/apple-icon-120x120.png",
- sizes: "120x120",
- type: "image/png",
- },
- {
- url: "/icons/apple-icon-144x144.png",
- sizes: "144x144",
- type: "image/png",
- },
- {
- url: "/icons/apple-icon-152x152.png",
- sizes: "152x152",
- type: "image/png",
- },
- {
- url: "/icons/apple-icon-180x180.png",
- sizes: "180x180",
- type: "image/png",
- },
- ],
- shortcut: ["/favicon.ico"],
- },
- appleWebApp: {
- capable: true,
- title: SITE_INFO.title,
- },
- manifest: "/manifest.json",
- other: {
- "msapplication-config": "/browserconfig.xml",
- },
-};
+import type { Metadata } from "next";
+import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
+
+export const baseMetaData: Metadata = {
+ metadataBase: new URL(SITE_URL),
+ title: SITE_INFO.title,
+ description: SITE_INFO.description,
+ openGraph: {
+ title: SITE_INFO.title,
+ description: SITE_INFO.description,
+ url: SITE_URL,
+ siteName: SITE_INFO.title,
+ locale: "en_US",
+ type: "website",
+ images: [
+ {
+ url: SITE_INFO.openGraphImage,
+ width: 1200,
+ height: 630,
+ alt: "OpenCut Wordmark",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: SITE_INFO.title,
+ description: SITE_INFO.description,
+ creator: "@opencutapp",
+ images: [SITE_INFO.twitterImage],
+ },
+ pinterest: {
+ richPin: false,
+ },
+ robots: {
+ index: true,
+ follow: true,
+ },
+ icons: {
+ icon: [
+ { url: "/favicon.ico" },
+ { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
+ { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
+ { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
+ ],
+ apple: [
+ { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
+ { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
+ { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
+ { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
+ {
+ url: "/icons/apple-icon-114x114.png",
+ sizes: "114x114",
+ type: "image/png",
+ },
+ {
+ url: "/icons/apple-icon-120x120.png",
+ sizes: "120x120",
+ type: "image/png",
+ },
+ {
+ url: "/icons/apple-icon-144x144.png",
+ sizes: "144x144",
+ type: "image/png",
+ },
+ {
+ url: "/icons/apple-icon-152x152.png",
+ sizes: "152x152",
+ type: "image/png",
+ },
+ {
+ url: "/icons/apple-icon-180x180.png",
+ sizes: "180x180",
+ type: "image/png",
+ },
+ ],
+ shortcut: ["/favicon.ico"],
+ },
+ appleWebApp: {
+ capable: true,
+ title: SITE_INFO.title,
+ },
+ manifest: "/manifest.json",
+ other: {
+ "msapplication-config": "/browserconfig.xml",
+ },
+};
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index d97c9a54..43dbc104 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -1,21 +1,21 @@
-import { Hero } from "@/components/landing/hero";
-import { Header } from "@/components/header";
-import { Footer } from "@/components/footer";
-import type { Metadata } from "next";
-import { SITE_URL } from "@/constants/site-constants";
-
-export const metadata: Metadata = {
- alternates: {
- canonical: SITE_URL,
- },
-};
-
-export default async function Home() {
- return (
- <div>
- <Header />
- <Hero />
- <Footer />
- </div>
- );
-}
+import { Hero } from "@/components/landing/hero";
+import { Header } from "@/components/header";
+import { Footer } from "@/components/footer";
+import type { Metadata } from "next";
+import { SITE_URL } from "@/constants/site-constants";
+
+export const metadata: Metadata = {
+ alternates: {
+ canonical: SITE_URL,
+ },
+};
+
+export default async function Home() {
+ return (
+ <div>
+ <Header />
+ <Hero />
+ <Footer />
+ </div>
+ );
+}
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index f32ce2b9..8c5d9d04 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -1,1015 +1,1015 @@
-"use client";
-
-import Image from "next/image";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import type { KeyboardEvent, MouseEvent } from "react";
-import { useEffect, useState } from "react";
-import { toast } from "sonner";
-import type { EditorCore } from "@/core";
-import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
-import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Input } from "@/components/ui/input";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useEditor } from "@/hooks/use-editor";
-import { useProjectsStore } from "./store";
-import type {
- TProjectMetadata,
- TProjectSortKey,
- TProjectSortOption,
-} from "@/lib/project/types";
-import { formatTimeCode } from "@/lib/time";
-import { formatDate } from "@/utils/date";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbPage,
- BreadcrumbSeparator,
-} from "@/components/ui/breadcrumb";
-import {
- Calendar04Icon,
- GridViewIcon,
- LeftToRightListDashIcon,
- PlusSignIcon,
- Search01Icon,
- Video01Icon,
- MoreHorizontalIcon,
- Delete02Icon,
- Copy01Icon,
- Edit03Icon,
- ArrowDown02Icon,
- InformationCircleIcon,
-} from "@hugeicons/core-free-icons";
-import { OcVideoIcon } from "@opencut/ui/icons";
-import { Label } from "@/components/ui/label";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
-import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
-import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
-import { cn } from "@/utils/ui";
-
-const formatProjectDuration = ({
- duration,
-}: {
- duration: number | undefined;
-}): string | null => {
- if (duration === undefined) {
- return null;
- }
-
- const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
- return formatTimeCode({ timeInSeconds: duration, format });
-};
-
-const VIEW_MODE_OPTIONS = [
- { mode: "grid" as const, icon: GridViewIcon, label: "Grid view" },
- { mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" },
-];
-
-export default function ProjectsPage() {
- const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
- const editor = useEditor();
- const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
-
- const isLoading = useEditor((e) => e.project.getIsLoading());
- const isInitialized = useEditor((e) => e.project.getIsInitialized());
- const projectsToDisplay = useEditor((e) =>
- e.project.getFilteredAndSortedProjects({ searchQuery, sortOption }),
- );
-
- useEffect(() => {
- if (!editor.project.getIsInitialized()) {
- editor.project.loadAllProjects();
- }
- }, [editor.project]);
-
- return (
- <div className="bg-background min-h-screen">
- <MigrationDialog />
- <StoragePersistenceDialog />
- <ProjectsHeader />
- <ProjectsToolbar projectIds={projectsToDisplay.map((p) => p.id)} />
- <main className="mx-auto px-4 pt-2 pb-6 flex flex-col gap-4">
- {isLoading || !isInitialized ? (
- <ProjectsSkeleton />
- ) : projectsToDisplay.length === 0 ? (
- <EmptyState />
- ) : (
- <div
- className={
- viewMode === "grid"
- ? "xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4 px-4"
- : "flex flex-col"
- }
- >
- {projectsToDisplay.map((project) => (
- <ProjectItem
- key={project.id}
- project={project}
- allProjectIds={projectsToDisplay.map((p) => p.id)}
- />
- ))}
- </div>
- )}
- </main>
- </div>
- );
-}
-
-function ProjectsHeader() {
- const { viewMode, isHydrated, setViewMode } = useProjectsStore();
-
- return (
- <header className="sticky top-0 z-20 px-8 bg-background flex flex-col gap-2">
- <div className="flex items-center justify-between h-16 pt-2">
- <div className="flex items-center gap-5">
- <Breadcrumb>
- <BreadcrumbList>
- <BreadcrumbItem>
- <BreadcrumbLink asChild>
- <Link href="/" className="text-sm sm:text-base">
- Home
- </Link>
- </BreadcrumbLink>
- </BreadcrumbItem>
- <BreadcrumbSeparator />
- <BreadcrumbItem>
- <BreadcrumbPage className="text-sm sm:text-base font-medium">
- All projects
- </BreadcrumbPage>
- </BreadcrumbItem>
- </BreadcrumbList>
- </Breadcrumb>
-
- <div className="hidden md:flex items-center rounded-md border p-1 px-1.5 h-10">
- {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
- <Button
- key={mode}
- variant="ghost"
- size="icon"
- className={cn(
- "rounded-sm hover:bg-background",
- isHydrated && viewMode === mode && "!bg-accent",
- )}
- onClick={() => setViewMode({ viewMode: mode })}
- aria-label={label}
- aria-pressed={isHydrated && viewMode === mode}
- >
- <HugeiconsIcon icon={icon} className="size-4" />
- </Button>
- ))}
- </div>
- </div>
-
- <div className="flex items-center gap-3 md:gap-4">
- <SearchBar className="hidden md:block" />
- <NewProjectButton />
- </div>
- </div>
- <SearchBar className="block md:hidden mb-4" />
- </header>
- );
-}
-
-const SORT_LABELS: Record<TProjectSortKey, string> = {
- createdAt: "Created",
- updatedAt: "Modified",
- name: "Name",
- duration: "Duration",
-};
-
-function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
- const {
- selectedProjectIds,
- sortKey,
- sortOrder,
- setSortOrder,
- setSelectedProjects,
- clearSelectedProjects,
- viewMode,
- setViewMode,
- } = useProjectsStore();
-
- const selectedProjectCount = selectedProjectIds.length;
- const isAllSelected =
- projectIds.length > 0 && selectedProjectCount === projectIds.length;
- const hasSomeSelected =
- selectedProjectCount > 0 && selectedProjectCount < projectIds.length;
-
- const handleSelectAll = ({ checked }: { checked: boolean }) => {
- if (checked) {
- setSelectedProjects({ projectIds });
- return;
- }
- clearSelectedProjects();
- };
-
- return (
- <div className="sticky top-16 z-10 flex items-center justify-between px-6 h-14 pt-2 bg-background">
- <div className="flex items-center gap-2">
- <Label
- className="flex items-center gap-3 cursor-pointer px-2"
- htmlFor="select-all-projects"
- >
- <Checkbox
- className="size-5"
- id="select-all-projects"
- checked={
- isAllSelected ? true : hasSomeSelected ? "indeterminate" : false
- }
- onCheckedChange={(checked) =>
- handleSelectAll({ checked: checked === true })
- }
- />
- <span className="text-muted-foreground hidden md:block">
- Select all
- </span>
- </Label>
-
- <div className="h-4 w-px bg-border/50" />
-
- <SortDropdown>
- <Button variant="text" className="text-muted-foreground pl-2">
- {SORT_LABELS[sortKey]}
- </Button>
- </SortDropdown>
- <Button
- variant="text"
- className="text-muted-foreground"
- onClick={() =>
- setSortOrder({
- sortOrder: sortOrder === "asc" ? "desc" : "asc",
- })
- }
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ") {
- setSortOrder({
- sortOrder: sortOrder === "asc" ? "desc" : "asc",
- });
- }
- }}
- aria-label={`Sort ${sortOrder === "asc" ? "ascending" : "descending"}`}
- >
- <HugeiconsIcon
- icon={ArrowDown02Icon}
- className={sortOrder === "asc" ? "rotate-180" : ""}
- />
- </Button>
-
- <div className="h-4 w-px bg-border/50 block md:hidden" />
-
- <div className="flex md:hidden items-center gap-4">
- {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
- <Button
- key={mode}
- variant="text"
- onClick={() => setViewMode({ viewMode: mode })}
- aria-label={label}
- >
- <HugeiconsIcon
- icon={icon}
- className={cn(
- viewMode === mode ? "text-primary" : "text-muted-foreground",
- )}
- />
- </Button>
- ))}
- </div>
- </div>
- {selectedProjectCount > 0 ? <ProjectActions /> : null}
- </div>
- );
-}
-
-function SearchBar({
- className,
- collapsed,
-}: {
- className?: string;
- collapsed?: boolean;
-}) {
- const { searchQuery, setSearchQuery } = useProjectsStore();
-
- return (
- <>
- {collapsed ? (
- <div className="block md:hidden">
- <Button
- size="icon"
- variant="outline"
- className="size-10.5 rounded-full"
- >
- <HugeiconsIcon icon={Search01Icon} />
- </Button>
- </div>
- ) : (
- <div className={cn("relative", className)}>
- <HugeiconsIcon
- icon={Search01Icon}
- className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
- aria-hidden="true"
- />
- <Input
- placeholder="Search..."
- value={searchQuery}
- onChange={(event) => setSearchQuery({ query: event.target.value })}
- size="lg"
- className="pl-9"
- />
- </div>
- )}
- </>
- );
-}
-
-const PROJECT_ACTIONS = [
- {
- id: "duplicate",
- label: "Duplicate",
- icon: Copy01Icon,
- variant: "outline" as const,
- },
- {
- id: "delete",
- label: "Delete",
- icon: Delete02Icon,
- variant: "destructive-foreground" as const,
- },
-] as const;
-
-async function deleteProjects({
- editor,
- ids,
-}: {
- editor: EditorCore;
- ids: string[];
-}) {
- await editor.project.deleteProjects({ ids });
-}
-
-async function duplicateProjects({
- editor,
- ids,
-}: {
- editor: EditorCore;
- ids: string[];
-}) {
- await editor.project.duplicateProjects({ ids });
-}
-
-async function renameProject({
- editor,
- id,
- name,
-}: {
- editor: EditorCore;
- id: string;
- name: string;
-}) {
- await editor.project.renameProject({ id, name });
-}
-
-function ProjectActions() {
- const editor = useEditor();
- const { selectedProjectIds, clearSelectedProjects } = useProjectsStore();
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
-
- const savedProjects = editor.project.getSavedProjects();
- const selectedProjectNames = savedProjects
- .filter((project) => selectedProjectIds.includes(project.id))
- .map((project) => project.name);
-
- const handleDuplicate = async () => {
- await duplicateProjects({ editor, ids: selectedProjectIds });
- clearSelectedProjects();
- };
-
- const handleDeleteClick = () => {
- setIsDeleteDialogOpen(true);
- };
-
- const handleDeleteConfirm = async () => {
- await deleteProjects({ editor, ids: selectedProjectIds });
- clearSelectedProjects();
- setIsDeleteDialogOpen(false);
- };
-
- const actionHandlers: Record<string, () => void> = {
- duplicate: handleDuplicate,
- delete: handleDeleteClick,
- };
-
- return (
- <>
- <div className="flex items-center gap-2.5 px-3">
- <div className="hidden sm:flex items-center gap-2.5">
- {PROJECT_ACTIONS.map((action) => (
- <Button
- key={action.id}
- size="icon"
- variant={action.variant}
- className="size-9"
- onClick={actionHandlers[action.id]}
- >
- <HugeiconsIcon icon={action.icon} />
- </Button>
- ))}
- </div>
-
- <DropdownMenu>
- <DropdownMenuTrigger asChild className="sm:hidden">
- <Button size="icon" variant="outline" className="size-9">
- <HugeiconsIcon icon={MoreHorizontalIcon} />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- {PROJECT_ACTIONS.map((action) => (
- <DropdownMenuItem
- key={action.id}
- variant={action.id === "delete" ? "destructive" : undefined}
- onClick={actionHandlers[action.id]}
- >
- <HugeiconsIcon icon={action.icon} />
- {action.label}
- </DropdownMenuItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
-
- <DeleteProjectDialog
- isOpen={isDeleteDialogOpen}
- onOpenChange={setIsDeleteDialogOpen}
- projectNames={selectedProjectNames}
- onConfirm={handleDeleteConfirm}
- />
- </>
- );
-}
-
-function SortDropdown({ children }: { children: React.ReactNode }) {
- const { sortKey, setSortKey } = useProjectsStore();
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
- <DropdownMenuContent className="w-48" align="center">
- <DropdownMenuCheckboxItem
- checked={sortKey === "createdAt"}
- onCheckedChange={() => setSortKey({ sortKey: "createdAt" })}
- >
- Created
- </DropdownMenuCheckboxItem>
- <DropdownMenuCheckboxItem
- checked={sortKey === "updatedAt"}
- onCheckedChange={() => setSortKey({ sortKey: "updatedAt" })}
- >
- Modified
- </DropdownMenuCheckboxItem>
- <DropdownMenuCheckboxItem
- checked={sortKey === "name"}
- onCheckedChange={() => setSortKey({ sortKey: "name" })}
- >
- Name
- </DropdownMenuCheckboxItem>
- <DropdownMenuCheckboxItem
- checked={sortKey === "duration"}
- onCheckedChange={() => setSortKey({ sortKey: "duration" })}
- >
- Duration
- </DropdownMenuCheckboxItem>
- </DropdownMenuContent>
- </DropdownMenu>
- );
-}
-
-function NewProjectButton() {
- const editor = useEditor();
- const router = useRouter();
-
- const handleCreateProject = async () => {
- const projectId = await editor.project.createNewProject({
- name: "New project",
- });
- router.push(`/editor/${projectId}`);
- };
-
- return (
- <Button
- size="lg"
- className="flex px-5 md:px-6"
- onClick={handleCreateProject}
- >
- <span className="text-sm font-medium hidden md:block">New project</span>
- <span className="text-sm font-medium block md:hidden">New</span>
- </Button>
- );
-}
-
-function ProjectItem({
- project,
- allProjectIds,
-}: {
- project: TProjectMetadata;
- allProjectIds: string[];
-}) {
- const {
- selectedProjectIds,
- viewMode,
- setProjectSelected,
- selectProjectRange,
- } = useProjectsStore();
- const selectedProjectIdSet = new Set(selectedProjectIds);
- const isSelected = selectedProjectIdSet.has(project.id);
- const selectedProjectCount = selectedProjectIds.length;
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
- const editor = useEditor();
- const durationLabel = formatProjectDuration({ duration: project.duration });
- const isMultiSelect = selectedProjectCount > 1;
- const isGridView = viewMode === "grid";
-
- const handleRename = () => setIsRenameDialogOpen(true);
- const handleDuplicate = async () => {
- await duplicateProjects({ editor, ids: [project.id] });
- };
- const handleDeleteClick = () => setIsDeleteDialogOpen(true);
- const handleInfoClick = () => setIsInfoDialogOpen(true);
- const handleDeleteConfirm = async () => {
- await deleteProjects({ editor, ids: [project.id] });
- setIsDeleteDialogOpen(false);
- };
-
- const handleCheckboxChange = ({
- checked,
- shiftKey,
- }: {
- checked: boolean;
- shiftKey: boolean;
- }) => {
- if (shiftKey && checked) {
- selectProjectRange({ projectId: project.id, allProjectIds });
- return;
- }
- setProjectSelected({ projectId: project.id, isSelected: checked });
- };
-
- const gridContent = (
- <Card className="bg-background overflow-hidden border-none p-0">
- <div className="bg-muted relative aspect-video">
- <div className="absolute inset-0">
- {project.thumbnail ? (
- <Image
- src={project.thumbnail}
- alt="Project thumbnail"
- fill
- className="object-cover"
- />
- ) : (
- <div className="flex size-full items-center justify-center">
- <OcVideoIcon className="text-muted-foreground size-12 shrink-0" />
- </div>
- )}
- </div>
-
- {durationLabel && (
- <div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs font-semibold px-2 py-1 rounded-sm">
- {durationLabel}
- </div>
- )}
- </div>
-
- <CardContent className="flex flex-col gap-2 px-0 pt-4">
- <h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium">
- {project.name}
- </h3>
- <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
- <HugeiconsIcon icon={Calendar04Icon} className="size-4" />
- <span>Created {formatDate({ date: project.createdAt })}</span>
- </div>
- </CardContent>
- </Card>
- );
-
- const listRowContent = (
- <div className="flex items-center gap-3 flex-1 min-w-0">
- <div className="bg-muted relative size-10 rounded overflow-hidden shrink-0">
- {project.thumbnail ? (
- <Image
- src={project.thumbnail}
- alt="Project thumbnail"
- fill
- className="object-cover"
- />
- ) : (
- <div className="flex size-full items-center justify-center">
- <OcVideoIcon className="text-muted-foreground size-5 shrink-0" />
- </div>
- )}
- </div>
-
- <h3 className="group-hover:text-foreground/90 text-sm font-medium truncate flex-1 min-w-0">
- {project.name}
- </h3>
-
- <span className="text-muted-foreground text-sm shrink-0 hidden sm:block">
- {durationLabel ?? "—"}
- </span>
-
- <span className="text-muted-foreground text-sm shrink-0 w-auto pl-8 text-right hidden xs:block">
- {formatDate({ date: project.createdAt })}
- </span>
- </div>
- );
-
- const listContent = (
- <div
- className={`flex items-center gap-4 py-2 px-4 border-b border-border/50 ${
- isSelected ? "bg-primary/5" : ""
- }`}
- >
- <Checkbox
- checked={isSelected}
- onMouseDown={(event) => event.preventDefault()}
- onClick={(event) => {
- handleCheckboxChange({
- checked: !isSelected,
- shiftKey: event.shiftKey,
- });
- }}
- onCheckedChange={() => {}}
- className="size-5 shrink-0"
- />
-
- <Link href={`/editor/${project.id}`} className="flex-1 min-w-0">
- {listRowContent}
- </Link>
-
- {!isMultiSelect && (
- <ProjectMenu
- isOpen={isDropdownOpen}
- onOpenChange={setIsDropdownOpen}
- variant="list"
- onRenameClick={handleRename}
- onDuplicateClick={handleDuplicate}
- onDeleteClick={handleDeleteClick}
- onInfoClick={handleInfoClick}
- />
- )}
- </div>
- );
-
- return (
- <>
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div className="group relative">
- {isGridView ? (
- <>
- <Link href={`/editor/${project.id}`} className="block">
- {gridContent}
- </Link>
-
- <Checkbox
- checked={isSelected}
- onMouseDown={(event) => event.preventDefault()}
- onClick={(event) => {
- handleCheckboxChange({
- checked: !isSelected,
- shiftKey: event.shiftKey,
- });
- }}
- onCheckedChange={() => {}}
- className={`absolute z-10 size-5 top-3 left-3 ${
- isSelected || isDropdownOpen
- ? "opacity-100"
- : "opacity-0 group-hover:opacity-100"
- }`}
- />
-
- {!isMultiSelect && (
- <ProjectMenu
- isOpen={isDropdownOpen}
- onOpenChange={setIsDropdownOpen}
- onRenameClick={handleRename}
- onDuplicateClick={handleDuplicate}
- onDeleteClick={handleDeleteClick}
- onInfoClick={handleInfoClick}
- />
- )}
- </>
- ) : (
- listContent
- )}
- </div>
- </ContextMenuTrigger>
- <ProjectContextMenuContent
- onRenameClick={handleRename}
- onDuplicateClick={handleDuplicate}
- onDeleteClick={handleDeleteClick}
- onInfoClick={handleInfoClick}
- />
- </ContextMenu>
-
- <RenameProjectDialog
- isOpen={isRenameDialogOpen}
- onOpenChange={setIsRenameDialogOpen}
- projectName={project.name}
- onConfirm={async (newName) => {
- await renameProject({ editor, id: project.id, name: newName });
- setIsRenameDialogOpen(false);
- }}
- />
-
- <DeleteProjectDialog
- isOpen={isDeleteDialogOpen}
- onOpenChange={setIsDeleteDialogOpen}
- projectNames={[project.name]}
- onConfirm={handleDeleteConfirm}
- />
-
- <ProjectInfoDialog
- isOpen={isInfoDialogOpen}
- onOpenChange={setIsInfoDialogOpen}
- project={project}
- />
- </>
- );
-}
-
-function ProjectContextMenuContent({
- onRenameClick,
- onDuplicateClick,
- onDeleteClick,
- onInfoClick,
-}: {
- onRenameClick: () => void;
- onDuplicateClick: () => void;
- onDeleteClick: () => void;
- onInfoClick: () => void;
-}) {
- return (
- <ContextMenuContent>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={Edit03Icon} />}
- onClick={onRenameClick}
- >
- Rename
- </ContextMenuItem>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={Copy01Icon} />}
- onClick={onDuplicateClick}
- >
- Duplicate
- </ContextMenuItem>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={InformationCircleIcon} />}
- onClick={onInfoClick}
- >
- Info
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuItem
- variant="destructive"
- icon={<HugeiconsIcon icon={Delete02Icon} />}
- onClick={onDeleteClick}
- >
- Delete
- </ContextMenuItem>
- </ContextMenuContent>
- );
-}
-
-function ProjectMenu({
- isOpen,
- onOpenChange,
- variant = "grid",
- onRenameClick,
- onDuplicateClick,
- onDeleteClick,
- onInfoClick,
-}: {
- isOpen: boolean;
- onOpenChange: (open: boolean) => void;
- variant?: "grid" | "list";
- onRenameClick: () => void;
- onDuplicateClick: () => void;
- onDeleteClick: () => void;
- onInfoClick: () => void;
-}) {
- const handleMenuClick = ({
- event,
- }: {
- event: MouseEvent<HTMLButtonElement>;
- }) => {
- event.preventDefault();
- event.stopPropagation();
- };
-
- const handleMenuKeyDown = ({
- event,
- }: {
- event: KeyboardEvent<HTMLButtonElement>;
- }) => {
- if (event.key !== "Enter" && event.key !== " ") {
- return;
- }
- event.preventDefault();
- event.stopPropagation();
- };
-
- const handleRename = () => {
- onRenameClick();
- onOpenChange(false);
- };
-
- const handleDuplicate = () => {
- onDuplicateClick();
- onOpenChange(false);
- };
-
- const handleDeleteClick = () => {
- onDeleteClick();
- onOpenChange(false);
- };
-
- const handleInfoClick = () => {
- onInfoClick();
- onOpenChange(false);
- };
-
- const isGrid = variant === "grid";
-
- return (
- <DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
- <DropdownMenuTrigger asChild>
- <Button
- variant="background"
- className={
- isGrid
- ? `absolute z-10 top-3 right-3 ${isOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`
- : "!bg-transparent !shadow-none"
- }
- size="icon"
- aria-label="Project menu"
- onClick={(event) =>
- handleMenuClick({
- event: event as unknown as MouseEvent<HTMLButtonElement>,
- })
- }
- onMouseDown={(event) => event.stopPropagation()}
- onKeyDown={(event) =>
- handleMenuKeyDown({
- event: event as unknown as KeyboardEvent<HTMLButtonElement>,
- })
- }
- >
- <HugeiconsIcon
- icon={MoreHorizontalIcon}
- className="text-foreground"
- aria-hidden="true"
- />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent className="w-48" align="end">
- <DropdownMenuItem onClick={handleRename}>
- <HugeiconsIcon icon={Edit03Icon} />
- Rename
- </DropdownMenuItem>
- <DropdownMenuItem onClick={handleDuplicate}>
- <HugeiconsIcon icon={Copy01Icon} />
- Duplicate
- </DropdownMenuItem>
- <DropdownMenuItem onClick={handleInfoClick}>
- <HugeiconsIcon icon={InformationCircleIcon} />
- Info
- </DropdownMenuItem>
- <DropdownMenuItem variant="destructive" onClick={handleDeleteClick}>
- <HugeiconsIcon icon={Delete02Icon} />
- Delete
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- );
-}
-
-function ProjectsSkeleton() {
- const skeletonIds = Array.from(
- { length: 24 },
- (_, index) => `skeleton-${index}`,
- );
-
- return (
- <div className="px-4 xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
- {skeletonIds.map((skeletonId) => (
- <Card
- key={skeletonId}
- className="bg-background overflow-hidden border-none p-0"
- >
- <div className="bg-muted relative aspect-video">
- <div className="absolute inset-0">
- <Skeleton className="bg-muted/50 size-full" />
- </div>
- </div>
- <CardContent className="flex flex-col gap-2 px-0 pt-4">
- <Skeleton className="bg-muted/50 h-4 w-3/4" />
- <div className="text-muted-foreground flex items-center gap-1.5">
- <Skeleton className="bg-muted/50 size-4" />
- <Skeleton className="bg-muted/50 h-4 w-24" />
- </div>
- </CardContent>
- </Card>
- ))}
- </div>
- );
-}
-
-function EmptyState() {
- const { searchQuery, setSearchQuery } = useProjectsStore();
- const router = useRouter();
- const editor = useEditor();
- const savedProjects = editor.project.getSavedProjects();
-
- const handleCreateProject = async () => {
- try {
- const projectId = await editor.project.createNewProject({
- name: "New project",
- });
- router.push(`/editor/${projectId}`);
- } catch (error) {
- toast.error("Failed to create project", {
- description:
- error instanceof Error ? error.message : "Please try again",
- });
- }
- };
-
- if (savedProjects.length > 0) {
- return (
- <div className="flex flex-col items-center justify-center gap-5 py-16 text-center">
- <div className="flex flex-col items-center gap-8">
- <HugeiconsIcon
- icon={Search01Icon}
- className="text-muted-foreground size-16 bg-accent/35 border rounded-md p-4"
- />
- <div className="flex flex-col items-center gap-3">
- <h3 className="text-lg font-medium">No results found</h3>
- <p className="text-muted-foreground max-w-md">
- Your search for "{searchQuery}" did not return any results.
- </p>
- </div>
- </div>
- <Button
- onClick={() => setSearchQuery({ query: "" })}
- variant="outline"
- size="lg"
- >
- Clear search
- </Button>
- </div>
- );
- }
-
- return (
- <div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
- <div className="flex flex-col items-center gap-2">
- <div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
- <HugeiconsIcon
- icon={Video01Icon}
- className="text-muted-foreground size-8"
- />
- </div>
- <h3 className="text-lg font-medium">No projects yet</h3>
- <p className="text-muted-foreground max-w-md">
- Start creating your first project. Import media, edit, and export your
- videos. All privately.
- </p>
- </div>
- <Button size="lg" className="gap-2" onClick={handleCreateProject}>
- <HugeiconsIcon icon={PlusSignIcon} />
- Create your first project
- </Button>
- </div>
- );
-}
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import type { KeyboardEvent, MouseEvent } from "react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import type { EditorCore } from "@/core";
+import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
+import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useEditor } from "@/hooks/use-editor";
+import { useProjectsStore } from "./store";
+import type {
+ TProjectMetadata,
+ TProjectSortKey,
+ TProjectSortOption,
+} from "@/lib/project/types";
+import { formatTimeCode } from "@/lib/time";
+import { formatDate } from "@/utils/date";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import {
+ Calendar04Icon,
+ GridViewIcon,
+ LeftToRightListDashIcon,
+ PlusSignIcon,
+ Search01Icon,
+ Video01Icon,
+ MoreHorizontalIcon,
+ Delete02Icon,
+ Copy01Icon,
+ Edit03Icon,
+ ArrowDown02Icon,
+ InformationCircleIcon,
+} from "@hugeicons/core-free-icons";
+import { OcVideoIcon } from "@opencut/ui/icons";
+import { Label } from "@/components/ui/label";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
+import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
+import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
+import { cn } from "@/utils/ui";
+
+const formatProjectDuration = ({
+ duration,
+}: {
+ duration: number | undefined;
+}): string | null => {
+ if (duration === undefined) {
+ return null;
+ }
+
+ const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
+ return formatTimeCode({ timeInSeconds: duration, format });
+};
+
+const VIEW_MODE_OPTIONS = [
+ { mode: "grid" as const, icon: GridViewIcon, label: "Grid view" },
+ { mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" },
+];
+
+export default function ProjectsPage() {
+ const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
+ const editor = useEditor();
+ const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
+
+ const isLoading = useEditor((e) => e.project.getIsLoading());
+ const isInitialized = useEditor((e) => e.project.getIsInitialized());
+ const projectsToDisplay = useEditor((e) =>
+ e.project.getFilteredAndSortedProjects({ searchQuery, sortOption }),
+ );
+
+ useEffect(() => {
+ if (!editor.project.getIsInitialized()) {
+ editor.project.loadAllProjects();
+ }
+ }, [editor.project]);
+
+ return (
+ <div className="bg-background min-h-screen">
+ <MigrationDialog />
+ <StoragePersistenceDialog />
+ <ProjectsHeader />
+ <ProjectsToolbar projectIds={projectsToDisplay.map((p) => p.id)} />
+ <main className="mx-auto px-4 pt-2 pb-6 flex flex-col gap-4">
+ {isLoading || !isInitialized ? (
+ <ProjectsSkeleton />
+ ) : projectsToDisplay.length === 0 ? (
+ <EmptyState />
+ ) : (
+ <div
+ className={
+ viewMode === "grid"
+ ? "xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4 px-4"
+ : "flex flex-col"
+ }
+ >
+ {projectsToDisplay.map((project) => (
+ <ProjectItem
+ key={project.id}
+ project={project}
+ allProjectIds={projectsToDisplay.map((p) => p.id)}
+ />
+ ))}
+ </div>
+ )}
+ </main>
+ </div>
+ );
+}
+
+function ProjectsHeader() {
+ const { viewMode, isHydrated, setViewMode } = useProjectsStore();
+
+ return (
+ <header className="sticky top-0 z-20 px-8 bg-background flex flex-col gap-2">
+ <div className="flex items-center justify-between h-16 pt-2">
+ <div className="flex items-center gap-5">
+ <Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink asChild>
+ <Link href="/" className="text-sm sm:text-base">
+ Home
+ </Link>
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ <BreadcrumbPage className="text-sm sm:text-base font-medium">
+ All projects
+ </BreadcrumbPage>
+ </BreadcrumbItem>
+ </BreadcrumbList>
+ </Breadcrumb>
+
+ <div className="hidden md:flex items-center rounded-md border p-1 px-1.5 h-10">
+ {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
+ <Button
+ key={mode}
+ variant="ghost"
+ size="icon"
+ className={cn(
+ "rounded-sm hover:bg-background",
+ isHydrated && viewMode === mode && "!bg-accent",
+ )}
+ onClick={() => setViewMode({ viewMode: mode })}
+ aria-label={label}
+ aria-pressed={isHydrated && viewMode === mode}
+ >
+ <HugeiconsIcon icon={icon} className="size-4" />
+ </Button>
+ ))}
+ </div>
+ </div>
+
+ <div className="flex items-center gap-3 md:gap-4">
+ <SearchBar className="hidden md:block" />
+ <NewProjectButton />
+ </div>
+ </div>
+ <SearchBar className="block md:hidden mb-4" />
+ </header>
+ );
+}
+
+const SORT_LABELS: Record<TProjectSortKey, string> = {
+ createdAt: "Created",
+ updatedAt: "Modified",
+ name: "Name",
+ duration: "Duration",
+};
+
+function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
+ const {
+ selectedProjectIds,
+ sortKey,
+ sortOrder,
+ setSortOrder,
+ setSelectedProjects,
+ clearSelectedProjects,
+ viewMode,
+ setViewMode,
+ } = useProjectsStore();
+
+ const selectedProjectCount = selectedProjectIds.length;
+ const isAllSelected =
+ projectIds.length > 0 && selectedProjectCount === projectIds.length;
+ const hasSomeSelected =
+ selectedProjectCount > 0 && selectedProjectCount < projectIds.length;
+
+ const handleSelectAll = ({ checked }: { checked: boolean }) => {
+ if (checked) {
+ setSelectedProjects({ projectIds });
+ return;
+ }
+ clearSelectedProjects();
+ };
+
+ return (
+ <div className="sticky top-16 z-10 flex items-center justify-between px-6 h-14 pt-2 bg-background">
+ <div className="flex items-center gap-2">
+ <Label
+ className="flex items-center gap-3 cursor-pointer px-2"
+ htmlFor="select-all-projects"
+ >
+ <Checkbox
+ className="size-5"
+ id="select-all-projects"
+ checked={
+ isAllSelected ? true : hasSomeSelected ? "indeterminate" : false
+ }
+ onCheckedChange={(checked) =>
+ handleSelectAll({ checked: checked === true })
+ }
+ />
+ <span className="text-muted-foreground hidden md:block">
+ Select all
+ </span>
+ </Label>
+
+ <div className="h-4 w-px bg-border/50" />
+
+ <SortDropdown>
+ <Button variant="text" className="text-muted-foreground pl-2">
+ {SORT_LABELS[sortKey]}
+ </Button>
+ </SortDropdown>
+ <Button
+ variant="text"
+ className="text-muted-foreground"
+ onClick={() =>
+ setSortOrder({
+ sortOrder: sortOrder === "asc" ? "desc" : "asc",
+ })
+ }
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ setSortOrder({
+ sortOrder: sortOrder === "asc" ? "desc" : "asc",
+ });
+ }
+ }}
+ aria-label={`Sort ${sortOrder === "asc" ? "ascending" : "descending"}`}
+ >
+ <HugeiconsIcon
+ icon={ArrowDown02Icon}
+ className={sortOrder === "asc" ? "rotate-180" : ""}
+ />
+ </Button>
+
+ <div className="h-4 w-px bg-border/50 block md:hidden" />
+
+ <div className="flex md:hidden items-center gap-4">
+ {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
+ <Button
+ key={mode}
+ variant="text"
+ onClick={() => setViewMode({ viewMode: mode })}
+ aria-label={label}
+ >
+ <HugeiconsIcon
+ icon={icon}
+ className={cn(
+ viewMode === mode ? "text-primary" : "text-muted-foreground",
+ )}
+ />
+ </Button>
+ ))}
+ </div>
+ </div>
+ {selectedProjectCount > 0 ? <ProjectActions /> : null}
+ </div>
+ );
+}
+
+function SearchBar({
+ className,
+ collapsed,
+}: {
+ className?: string;
+ collapsed?: boolean;
+}) {
+ const { searchQuery, setSearchQuery } = useProjectsStore();
+
+ return (
+ <>
+ {collapsed ? (
+ <div className="block md:hidden">
+ <Button
+ size="icon"
+ variant="outline"
+ className="size-10.5 rounded-full"
+ >
+ <HugeiconsIcon icon={Search01Icon} />
+ </Button>
+ </div>
+ ) : (
+ <div className={cn("relative", className)}>
+ <HugeiconsIcon
+ icon={Search01Icon}
+ className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
+ aria-hidden="true"
+ />
+ <Input
+ placeholder="Search..."
+ value={searchQuery}
+ onChange={(event) => setSearchQuery({ query: event.target.value })}
+ size="lg"
+ className="pl-9"
+ />
+ </div>
+ )}
+ </>
+ );
+}
+
+const PROJECT_ACTIONS = [
+ {
+ id: "duplicate",
+ label: "Duplicate",
+ icon: Copy01Icon,
+ variant: "outline" as const,
+ },
+ {
+ id: "delete",
+ label: "Delete",
+ icon: Delete02Icon,
+ variant: "destructive-foreground" as const,
+ },
+] as const;
+
+async function deleteProjects({
+ editor,
+ ids,
+}: {
+ editor: EditorCore;
+ ids: string[];
+}) {
+ await editor.project.deleteProjects({ ids });
+}
+
+async function duplicateProjects({
+ editor,
+ ids,
+}: {
+ editor: EditorCore;
+ ids: string[];
+}) {
+ await editor.project.duplicateProjects({ ids });
+}
+
+async function renameProject({
+ editor,
+ id,
+ name,
+}: {
+ editor: EditorCore;
+ id: string;
+ name: string;
+}) {
+ await editor.project.renameProject({ id, name });
+}
+
+function ProjectActions() {
+ const editor = useEditor();
+ const { selectedProjectIds, clearSelectedProjects } = useProjectsStore();
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+
+ const savedProjects = editor.project.getSavedProjects();
+ const selectedProjectNames = savedProjects
+ .filter((project) => selectedProjectIds.includes(project.id))
+ .map((project) => project.name);
+
+ const handleDuplicate = async () => {
+ await duplicateProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ };
+
+ const handleDeleteClick = () => {
+ setIsDeleteDialogOpen(true);
+ };
+
+ const handleDeleteConfirm = async () => {
+ await deleteProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ setIsDeleteDialogOpen(false);
+ };
+
+ const actionHandlers: Record<string, () => void> = {
+ duplicate: handleDuplicate,
+ delete: handleDeleteClick,
+ };
+
+ return (
+ <>
+ <div className="flex items-center gap-2.5 px-3">
+ <div className="hidden sm:flex items-center gap-2.5">
+ {PROJECT_ACTIONS.map((action) => (
+ <Button
+ key={action.id}
+ size="icon"
+ variant={action.variant}
+ className="size-9"
+ onClick={actionHandlers[action.id]}
+ >
+ <HugeiconsIcon icon={action.icon} />
+ </Button>
+ ))}
+ </div>
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild className="sm:hidden">
+ <Button size="icon" variant="outline" className="size-9">
+ <HugeiconsIcon icon={MoreHorizontalIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {PROJECT_ACTIONS.map((action) => (
+ <DropdownMenuItem
+ key={action.id}
+ variant={action.id === "delete" ? "destructive" : undefined}
+ onClick={actionHandlers[action.id]}
+ >
+ <HugeiconsIcon icon={action.icon} />
+ {action.label}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ projectNames={selectedProjectNames}
+ onConfirm={handleDeleteConfirm}
+ />
+ </>
+ );
+}
+
+function SortDropdown({ children }: { children: React.ReactNode }) {
+ const { sortKey, setSortKey } = useProjectsStore();
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="center">
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "createdAt"}
+ onCheckedChange={() => setSortKey({ sortKey: "createdAt" })}
+ >
+ Created
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "updatedAt"}
+ onCheckedChange={() => setSortKey({ sortKey: "updatedAt" })}
+ >
+ Modified
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "name"}
+ onCheckedChange={() => setSortKey({ sortKey: "name" })}
+ >
+ Name
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "duration"}
+ onCheckedChange={() => setSortKey({ sortKey: "duration" })}
+ >
+ Duration
+ </DropdownMenuCheckboxItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
+
+function NewProjectButton() {
+ const editor = useEditor();
+ const router = useRouter();
+
+ const handleCreateProject = async () => {
+ const projectId = await editor.project.createNewProject({
+ name: "New project",
+ });
+ router.push(`/editor/${projectId}`);
+ };
+
+ return (
+ <Button
+ size="lg"
+ className="flex px-5 md:px-6"
+ onClick={handleCreateProject}
+ >
+ <span className="text-sm font-medium hidden md:block">New project</span>
+ <span className="text-sm font-medium block md:hidden">New</span>
+ </Button>
+ );
+}
+
+function ProjectItem({
+ project,
+ allProjectIds,
+}: {
+ project: TProjectMetadata;
+ allProjectIds: string[];
+}) {
+ const {
+ selectedProjectIds,
+ viewMode,
+ setProjectSelected,
+ selectProjectRange,
+ } = useProjectsStore();
+ const selectedProjectIdSet = new Set(selectedProjectIds);
+ const isSelected = selectedProjectIdSet.has(project.id);
+ const selectedProjectCount = selectedProjectIds.length;
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
+ const editor = useEditor();
+ const durationLabel = formatProjectDuration({ duration: project.duration });
+ const isMultiSelect = selectedProjectCount > 1;
+ const isGridView = viewMode === "grid";
+
+ const handleRename = () => setIsRenameDialogOpen(true);
+ const handleDuplicate = async () => {
+ await duplicateProjects({ editor, ids: [project.id] });
+ };
+ const handleDeleteClick = () => setIsDeleteDialogOpen(true);
+ const handleInfoClick = () => setIsInfoDialogOpen(true);
+ const handleDeleteConfirm = async () => {
+ await deleteProjects({ editor, ids: [project.id] });
+ setIsDeleteDialogOpen(false);
+ };
+
+ const handleCheckboxChange = ({
+ checked,
+ shiftKey,
+ }: {
+ checked: boolean;
+ shiftKey: boolean;
+ }) => {
+ if (shiftKey && checked) {
+ selectProjectRange({ projectId: project.id, allProjectIds });
+ return;
+ }
+ setProjectSelected({ projectId: project.id, isSelected: checked });
+ };
+
+ const gridContent = (
+ <Card className="bg-background overflow-hidden border-none p-0">
+ <div className="bg-muted relative aspect-video">
+ <div className="absolute inset-0">
+ {project.thumbnail ? (
+ <Image
+ src={project.thumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="flex size-full items-center justify-center">
+ <OcVideoIcon className="text-muted-foreground size-12 shrink-0" />
+ </div>
+ )}
+ </div>
+
+ {durationLabel && (
+ <div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs font-semibold px-2 py-1 rounded-sm">
+ {durationLabel}
+ </div>
+ )}
+ </div>
+
+ <CardContent className="flex flex-col gap-2 px-0 pt-4">
+ <h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium">
+ {project.name}
+ </h3>
+ <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
+ <HugeiconsIcon icon={Calendar04Icon} className="size-4" />
+ <span>Created {formatDate({ date: project.createdAt })}</span>
+ </div>
+ </CardContent>
+ </Card>
+ );
+
+ const listRowContent = (
+ <div className="flex items-center gap-3 flex-1 min-w-0">
+ <div className="bg-muted relative size-10 rounded overflow-hidden shrink-0">
+ {project.thumbnail ? (
+ <Image
+ src={project.thumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="flex size-full items-center justify-center">
+ <OcVideoIcon className="text-muted-foreground size-5 shrink-0" />
+ </div>
+ )}
+ </div>
+
+ <h3 className="group-hover:text-foreground/90 text-sm font-medium truncate flex-1 min-w-0">
+ {project.name}
+ </h3>
+
+ <span className="text-muted-foreground text-sm shrink-0 hidden sm:block">
+ {durationLabel ?? "—"}
+ </span>
+
+ <span className="text-muted-foreground text-sm shrink-0 w-auto pl-8 text-right hidden xs:block">
+ {formatDate({ date: project.createdAt })}
+ </span>
+ </div>
+ );
+
+ const listContent = (
+ <div
+ className={`flex items-center gap-4 py-2 px-4 border-b border-border/50 ${
+ isSelected ? "bg-primary/5" : ""
+ }`}
+ >
+ <Checkbox
+ checked={isSelected}
+ onMouseDown={(event) => event.preventDefault()}
+ onClick={(event) => {
+ handleCheckboxChange({
+ checked: !isSelected,
+ shiftKey: event.shiftKey,
+ });
+ }}
+ onCheckedChange={() => {}}
+ className="size-5 shrink-0"
+ />
+
+ <Link href={`/editor/${project.id}`} className="flex-1 min-w-0">
+ {listRowContent}
+ </Link>
+
+ {!isMultiSelect && (
+ <ProjectMenu
+ isOpen={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ variant="list"
+ onRenameClick={handleRename}
+ onDuplicateClick={handleDuplicate}
+ onDeleteClick={handleDeleteClick}
+ onInfoClick={handleInfoClick}
+ />
+ )}
+ </div>
+ );
+
+ return (
+ <>
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div className="group relative">
+ {isGridView ? (
+ <>
+ <Link href={`/editor/${project.id}`} className="block">
+ {gridContent}
+ </Link>
+
+ <Checkbox
+ checked={isSelected}
+ onMouseDown={(event) => event.preventDefault()}
+ onClick={(event) => {
+ handleCheckboxChange({
+ checked: !isSelected,
+ shiftKey: event.shiftKey,
+ });
+ }}
+ onCheckedChange={() => {}}
+ className={`absolute z-10 size-5 top-3 left-3 ${
+ isSelected || isDropdownOpen
+ ? "opacity-100"
+ : "opacity-0 group-hover:opacity-100"
+ }`}
+ />
+
+ {!isMultiSelect && (
+ <ProjectMenu
+ isOpen={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ onRenameClick={handleRename}
+ onDuplicateClick={handleDuplicate}
+ onDeleteClick={handleDeleteClick}
+ onInfoClick={handleInfoClick}
+ />
+ )}
+ </>
+ ) : (
+ listContent
+ )}
+ </div>
+ </ContextMenuTrigger>
+ <ProjectContextMenuContent
+ onRenameClick={handleRename}
+ onDuplicateClick={handleDuplicate}
+ onDeleteClick={handleDeleteClick}
+ onInfoClick={handleInfoClick}
+ />
+ </ContextMenu>
+
+ <RenameProjectDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ projectName={project.name}
+ onConfirm={async (newName) => {
+ await renameProject({ editor, id: project.id, name: newName });
+ setIsRenameDialogOpen(false);
+ }}
+ />
+
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ projectNames={[project.name]}
+ onConfirm={handleDeleteConfirm}
+ />
+
+ <ProjectInfoDialog
+ isOpen={isInfoDialogOpen}
+ onOpenChange={setIsInfoDialogOpen}
+ project={project}
+ />
+ </>
+ );
+}
+
+function ProjectContextMenuContent({
+ onRenameClick,
+ onDuplicateClick,
+ onDeleteClick,
+ onInfoClick,
+}: {
+ onRenameClick: () => void;
+ onDuplicateClick: () => void;
+ onDeleteClick: () => void;
+ onInfoClick: () => void;
+}) {
+ return (
+ <ContextMenuContent>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Edit03Icon} />}
+ onClick={onRenameClick}
+ >
+ Rename
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ onClick={onDuplicateClick}
+ >
+ Duplicate
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={InformationCircleIcon} />}
+ onClick={onInfoClick}
+ >
+ Info
+ </ContextMenuItem>
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ variant="destructive"
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ onClick={onDeleteClick}
+ >
+ Delete
+ </ContextMenuItem>
+ </ContextMenuContent>
+ );
+}
+
+function ProjectMenu({
+ isOpen,
+ onOpenChange,
+ variant = "grid",
+ onRenameClick,
+ onDuplicateClick,
+ onDeleteClick,
+ onInfoClick,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ variant?: "grid" | "list";
+ onRenameClick: () => void;
+ onDuplicateClick: () => void;
+ onDeleteClick: () => void;
+ onInfoClick: () => void;
+}) {
+ const handleMenuClick = ({
+ event,
+ }: {
+ event: MouseEvent<HTMLButtonElement>;
+ }) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleMenuKeyDown = ({
+ event,
+ }: {
+ event: KeyboardEvent<HTMLButtonElement>;
+ }) => {
+ if (event.key !== "Enter" && event.key !== " ") {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleRename = () => {
+ onRenameClick();
+ onOpenChange(false);
+ };
+
+ const handleDuplicate = () => {
+ onDuplicateClick();
+ onOpenChange(false);
+ };
+
+ const handleDeleteClick = () => {
+ onDeleteClick();
+ onOpenChange(false);
+ };
+
+ const handleInfoClick = () => {
+ onInfoClick();
+ onOpenChange(false);
+ };
+
+ const isGrid = variant === "grid";
+
+ return (
+ <DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="background"
+ className={
+ isGrid
+ ? `absolute z-10 top-3 right-3 ${isOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`
+ : "!bg-transparent !shadow-none"
+ }
+ size="icon"
+ aria-label="Project menu"
+ onClick={(event) =>
+ handleMenuClick({
+ event: event as unknown as MouseEvent<HTMLButtonElement>,
+ })
+ }
+ onMouseDown={(event) => event.stopPropagation()}
+ onKeyDown={(event) =>
+ handleMenuKeyDown({
+ event: event as unknown as KeyboardEvent<HTMLButtonElement>,
+ })
+ }
+ >
+ <HugeiconsIcon
+ icon={MoreHorizontalIcon}
+ className="text-foreground"
+ aria-hidden="true"
+ />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="end">
+ <DropdownMenuItem onClick={handleRename}>
+ <HugeiconsIcon icon={Edit03Icon} />
+ Rename
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleDuplicate}>
+ <HugeiconsIcon icon={Copy01Icon} />
+ Duplicate
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleInfoClick}>
+ <HugeiconsIcon icon={InformationCircleIcon} />
+ Info
+ </DropdownMenuItem>
+ <DropdownMenuItem variant="destructive" onClick={handleDeleteClick}>
+ <HugeiconsIcon icon={Delete02Icon} />
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
+
+function ProjectsSkeleton() {
+ const skeletonIds = Array.from(
+ { length: 24 },
+ (_, index) => `skeleton-${index}`,
+ );
+
+ return (
+ <div className="px-4 xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
+ {skeletonIds.map((skeletonId) => (
+ <Card
+ key={skeletonId}
+ className="bg-background overflow-hidden border-none p-0"
+ >
+ <div className="bg-muted relative aspect-video">
+ <div className="absolute inset-0">
+ <Skeleton className="bg-muted/50 size-full" />
+ </div>
+ </div>
+ <CardContent className="flex flex-col gap-2 px-0 pt-4">
+ <Skeleton className="bg-muted/50 h-4 w-3/4" />
+ <div className="text-muted-foreground flex items-center gap-1.5">
+ <Skeleton className="bg-muted/50 size-4" />
+ <Skeleton className="bg-muted/50 h-4 w-24" />
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ );
+}
+
+function EmptyState() {
+ const { searchQuery, setSearchQuery } = useProjectsStore();
+ const router = useRouter();
+ const editor = useEditor();
+ const savedProjects = editor.project.getSavedProjects();
+
+ const handleCreateProject = async () => {
+ try {
+ const projectId = await editor.project.createNewProject({
+ name: "New project",
+ });
+ router.push(`/editor/${projectId}`);
+ } catch (error) {
+ toast.error("Failed to create project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ };
+
+ if (savedProjects.length > 0) {
+ return (
+ <div className="flex flex-col items-center justify-center gap-5 py-16 text-center">
+ <div className="flex flex-col items-center gap-8">
+ <HugeiconsIcon
+ icon={Search01Icon}
+ className="text-muted-foreground size-16 bg-accent/35 border rounded-md p-4"
+ />
+ <div className="flex flex-col items-center gap-3">
+ <h3 className="text-lg font-medium">No results found</h3>
+ <p className="text-muted-foreground max-w-md">
+ Your search for "{searchQuery}" did not return any results.
+ </p>
+ </div>
+ </div>
+ <Button
+ onClick={() => setSearchQuery({ query: "" })}
+ variant="outline"
+ size="lg"
+ >
+ Clear search
+ </Button>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
+ <div className="flex flex-col items-center gap-2">
+ <div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
+ <HugeiconsIcon
+ icon={Video01Icon}
+ className="text-muted-foreground size-8"
+ />
+ </div>
+ <h3 className="text-lg font-medium">No projects yet</h3>
+ <p className="text-muted-foreground max-w-md">
+ Start creating your first project. Import media, edit, and export your
+ videos. All privately.
+ </p>
+ </div>
+ <Button size="lg" className="gap-2" onClick={handleCreateProject}>
+ <HugeiconsIcon icon={PlusSignIcon} />
+ Create your first project
+ </Button>
+ </div>
+ );
+}
diff --git a/apps/web/src/app/projects/store.ts b/apps/web/src/app/projects/store.ts
index 2c79aa0a..71f89b4a 100644
--- a/apps/web/src/app/projects/store.ts
+++ b/apps/web/src/app/projects/store.ts
@@ -1,135 +1,135 @@
-import { create } from "zustand";
-import { persist } from "zustand/middleware";
-import type { TProjectSortKey } from "@/lib/project/types";
-
-export type ProjectsViewMode = "grid" | "list";
-
-interface ProjectsState {
- searchQuery: string;
- sortKey: TProjectSortKey;
- sortOrder: "asc" | "desc";
- viewMode: ProjectsViewMode;
- selectedProjectIds: string[];
- lastSelectedProjectId: string | null;
- isHydrated: boolean;
- setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
- setSearchQuery: ({ query }: { query: string }) => void;
- setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
- setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
- toggleSortOrder: () => void;
- setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
- setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
- clearSelectedProjects: () => void;
- setProjectSelected: ({
- projectId,
- isSelected,
- }: {
- projectId: string;
- isSelected: boolean;
- }) => void;
- selectProjectRange: ({
- projectId,
- allProjectIds,
- }: {
- projectId: string;
- allProjectIds: string[];
- }) => void;
-}
-
-const getNextSelectedProjectIds = ({
- selectedProjectIds,
- projectId,
- isSelected,
-}: {
- selectedProjectIds: string[];
- projectId: string;
- isSelected: boolean;
-}): string[] => {
- const selectedProjectIdSet = new Set(selectedProjectIds);
-
- if (isSelected) {
- selectedProjectIdSet.add(projectId);
- return Array.from(selectedProjectIdSet);
- }
-
- selectedProjectIdSet.delete(projectId);
- return Array.from(selectedProjectIdSet);
-};
-
-export const useProjectsStore = create<ProjectsState>()(
- persist(
- (set) => ({
- searchQuery: "",
- sortKey: "updatedAt",
- sortOrder: "desc",
- viewMode: "grid",
- selectedProjectIds: [],
- lastSelectedProjectId: null,
- isHydrated: false,
- setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
- setSearchQuery: ({ query }) => set({ searchQuery: query }),
- setSortKey: ({ sortKey }) => set({ sortKey }),
- setSortOrder: ({ sortOrder }) => set({ sortOrder }),
- toggleSortOrder: () =>
- set((state) => ({
- sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
- })),
- setViewMode: ({ viewMode }) => set({ viewMode }),
- setSelectedProjects: ({ projectIds }) =>
- set({ selectedProjectIds: projectIds }),
- clearSelectedProjects: () =>
- set({ selectedProjectIds: [], lastSelectedProjectId: null }),
- setProjectSelected: ({ projectId, isSelected }) =>
- set((state) => ({
- selectedProjectIds: getNextSelectedProjectIds({
- selectedProjectIds: state.selectedProjectIds,
- projectId,
- isSelected,
- }),
- lastSelectedProjectId: isSelected
- ? projectId
- : state.lastSelectedProjectId,
- })),
- selectProjectRange: ({ projectId, allProjectIds }) =>
- set((state) => {
- const anchorId = state.lastSelectedProjectId;
- if (!anchorId) {
- return {
- selectedProjectIds: [projectId],
- lastSelectedProjectId: projectId,
- };
- }
-
- const anchorIndex = allProjectIds.indexOf(anchorId);
- const targetIndex = allProjectIds.indexOf(projectId);
-
- if (anchorIndex === -1 || targetIndex === -1) {
- return {
- selectedProjectIds: [projectId],
- lastSelectedProjectId: projectId,
- };
- }
-
- const startIndex = Math.min(anchorIndex, targetIndex);
- const endIndex = Math.max(anchorIndex, targetIndex);
- const rangeIds = allProjectIds.slice(startIndex, endIndex + 1);
-
- const merged = new Set([...state.selectedProjectIds, ...rangeIds]);
- return {
- selectedProjectIds: Array.from(merged),
- };
- }),
- }),
- {
- name: "projects-view-mode",
- partialize: (state) => ({
- viewMode: state.viewMode,
- sortKey: state.sortKey,
- sortOrder: state.sortOrder,
- }),
- onRehydrateStorage: () => (state) => {
- state?.setIsHydrated({ isHydrated: true });
- },
- },
- ),
-);
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import type { TProjectSortKey } from "@/lib/project/types";
+
+export type ProjectsViewMode = "grid" | "list";
+
+interface ProjectsState {
+ searchQuery: string;
+ sortKey: TProjectSortKey;
+ sortOrder: "asc" | "desc";
+ viewMode: ProjectsViewMode;
+ selectedProjectIds: string[];
+ lastSelectedProjectId: string | null;
+ isHydrated: boolean;
+ setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
+ setSearchQuery: ({ query }: { query: string }) => void;
+ setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
+ setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
+ toggleSortOrder: () => void;
+ setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
+ setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
+ clearSelectedProjects: () => void;
+ setProjectSelected: ({
+ projectId,
+ isSelected,
+ }: {
+ projectId: string;
+ isSelected: boolean;
+ }) => void;
+ selectProjectRange: ({
+ projectId,
+ allProjectIds,
+ }: {
+ projectId: string;
+ allProjectIds: string[];
+ }) => void;
+}
+
+const getNextSelectedProjectIds = ({
+ selectedProjectIds,
+ projectId,
+ isSelected,
+}: {
+ selectedProjectIds: string[];
+ projectId: string;
+ isSelected: boolean;
+}): string[] => {
+ const selectedProjectIdSet = new Set(selectedProjectIds);
+
+ if (isSelected) {
+ selectedProjectIdSet.add(projectId);
+ return Array.from(selectedProjectIdSet);
+ }
+
+ selectedProjectIdSet.delete(projectId);
+ return Array.from(selectedProjectIdSet);
+};
+
+export const useProjectsStore = create<ProjectsState>()(
+ persist(
+ (set) => ({
+ searchQuery: "",
+ sortKey: "updatedAt",
+ sortOrder: "desc",
+ viewMode: "grid",
+ selectedProjectIds: [],
+ lastSelectedProjectId: null,
+ isHydrated: false,
+ setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
+ setSearchQuery: ({ query }) => set({ searchQuery: query }),
+ setSortKey: ({ sortKey }) => set({ sortKey }),
+ setSortOrder: ({ sortOrder }) => set({ sortOrder }),
+ toggleSortOrder: () =>
+ set((state) => ({
+ sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
+ })),
+ setViewMode: ({ viewMode }) => set({ viewMode }),
+ setSelectedProjects: ({ projectIds }) =>
+ set({ selectedProjectIds: projectIds }),
+ clearSelectedProjects: () =>
+ set({ selectedProjectIds: [], lastSelectedProjectId: null }),
+ setProjectSelected: ({ projectId, isSelected }) =>
+ set((state) => ({
+ selectedProjectIds: getNextSelectedProjectIds({
+ selectedProjectIds: state.selectedProjectIds,
+ projectId,
+ isSelected,
+ }),
+ lastSelectedProjectId: isSelected
+ ? projectId
+ : state.lastSelectedProjectId,
+ })),
+ selectProjectRange: ({ projectId, allProjectIds }) =>
+ set((state) => {
+ const anchorId = state.lastSelectedProjectId;
+ if (!anchorId) {
+ return {
+ selectedProjectIds: [projectId],
+ lastSelectedProjectId: projectId,
+ };
+ }
+
+ const anchorIndex = allProjectIds.indexOf(anchorId);
+ const targetIndex = allProjectIds.indexOf(projectId);
+
+ if (anchorIndex === -1 || targetIndex === -1) {
+ return {
+ selectedProjectIds: [projectId],
+ lastSelectedProjectId: projectId,
+ };
+ }
+
+ const startIndex = Math.min(anchorIndex, targetIndex);
+ const endIndex = Math.max(anchorIndex, targetIndex);
+ const rangeIds = allProjectIds.slice(startIndex, endIndex + 1);
+
+ const merged = new Set([...state.selectedProjectIds, ...rangeIds]);
+ return {
+ selectedProjectIds: Array.from(merged),
+ };
+ }),
+ }),
+ {
+ name: "projects-view-mode",
+ partialize: (state) => ({
+ viewMode: state.viewMode,
+ sortKey: state.sortKey,
+ sortOrder: state.sortOrder,
+ }),
+ onRehydrateStorage: () => (state) => {
+ state?.setIsHydrated({ isHydrated: true });
+ },
+ },
+ ),
+);
diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx
index 59c36d78..a0c3d9be 100644
--- a/apps/web/src/app/roadmap/page.tsx
+++ b/apps/web/src/app/roadmap/page.tsx
@@ -1,146 +1,146 @@
-import type { Metadata } from "next";
-import { BasePage } from "@/app/base-page";
-import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
-import { Badge } from "@/components/ui/badge";
-import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
-import { cn } from "@/utils/ui";
-
-const LAST_UPDATED = "February 25, 2026";
-
-type StatusType = "complete" | "pending" | "default" | "info";
-
-interface Status {
- text: string;
- type: StatusType;
-}
-
-interface RoadmapItem {
- title: string;
- description: string;
- status: Status;
-}
-
-const roadmapItems: RoadmapItem[] = [
- {
- title: "Start",
- description:
- "This is where it all started. Repository created, initial project structure, and the vision for a free, open-source video editor. [Check out the first tweet](https://x.com/mazeincoding/status/1936706642512388188) to see where it started.",
- status: {
- text: "Completed",
- type: "complete",
- },
- },
- {
- title: "Core UI",
- description:
- "Build the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
- status: {
- text: "Completed",
- type: "complete",
- },
- },
- {
- title: "Essential functionality",
- description:
- "Everything that makes a video editor **useful**. Timeline interactivity, storage, effects, transitions, etc.",
- status: {
- text: "In progress",
- type: "pending",
- },
- },
- {
- title: "Native app (mobile/desktop)",
- description:
- "Native OpenCut apps for Mac, Windows, Linux, and iOS/Android.",
- status: {
- text: "Not started",
- type: "default",
- },
- },
-];
-
-export const metadata: Metadata = {
- title: "Roadmap - OpenCut",
- description:
- "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
- openGraph: {
- title: "OpenCut Roadmap - What's Coming Next",
- description:
- "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
- type: "website",
- images: [
- {
- url: "/open-graph/roadmap.jpg",
- width: 1200,
- height: 630,
- alt: "OpenCut Roadmap",
- },
- ],
- },
- twitter: {
- card: "summary_large_image",
- title: "OpenCut Roadmap - What's Coming Next",
- description:
- "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
- images: ["/open-graph/roadmap.jpg"],
- },
-};
-
-export default function RoadmapPage() {
- return (
- <BasePage
- title="Roadmap"
- description={`What's coming next for OpenCut (last updated: ${LAST_UPDATED})`}
- >
- <div className="mx-auto flex max-w-4xl flex-col gap-16">
- <div className="flex flex-col gap-6">
- {roadmapItems.map((item, index) => (
- <RoadmapItem key={item.title} item={item} index={index} />
- ))}
- </div>
- <GitHubContributeSection
- title="Want to help?"
- description="OpenCut is open source and built by the community. Every contribution,
- no matter how small, helps us build the best free video editor
- possible."
- />
- </div>
- </BasePage>
- );
-}
-
-function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
- return (
- <div className="flex flex-col gap-2">
- <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg font-medium">
- <span className="leading-normal select-none">{index + 1}</span>
- <h3>{item.title}</h3>
- <StatusBadge status={item.status} className="ml-1" />
- </div>
- <div className="text-foreground/70 leading-relaxed">
- <ReactMarkdownWrapper>{item.description}</ReactMarkdownWrapper>
- </div>
- </div>
- );
-}
-
-function StatusBadge({
- status,
- className,
-}: {
- status: Status;
- className?: string;
-}) {
- return (
- <Badge
- className={cn("shadow-none", className, {
- "bg-green-500! text-white": status.type === "complete",
- "bg-yellow-500! text-white": status.type === "pending",
- "bg-blue-500! text-white": status.type === "info",
- "bg-foreground/10! text-accent-foreground": status.type === "default",
- })}
- >
- {status.text}
- </Badge>
- );
-}
+import type { Metadata } from "next";
+import { BasePage } from "@/app/base-page";
+import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
+import { Badge } from "@/components/ui/badge";
+import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
+import { cn } from "@/utils/ui";
+
+const LAST_UPDATED = "February 25, 2026";
+
+type StatusType = "complete" | "pending" | "default" | "info";
+
+interface Status {
+ text: string;
+ type: StatusType;
+}
+
+interface RoadmapItem {
+ title: string;
+ description: string;
+ status: Status;
+}
+
+const roadmapItems: RoadmapItem[] = [
+ {
+ title: "Start",
+ description:
+ "This is where it all started. Repository created, initial project structure, and the vision for a free, open-source video editor. [Check out the first tweet](https://x.com/mazeincoding/status/1936706642512388188) to see where it started.",
+ status: {
+ text: "Completed",
+ type: "complete",
+ },
+ },
+ {
+ title: "Core UI",
+ description:
+ "Build the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
+ status: {
+ text: "Completed",
+ type: "complete",
+ },
+ },
+ {
+ title: "Essential functionality",
+ description:
+ "Everything that makes a video editor **useful**. Timeline interactivity, storage, effects, transitions, etc.",
+ status: {
+ text: "In progress",
+ type: "pending",
+ },
+ },
+ {
+ title: "Native app (mobile/desktop)",
+ description:
+ "Native OpenCut apps for Mac, Windows, Linux, and iOS/Android.",
+ status: {
+ text: "Not started",
+ type: "default",
+ },
+ },
+];
+
+export const metadata: Metadata = {
+ title: "Roadmap - OpenCut",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ openGraph: {
+ title: "OpenCut Roadmap - What's Coming Next",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ type: "website",
+ images: [
+ {
+ url: "/open-graph/roadmap.jpg",
+ width: 1200,
+ height: 630,
+ alt: "OpenCut Roadmap",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "OpenCut Roadmap - What's Coming Next",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ images: ["/open-graph/roadmap.jpg"],
+ },
+};
+
+export default function RoadmapPage() {
+ return (
+ <BasePage
+ title="Roadmap"
+ description={`What's coming next for OpenCut (last updated: ${LAST_UPDATED})`}
+ >
+ <div className="mx-auto flex max-w-4xl flex-col gap-16">
+ <div className="flex flex-col gap-6">
+ {roadmapItems.map((item, index) => (
+ <RoadmapItem key={item.title} item={item} index={index} />
+ ))}
+ </div>
+ <GitHubContributeSection
+ title="Want to help?"
+ description="OpenCut is open source and built by the community. Every contribution,
+ no matter how small, helps us build the best free video editor
+ possible."
+ />
+ </div>
+ </BasePage>
+ );
+}
+
+function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
+ return (
+ <div className="flex flex-col gap-2">
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg font-medium">
+ <span className="leading-normal select-none">{index + 1}</span>
+ <h3>{item.title}</h3>
+ <StatusBadge status={item.status} className="ml-1" />
+ </div>
+ <div className="text-foreground/70 leading-relaxed">
+ <ReactMarkdownWrapper>{item.description}</ReactMarkdownWrapper>
+ </div>
+ </div>
+ );
+}
+
+function StatusBadge({
+ status,
+ className,
+}: {
+ status: Status;
+ className?: string;
+}) {
+ return (
+ <Badge
+ className={cn("shadow-none", className, {
+ "bg-green-500! text-white": status.type === "complete",
+ "bg-yellow-500! text-white": status.type === "pending",
+ "bg-blue-500! text-white": status.type === "info",
+ "bg-foreground/10! text-accent-foreground": status.type === "default",
+ })}
+ >
+ {status.text}
+ </Badge>
+ );
+}
diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts
index 60c13829..c3e80a6a 100644
--- a/apps/web/src/app/robots.ts
+++ b/apps/web/src/app/robots.ts
@@ -1,13 +1,13 @@
-import type { MetadataRoute } from "next";
-import { SITE_URL } from "@/constants/site-constants";
-
-export default function robots(): MetadataRoute.Robots {
- return {
- rules: {
- userAgent: "*",
- allow: "/",
- disallow: ["/_next/", "/projects/", "/editor/"],
- },
- sitemap: `${SITE_URL}/sitemap.xml`,
- };
-}
+import type { MetadataRoute } from "next";
+import { SITE_URL } from "@/constants/site-constants";
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: {
+ userAgent: "*",
+ allow: "/",
+ disallow: ["/_next/", "/projects/", "/editor/"],
+ },
+ sitemap: `${SITE_URL}/sitemap.xml`,
+ };
+}
diff --git a/apps/web/src/app/rss.xml/route.ts b/apps/web/src/app/rss.xml/route.ts
index 909d5a1a..9cfad635 100644
--- a/apps/web/src/app/rss.xml/route.ts
+++ b/apps/web/src/app/rss.xml/route.ts
@@ -1,46 +1,46 @@
-import { Feed } from "feed";
-import { getPosts } from "@/lib/blog/query";
-import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
-
-export async function GET() {
- try {
- const { posts } = await getPosts();
-
- const feed = new Feed({
- title: `${SITE_INFO.title} Blog`,
- description: SITE_INFO.description,
- id: `${SITE_URL}`,
- link: `${SITE_URL}/blog/`,
- language: "en",
- image: `${SITE_INFO.openGraphImage}`,
- favicon: `${SITE_INFO.favicon}`,
- copyright: `All rights reserved ${new Date().getFullYear()}, ${
- SITE_INFO.title
- }`,
- });
-
- for (const post of posts) {
- feed.addItem({
- title: post.title,
- id: `${SITE_URL}/blog/${post.slug}`,
- link: `${SITE_URL}/blog/${post.slug}`,
- description: post.description,
- author: post.authors.map((author) => ({
- name: author.name,
- })),
- date: new Date(post.publishedAt),
- image: post.coverImage || SITE_INFO.openGraphImage,
- });
- }
-
- return new Response(feed.rss2(), {
- headers: {
- "Content-Type": "text/xml",
- "Cache-Control": "public, max-age=86400, stale-while-revalidate",
- },
- });
- } catch (error) {
- console.error("Error generating RSS feed", error);
- return new Response("Internal Server Error", { status: 500 });
- }
-}
+import { Feed } from "feed";
+import { getPosts } from "@/lib/blog/query";
+import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
+
+export async function GET() {
+ try {
+ const { posts } = await getPosts();
+
+ const feed = new Feed({
+ title: `${SITE_INFO.title} Blog`,
+ description: SITE_INFO.description,
+ id: `${SITE_URL}`,
+ link: `${SITE_URL}/blog/`,
+ language: "en",
+ image: `${SITE_INFO.openGraphImage}`,
+ favicon: `${SITE_INFO.favicon}`,
+ copyright: `All rights reserved ${new Date().getFullYear()}, ${
+ SITE_INFO.title
+ }`,
+ });
+
+ for (const post of posts) {
+ feed.addItem({
+ title: post.title,
+ id: `${SITE_URL}/blog/${post.slug}`,
+ link: `${SITE_URL}/blog/${post.slug}`,
+ description: post.description,
+ author: post.authors.map((author) => ({
+ name: author.name,
+ })),
+ date: new Date(post.publishedAt),
+ image: post.coverImage || SITE_INFO.openGraphImage,
+ });
+ }
+
+ return new Response(feed.rss2(), {
+ headers: {
+ "Content-Type": "text/xml",
+ "Cache-Control": "public, max-age=86400, stale-while-revalidate",
+ },
+ });
+ } catch (error) {
+ console.error("Error generating RSS feed", error);
+ return new Response("Internal Server Error", { status: 500 });
+ }
+}
diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts
index fed1aed6..2dea655d 100644
--- a/apps/web/src/app/sitemap.ts
+++ b/apps/web/src/app/sitemap.ts
@@ -1,61 +1,61 @@
-import { SITE_URL } from "@/constants/site-constants";
-import { getPosts } from "@/lib/blog/query";
-import type { MetadataRoute } from "next";
-
-export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
- const data = await getPosts();
-
- const postPages: MetadataRoute.Sitemap =
- data?.posts?.map((post) => ({
- url: `${SITE_URL}/blog/${post.slug}`,
- lastModified: new Date(post.publishedAt),
- changeFrequency: "weekly",
- priority: 0.8,
- })) ?? [];
-
- return [
- {
- url: SITE_URL,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/contributors`,
- lastModified: new Date(),
- changeFrequency: "daily",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/roadmap`,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/privacy`,
- lastModified: new Date(),
- changeFrequency: "monthly",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/terms`,
- lastModified: new Date(),
- changeFrequency: "monthly",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/why-not-capcut`,
- lastModified: new Date(),
- changeFrequency: "yearly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/blog`,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- ...postPages,
- ];
-}
+import { SITE_URL } from "@/constants/site-constants";
+import { getPosts } from "@/lib/blog/query";
+import type { MetadataRoute } from "next";
+
+export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
+ const data = await getPosts();
+
+ const postPages: MetadataRoute.Sitemap =
+ data?.posts?.map((post) => ({
+ url: `${SITE_URL}/blog/${post.slug}`,
+ lastModified: new Date(post.publishedAt),
+ changeFrequency: "weekly",
+ priority: 0.8,
+ })) ?? [];
+
+ return [
+ {
+ url: SITE_URL,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/contributors`,
+ lastModified: new Date(),
+ changeFrequency: "daily",
+ priority: 0.5,
+ },
+ {
+ url: `${SITE_URL}/roadmap`,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/privacy`,
+ lastModified: new Date(),
+ changeFrequency: "monthly",
+ priority: 0.5,
+ },
+ {
+ url: `${SITE_URL}/terms`,
+ lastModified: new Date(),
+ changeFrequency: "monthly",
+ priority: 0.5,
+ },
+ {
+ url: `${SITE_URL}/why-not-capcut`,
+ lastModified: new Date(),
+ changeFrequency: "yearly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/blog`,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ ...postPages,
+ ];
+}
diff --git a/apps/web/src/app/sponsors/page.tsx b/apps/web/src/app/sponsors/page.tsx
index a1a57fce..64f18776 100644
--- a/apps/web/src/app/sponsors/page.tsx
+++ b/apps/web/src/app/sponsors/page.tsx
@@ -1,86 +1,86 @@
-import type { Metadata } from "next";
-import Image from "next/image";
-import Link from "next/link";
-import { BasePage } from "@/app/base-page";
-import { Card, CardContent } from "@/components/ui/card";
-import { SPONSORS, type Sponsor } from "@/constants/site-constants";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-
-export const metadata: Metadata = {
- title: "Sponsors - OpenCut",
- description:
- "Support OpenCut and help us build the future of free and open-source video editing.",
- openGraph: {
- title: "Sponsors - OpenCut",
- description:
- "Support OpenCut and help us build the future of free and open-source video editing.",
- type: "website",
- },
-};
-
-export default function SponsorsPage() {
- return (
- <BasePage>
- <div className="flex flex-col gap-8 text-center">
- <h1 className="text-5xl font-bold tracking-tight md:text-6xl">
- Sponsors
- </h1>
- <p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed text-pretty">
- Support OpenCut and help us build the future of privacy-first video
- editing.
- </p>
- </div>
- <SponsorsGrid />
- </BasePage>
- );
-}
-
-function SponsorsGrid() {
- return (
- <div className="grid gap-6 sm:grid-cols-2">
- {SPONSORS.map((sponsor) => (
- <SponsorCard key={sponsor.name} sponsor={sponsor} />
- ))}
- </div>
- );
-}
-
-function SponsorCard({ sponsor }: { sponsor: Sponsor }) {
- return (
- <Link
- href={sponsor.url}
- target="_blank"
- rel="noopener noreferrer"
- className="size-full"
- >
- <Card className="h-full">
- <CardContent className="flex h-full flex-col justify-center gap-8 p-8">
- <Image
- src={sponsor.logo}
- alt={`${sponsor.name} logo`}
- width={50}
- height={50}
- className={cn(
- "object-contain",
- sponsor.invertOnDark && "invert-0 dark:invert",
- )}
- />
- <div className="flex flex-col gap-2">
- <div className="flex items-center gap-2">
- <h3 className="text-xl font-semibold group-hover:underline">
- {sponsor.name}
- </h3>
- <HugeiconsIcon
- icon={LinkSquare02Icon}
- className="text-muted-foreground size-4"
- />
- </div>
- <p className="text-muted-foreground">{sponsor.description}</p>
- </div>
- </CardContent>
- </Card>
- </Link>
- );
-}
+import type { Metadata } from "next";
+import Image from "next/image";
+import Link from "next/link";
+import { BasePage } from "@/app/base-page";
+import { Card, CardContent } from "@/components/ui/card";
+import { SPONSORS, type Sponsor } from "@/constants/site-constants";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+export const metadata: Metadata = {
+ title: "Sponsors - OpenCut",
+ description:
+ "Support OpenCut and help us build the future of free and open-source video editing.",
+ openGraph: {
+ title: "Sponsors - OpenCut",
+ description:
+ "Support OpenCut and help us build the future of free and open-source video editing.",
+ type: "website",
+ },
+};
+
+export default function SponsorsPage() {
+ return (
+ <BasePage>
+ <div className="flex flex-col gap-8 text-center">
+ <h1 className="text-5xl font-bold tracking-tight md:text-6xl">
+ Sponsors
+ </h1>
+ <p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed text-pretty">
+ Support OpenCut and help us build the future of privacy-first video
+ editing.
+ </p>
+ </div>
+ <SponsorsGrid />
+ </BasePage>
+ );
+}
+
+function SponsorsGrid() {
+ return (
+ <div className="grid gap-6 sm:grid-cols-2">
+ {SPONSORS.map((sponsor) => (
+ <SponsorCard key={sponsor.name} sponsor={sponsor} />
+ ))}
+ </div>
+ );
+}
+
+function SponsorCard({ sponsor }: { sponsor: Sponsor }) {
+ return (
+ <Link
+ href={sponsor.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="size-full"
+ >
+ <Card className="h-full">
+ <CardContent className="flex h-full flex-col justify-center gap-8 p-8">
+ <Image
+ src={sponsor.logo}
+ alt={`${sponsor.name} logo`}
+ width={50}
+ height={50}
+ className={cn(
+ "object-contain",
+ sponsor.invertOnDark && "invert-0 dark:invert",
+ )}
+ />
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center gap-2">
+ <h3 className="text-xl font-semibold group-hover:underline">
+ {sponsor.name}
+ </h3>
+ <HugeiconsIcon
+ icon={LinkSquare02Icon}
+ className="text-muted-foreground size-4"
+ />
+ </div>
+ <p className="text-muted-foreground">{sponsor.description}</p>
+ </div>
+ </CardContent>
+ </Card>
+ </Link>
+ );
+}