diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-27 00:55:15 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:56:48 +0200 |
| commit | 4d127a038bf768eded68aae3cfa5c1b12241bdac (patch) | |
| tree | 16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/src/app | |
| parent | 82817cb50cb0487350573d6129130aec705f924b (diff) | |
| download | opencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz | |
chore: normalize line endings
Diffstat (limited to 'apps/web/src/app')
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>
+ );
+}
|
