lots of stuff

This commit is contained in:
Maze Winther
2026-01-23 15:40:01 +01:00
parent b1701e1b0e
commit c6deceaa89
292 changed files with 28825 additions and 26457 deletions
+2 -4
View File
@@ -1,5 +1,3 @@
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
return new Response("OK", { status: 200 });
export async function GET() {
return new Response("OK", { status: 200 });
}
+228 -228
View File
@@ -1,280 +1,280 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
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),
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(),
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),
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(),
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(),
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`;
if (!query) return `${sort}_desc`;
return sort === "score" ? "score" : `${sort}_desc`;
}
function applyEffectsFilters({
params,
min_rating,
commercial_only,
params,
min_rating,
commercial_only,
}: {
params: URLSearchParams;
min_rating: number;
commercial_only: boolean;
params: URLSearchParams;
min_rating: number;
commercial_only: boolean;
}) {
params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`);
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")',
);
}
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",
);
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>,
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,
};
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 });
}
try {
const { limited } = await checkRateLimit({ request });
if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const { searchParams } = new URL(request.url);
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,
});
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 },
);
}
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;
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 },
);
}
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 baseUrl = "https://freesound.org/apiv2/search/text/";
const sortParam = buildSortParameter({ query, sort });
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 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 isEffectsSearch = type === "effects" || !type;
if (isEffectsSearch) {
applyEffectsFilters({ params, min_rating, commercial_only });
}
const response = await fetch(`${baseUrl}?${params.toString()}`);
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 },
);
}
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 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 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 data = freesoundValidation.data;
const transformedResults = data.results.map(transformFreesoundResult);
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 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 },
);
}
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 },
);
}
return NextResponse.json(responseValidation.data);
} catch (error) {
console.error("Error searching sounds:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+42 -42
View File
@@ -3,51 +3,51 @@ 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?: string;
children: React.ReactNode;
className?: string;
mainClassName?: string;
maxWidth?: "3xl" | "6xl" | "full";
title?: string;
description?: string;
}
export function BasePage({
children,
className = "",
mainClassName = "",
maxWidth = "3xl",
title,
description,
children,
className = "",
mainClassName = "",
maxWidth = "3xl",
title,
description,
}: BasePageProps) {
const maxWidthClass = {
"3xl": "max-w-3xl",
"6xl": "max-w-6xl",
full: "max-w-full",
}[maxWidth];
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>
</div>
)}
{children}
</main>
<Footer />
</section>
);
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>
</div>
)}
{children}
</main>
<Footer />
</section>
);
}
+109 -109
View File
@@ -1,152 +1,152 @@
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 { Post, Author } from "@/types/blog";
import { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import type { Author, Post } from "@/types/blog";
type PageProps = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
export async function generateMetadata({
params,
params,
}: PageProps): Promise<Metadata> {
const slug = (await params).slug;
const slug = (await params).slug;
const data = await getSinglePost({ slug });
const data = await getSinglePost({ slug });
if (!data || !data.post) return {};
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),
},
};
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 [];
const data = await getPosts();
if (!data || !data.posts.length) return [];
return data.posts.map((post) => ({
slug: post.slug,
}));
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 slug = (await params).slug;
const data = await getSinglePost({ slug });
if (!data || !data.post) return notFound();
const html = await processHtmlContent({ html: data.post.content });
const html = await processHtmlContent({ html: data.post.content });
return (
<BasePage>
<PostHeader post={data.post} />
<Separator />
<PostContent html={html} />
</BasePage>
);
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",
});
const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
day: "numeric",
month: "long",
year: "numeric",
});
return (
<>
{post.coverImage && <PostCoverImage post={post} />}
<PostMeta date={formattedDate} publishedAt={post.publishedAt} />
<PostTitle title={post.title} />
<PostAuthor author={post.authors[0]} />
</>
);
return (
<>
{post.coverImage && <PostCoverImage post={post} />}
<PostMeta date={formattedDate} publishedAt={post.publishedAt} />
<PostTitle title={post.title} />
<PostAuthor author={post.authors[0]} />
</>
);
}
function PostCoverImage({ post }: { post: Post }) {
return (
<div className="relative aspect-video overflow-hidden rounded-lg">
<Image
src={post.coverImage}
alt={post.title}
loading="eager"
fill
className="rounded-lg object-cover"
/>
</div>
);
return (
<div className="relative aspect-video overflow-hidden rounded-lg">
<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>
);
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">{title}</h1>
);
return (
<h1 className="text-5xl font-bold tracking-tight md:text-4xl">{title}</h1>
);
}
function PostAuthor({ author }: { author?: Author }) {
if (!author) return null;
if (!author) return null;
return (
<div className="flex items-center justify-center gap-2">
<Image
src={author.image}
alt={author.name}
width={36}
height={36}
loading="eager"
className="aspect-square size-8 shrink-0 rounded-full"
/>
<p className="text-muted-foreground">{author.name}</p>
</div>
);
return (
<div className="flex items-center justify-center gap-2">
<Image
src={author.image}
alt={author.name}
width={36}
height={36}
loading="eager"
className="aspect-square size-8 shrink-0 rounded-full"
/>
<p className="text-muted-foreground">{author.name}</p>
</div>
);
}
function PostContent({ html }: { html: string }) {
return (
<section className="pt-8">
<Prose html={html} />
</section>
);
return (
<section className="pt-8">
<Prose html={html} />
</section>
);
}
+57 -57
View File
@@ -1,73 +1,73 @@
import { Metadata } from "next";
import type { Metadata } from "next";
import Link from "next/link";
import { BasePage } from "@/app/base-page";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Link from "next/link";
import { getPosts } from "@/lib/blog/query";
import { Post, Author } from "@/types/blog";
import { Separator } from "@/components/ui/separator";
import { getPosts } from "@/lib/blog/query";
import type { Author, Post } from "@/types/blog";
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",
},
title: "Blog - OpenCut",
description:
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
openGraph: {
title: "Blog - OpenCut",
description:
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
type: "website",
},
};
export default async function BlogPage() {
const data = await getPosts();
if (!data || !data.posts) return <div>No posts yet</div>;
const data = await getPosts();
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>
);
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 transition-opacity 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>
{post.authors && post.authors.length > 0 && (
<AuthorList authors={post.authors} />
)}
</div>
</Link>
);
return (
<Link href={`/blog/${post.slug}`}>
<div className="flex h-auto w-full items-center justify-between py-6 opacity-100 transition-opacity 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>
{post.authors && post.authors.length > 0 && (
<AuthorList authors={post.authors} />
)}
</div>
</Link>
);
}
function AuthorList({ authors }: { authors: Author[] }) {
return (
<div className="flex items-center gap-2">
{authors.map((author) => (
<div key={author.id} className="flex items-center gap-2">
<Avatar className="h-6 w-6 shadow-sm">
<AvatarImage src={author.image} alt={author.name} />
<AvatarFallback className="text-xs">
{author.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
))}
</div>
);
return (
<div className="flex items-center gap-2">
{authors.map((author) => (
<div key={author.id} className="flex items-center gap-2">
<Avatar className="h-6 w-6 shadow-sm">
<AvatarImage src={author.image} alt={author.name} />
<AvatarFallback className="text-xs">
{author.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
))}
</div>
);
}
+231 -231
View File
@@ -1,278 +1,278 @@
import { Metadata } from "next";
import { Card, CardContent } from "@/components/ui/card";
import { GithubIcon } from "@opencut/ui/icons";
import { ExternalLink } from "lucide-react";
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 { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { GithubIcon } from "@opencut/ui/icons";
import { Card, CardContent } from "@/components/ui/card";
import { EXTERNAL_TOOLS, SOCIAL_LINKS } from "@/constants/site-constants";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
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",
},
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;
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
},
);
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 [];
}
if (!response.ok) {
console.error("Failed to fetch contributors");
return [];
}
const contributors = (await response.json()) as Contributor[];
const contributors = (await response.json()) as Contributor[];
const filteredContributors = contributors.filter(
(contributor) => contributor.type === "User",
);
const filteredContributors = contributors.filter(
(contributor) => contributor.type === "User",
);
return filteredContributors;
} catch (error) {
console.error("Error fetching contributors:", error);
return [];
}
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,
);
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>
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} />
)}
{contributors.length === 0 && <EmptyState />}
<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>
);
<div className="mx-auto flex max-w-6xl flex-col gap-20">
{topContributors.length > 0 && (
<TopContributorsSection contributors={topContributors} />
)}
{otherContributors.length > 0 && (
<AllContributorsSection contributors={otherContributors} />
)}
{contributors.length === 0 && <EmptyState />}
<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 h-2 w-2 rounded-full" />
<span className="font-medium">{value}</span>
<span className="text-muted-foreground">{label}</span>
</div>
);
return (
<div className="flex items-center gap-2">
<div className="bg-foreground h-2 w-2 rounded-full" />
<span className="font-medium">{value}</span>
<span className="text-muted-foreground">{label}</span>
</div>
);
}
function TopContributorsSection({
contributors,
contributors,
}: {
contributors: Contributor[];
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>
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>
);
<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>
);
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,
}: {
contributors: Contributor[];
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>
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, index) => (
<Link
key={contributor.id}
href={contributor.html_url}
target="_blank"
rel="noopener noreferrer"
className="opacity-100 transition-opacity hover:opacity-70"
>
<div className="flex flex-col items-center gap-2 p-2">
<Avatar className="h-16 w-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>
);
<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 transition-opacity hover:opacity-70"
>
<div className="flex flex-col items-center gap-2 p-2">
<Avatar className="h-16 w-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 EmptyState() {
return (
<div className="flex flex-col gap-8 py-20 text-center">
<div className="flex flex-col gap-6">
<div className="bg-muted/50 mx-auto flex h-20 w-20 items-center justify-center rounded-full">
<GithubIcon className="text-muted-foreground h-8 w-8" />
</div>
<div className="flex flex-col gap-3">
<h3 className="text-xl font-medium">No contributors found</h3>
<p className="text-muted-foreground mx-auto max-w-md">
Unable to load contributors at the moment. Check back later or view
on GitHub.
</p>
</div>
</div>
<Link
href={`${SOCIAL_LINKS.github}/graphs/contributors`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" className="gap-2">
<GithubIcon className="h-4 w-4" />
View on GitHub
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</div>
);
return (
<div className="flex flex-col gap-8 py-20 text-center">
<div className="flex flex-col gap-6">
<div className="bg-muted/50 mx-auto flex h-20 w-20 items-center justify-center rounded-full">
<GithubIcon className="text-muted-foreground h-8 w-8" />
</div>
<div className="flex flex-col gap-3">
<h3 className="text-xl font-medium">No contributors found</h3>
<p className="text-muted-foreground mx-auto max-w-md">
Unable to load contributors at the moment. Check back later or view
on GitHub.
</p>
</div>
</div>
<Link
href={`${SOCIAL_LINKS.github}/graphs/contributors`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" className="gap-2">
<GithubIcon className="h-4 w-4" />
View on GitHub
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</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>
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 md:grid-cols-3">
{EXTERNAL_TOOLS.map((tool, index) => (
<Link
key={index}
href={tool.url}
target="_blank"
className="block"
style={{ animationDelay: `${index * 100}ms` }}
>
<Card className="h-full">
<CardContent className="flex h-full flex-col gap-4 p-6 text-center">
<div className="bg-muted/50 mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<tool.icon className="h-6 w-6" />
</div>
<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>
);
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
{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 h-full flex-col gap-4 p-6 text-center">
<div className="bg-muted/50 mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<tool.icon className="h-6 w-6" />
</div>
<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>
);
}
+80 -80
View File
@@ -2,9 +2,9 @@
import { useParams } from "next/navigation";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { AssetsPanel } from "@/components/editor/assets-panel";
import { PropertiesPanel } from "@/components/editor/properties-panel";
@@ -17,92 +17,92 @@ import { MigrationDialog } from "@/components/editor/migration-dialog";
import { usePanelStore } from "@/stores/panel-store";
export default function Editor() {
const params = useParams();
const projectId = params.project_id as string;
const params = useParams();
const projectId = params.project_id as string;
return (
<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>
);
return (
<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>
);
}
function EditorLayout() {
const { panels, setPanel } = usePanelStore();
const { panels, setPanel } = usePanelStore();
return (
<ResizablePanelGroup
direction="vertical"
className="h-full w-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="h-full w-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 rounded-sm"
>
<AssetsPanel />
</ResizablePanel>
return (
<ResizablePanelGroup
direction="vertical"
className="h-full w-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="h-full w-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 rounded-sm"
>
<AssetsPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={panels.preview}
minSize={30}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizablePanel
defaultSize={panels.preview}
minSize={30}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={panels.properties}
minSize={15}
maxSize={40}
className="min-w-0 rounded-sm"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizablePanel
defaultSize={panels.properties}
minSize={15}
maxSize={40}
className="min-w-0 rounded-sm"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={panels.timeline}
minSize={15}
maxSize={70}
className="min-h-0 px-3 pb-3"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
);
<ResizablePanel
defaultSize={panels.timeline}
minSize={15}
maxSize={70}
className="min-h-0 px-3 pb-3"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
);
}
+203 -191
View File
@@ -8,86 +8,89 @@
@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-foreground: hsl(0 0% 2%);
--primary: hsl(205, 84%, 47%);
--primary-foreground: hsl(0 0% 91%);
--secondary: hsl(216, 13%, 92%);
--secondary-foreground: hsl(0 0% 2%);
--muted: hsl(0 0% 85.1%);
--muted-foreground: hsl(0 0% 50%);
--accent: hsl(216, 13%, 92%);
--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% 83%);
--input: hsl(0 0% 85.1%);
--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%);
--panel-background: hsl(216 13% 92%);
--panel-accent: hsl(216, 8%, 86%);
--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-foreground: hsl(0 0% 2%);
--primary: hsl(210, 91%, 49%);
--primary-foreground: hsl(0 0% 91%);
--secondary: hsl(216, 13%, 92%);
--secondary-foreground: hsl(0 0% 2%);
--muted: hsl(0 0% 85.1%);
--muted-foreground: hsl(0 0% 50%);
--accent: hsl(216, 13%, 92%);
--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% 83%);
--input: hsl(0 0% 85.1%);
--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%);
--panel-background: hsl(216 13% 92%);
--panel-accent: hsl(216, 8%, 86%);
--radius: 1rem;
--radius: 1rem;
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(0 0% 4%);
--foreground: hsl(0 0% 89%);
--card: hsl(0 0% 4%);
--card-foreground: hsl(0 0% 89%);
--popover: hsl(0 0% 14.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(205, 84%, 53%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 14.9%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 100% 60%);
--destructive-foreground: hsl(0 0% 98%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 17%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--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% 3.9%);
--sidebar-foreground: hsl(0 0% 98%);
--sidebar-primary: hsl(0 0% 98%);
--sidebar-primary-foreground: hsl(0 0% 9%);
--sidebar-accent: hsl(0 0% 14.9%);
--sidebar-accent-foreground: hsl(0 0% 98%);
--sidebar-border: hsl(0 0% 14.9%);
--sidebar-ring: hsl(0 0% 83.1%);
--panel-background: hsl(0 0% 11%);
--panel-accent: hsl(0 0% 15%);
--background: hsl(0 0% 4%);
--foreground: hsl(0 0% 89%);
--card: hsl(0 0% 4%);
--card-foreground: hsl(0 0% 89%);
--popover: hsl(0 0% 14.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(205, 84%, 53%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 14.9%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 100% 60%);
--destructive-foreground: hsl(0 0% 98%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 17%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--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% 3.9%);
--sidebar-foreground: hsl(0 0% 98%);
--sidebar-primary: hsl(0 0% 98%);
--sidebar-primary-foreground: hsl(0 0% 9%);
--sidebar-accent: hsl(0 0% 14.9%);
--sidebar-accent-foreground: hsl(0 0% 98%);
--sidebar-border: hsl(0 0% 14.9%);
--sidebar-ring: hsl(0 0% 83.1%);
--panel-background: hsl(0 0% 11%);
--panel-accent: hsl(0 0% 15%);
--sidebar: hsl(240 5.9% 10%);
}
@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.
@@ -95,155 +98,164 @@
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;
}
*,
::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;
}
}
@theme inline {
/* Responsive breakpoints */
--breakpoint-xs: 30rem;
/* Responsive breakpoints */
--breakpoint-xs: 30rem;
/* Typography */
--font-sans: var(--font-inter), sans-serif;
/* Typography */
--font-sans: var(--font-inter), sans-serif;
/* Font sizes */
--text-base: 0.95rem;
--text-base--line-height: calc(1.5 / 0.95);
--text-xs: 0.8rem;
--text-xs--line-height: calc(1 / 0.8);
/* Font sizes */
--text-base: 0.95rem;
--text-base--line-height: calc(1.5 / 0.95);
--text-xs: 0.8rem;
--text-xs--line-height: calc(1 / 0.8);
/* Border radius */
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 8px);
/* Border radius */
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 8px);
/* Palette mapped to root design tokens */
--color-background: var(--background);
--color-foreground: var(--foreground);
/* 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-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-constructive: var(--constructive);
--color-constructive-foreground: var(--constructive-foreground);
--color-constructive: var(--constructive);
--color-constructive-foreground: var(--constructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--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);
/* 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);
/* 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);
/* Panel */
--color-panel: var(--panel-background);
--color-panel-accent: var(--panel-accent);
/* Panel */
--color-panel: var(--panel-background);
--color-panel-accent: var(--panel-accent);
/* Animations */
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
/* 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-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
@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;
}
-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;
}
-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;
}
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar:vertical {
display: none;
}
}
@utility scrollbar-thin {
&::-webkit-scrollbar {
width: 6px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
&::-webkit-scrollbar {
width: 6px;
height: 8px;
}
&::-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;
}
}
+37 -37
View File
@@ -13,46 +13,46 @@ const siteFont = Inter({ subsets: ["latin"] });
export const metadata = baseMetaData;
const protectedRoutes = [
{
path: "/none",
method: "GET",
},
{
path: "/none",
method: "GET",
},
];
export default function RootLayout({
children,
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<BotIdClient protect={protectedRoutes} />
</head>
<body className={`${siteFont.className} font-sans antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange={true}
>
<TooltipProvider>
<Toaster />
<Script
src="https://cdn.databuddy.cc/databuddy.js"
strategy="afterInteractive"
async
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
data-disabled={webEnv.NODE_ENV === "development"}
data-track-attributes={false}
data-track-errors={true}
data-track-outgoing-links={false}
data-track-web-vitals={false}
data-track-sessions={false}
/>
{children}
</TooltipProvider>
</ThemeProvider>
</body>
</html>
);
return (
<html lang="en" suppressHydrationWarning>
<head>
<BotIdClient protect={protectedRoutes} />
</head>
<body className={`${siteFont.className} font-sans antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange={true}
>
<TooltipProvider>
<Toaster />
<Script
src="https://cdn.databuddy.cc/databuddy.js"
strategy="afterInteractive"
async
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
data-disabled={webEnv.NODE_ENV === "development"}
data-track-attributes={false}
data-track-errors={true}
data-track-outgoing-links={false}
data-track-web-vitals={false}
data-track-sessions={false}
/>
{children}
</TooltipProvider>
</ThemeProvider>
</body>
</html>
);
}
+81 -81
View File
@@ -2,85 +2,85 @@ 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",
},
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",
},
};
+10 -10
View File
@@ -5,17 +5,17 @@ import type { Metadata } from "next";
import { SITE_URL } from "@/constants/site-constants";
export const metadata: Metadata = {
alternates: {
canonical: SITE_URL,
},
alternates: {
canonical: SITE_URL,
},
};
export default async function Home() {
return (
<div>
<Header />
<Hero />
<Footer />
</div>
);
return (
<div>
<Header />
<Hero />
<Footer />
</div>
);
}
+267 -267
View File
@@ -1,287 +1,287 @@
import { Metadata } from "next";
import type { Metadata } from "next";
import { BasePage } from "@/app/base-page";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { Separator } from "@/components/ui/separator";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/constants/site-constants";
export const metadata: Metadata = {
title: "Privacy Policy - OpenCut",
description:
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
openGraph: {
title: "Privacy Policy - OpenCut",
description:
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
type: "website",
},
title: "Privacy Policy - OpenCut",
description:
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
openGraph: {
title: "Privacy Policy - OpenCut",
description:
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
type: "website",
},
};
export default function PrivacyPage() {
return (
<BasePage
title="Privacy policy"
description="Learn how we handle your data and privacy. Contact us if you have any questions."
>
<Accordion type="single" collapsible className="w-full">
<AccordionItem
value="quick-summary"
className="rounded-2xl border px-5"
>
<AccordionTrigger className="no-underline!">
Quick summary
</AccordionTrigger>
<AccordionContent>
<h3 className="mb-3 text-lg font-medium">
Your content stays private and encrypted.
</h3>
<ol className="list-decimal space-y-2 pl-6">
<li>
Basic editing happens locally in your browser - we never see
your files
</li>
<li>
AI features require encrypted uploads - your content is
encrypted before leaving your device
</li>
<li>
We only collect your email and basic profile info for your
account
</li>
<li>Project data stays on your device, not our servers</li>
<li>
We use analytics to improve the app, but no personal video
content is tracked
</li>
<li>
You can delete your account anytime and all data gets removed
</li>
<li>We don't sell your data or share it with advertisers</li>
</ol>
<p className="mt-4">
Questions? Email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
oss@opencut.app
</a>
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
return (
<BasePage
title="Privacy policy"
description="Learn how we handle your data and privacy. Contact us if you have any questions."
>
<Accordion type="single" collapsible className="w-full">
<AccordionItem
value="quick-summary"
className="rounded-2xl border px-5"
>
<AccordionTrigger className="no-underline!">
Quick summary
</AccordionTrigger>
<AccordionContent>
<h3 className="mb-3 text-lg font-medium">
Your content stays private and encrypted.
</h3>
<ol className="list-decimal space-y-2 pl-6">
<li>
Basic editing happens locally in your browser - we never see
your files
</li>
<li>
AI features require encrypted uploads - your content is
encrypted before leaving your device
</li>
<li>
We only collect your email and basic profile info for your
account
</li>
<li>Project data stays on your device, not our servers</li>
<li>
We use analytics to improve the app, but no personal video
content is tracked
</li>
<li>
You can delete your account anytime and all data gets removed
</li>
<li>We don't sell your data or share it with advertisers</li>
</ol>
<p className="mt-4">
Questions? Email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
oss@opencut.app
</a>
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">How We Handle Your Content</h2>
<p>
<strong>Basic video editing happens locally on your device.</strong>{" "}
For standard editing features, we never upload, store, or have access
to your video files. Your content remains completely private and under
your control.
</p>
<p>
<strong>AI features require secure processing:</strong> When you
choose to use AI features like auto captions, your audio/video content
is encrypted on your device before being uploaded to our servers for
processing. We use zero-knowledge encryption, meaning we cannot
decrypt or view your content.
</p>
<p>
After AI processing is complete, the encrypted content is immediately
deleted from our servers. Only the results (like generated captions)
are returned to your device.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">How We Handle Your Content</h2>
<p>
<strong>Basic video editing happens locally on your device.</strong>{" "}
For standard editing features, we never upload, store, or have access
to your video files. Your content remains completely private and under
your control.
</p>
<p>
<strong>AI features require secure processing:</strong> When you
choose to use AI features like auto captions, your audio/video content
is encrypted on your device before being uploaded to our servers for
processing. We use zero-knowledge encryption, meaning we cannot
decrypt or view your content.
</p>
<p>
After AI processing is complete, the encrypted content is immediately
deleted from our servers. Only the results (like generated captions)
are returned to your device.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Account Information</h2>
<p>When you create an account, we only collect:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Email address (for account access)</li>
<li>
Profile information from Google OAuth (if you choose to sign in with
Google)
</li>
</ul>
<p>
<strong>We do NOT store your projects on our servers.</strong> All
project data, including names, thumbnails, and creation dates, is
stored locally in your browser using IndexedDB.
</p>
<p>
We use{" "}
<a
href="https://www.better-auth.com"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
Better Auth
</a>{" "}
for secure authentication and follow industry-standard security
practices.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Account Information</h2>
<p>When you create an account, we only collect:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Email address (for account access)</li>
<li>
Profile information from Google OAuth (if you choose to sign in with
Google)
</li>
</ul>
<p>
<strong>We do NOT store your projects on our servers.</strong> All
project data, including names, thumbnails, and creation dates, is
stored locally in your browser using IndexedDB.
</p>
<p>
We use{" "}
<a
href="https://www.better-auth.com"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
Better Auth
</a>{" "}
for secure authentication and follow industry-standard security
practices.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">AI Features & Encryption</h2>
<p>
When you use AI-powered features (like auto captions, content
analysis, or enhancement tools), your content needs to be processed on
our servers. Here's how we protect your privacy:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>Client-side encryption:</strong> Your content is encrypted
on your device before upload
</li>
<li>
<strong>Zero-knowledge processing:</strong> We cannot decrypt or
view your original content
</li>
<li>
<strong>Temporary processing:</strong> Encrypted content is deleted
immediately after processing
</li>
<li>
<strong>Opt-in only:</strong> AI features are optional - basic
editing remains fully local
</li>
</ul>
<p>
Different AI features may process different types of content (audio
for captions, video for analysis, etc.), but all follow the same
zero-knowledge encryption approach.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">AI Features & Encryption</h2>
<p>
When you use AI-powered features (like auto captions, content
analysis, or enhancement tools), your content needs to be processed on
our servers. Here's how we protect your privacy:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>Client-side encryption:</strong> Your content is encrypted
on your device before upload
</li>
<li>
<strong>Zero-knowledge processing:</strong> We cannot decrypt or
view your original content
</li>
<li>
<strong>Temporary processing:</strong> Encrypted content is deleted
immediately after processing
</li>
<li>
<strong>Opt-in only:</strong> AI features are optional - basic
editing remains fully local
</li>
</ul>
<p>
Different AI features may process different types of content (audio
for captions, video for analysis, etc.), but all follow the same
zero-knowledge encryption approach.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Analytics</h2>
<p>
We use{" "}
<a
href="https://www.databuddy.cc"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
Databuddy
</a>{" "}
for completely anonymized and non-invasive analytics to understand how
people use OpenCut.
</p>
<p>
This helps us improve the editor, but we never collect personal
information, track individual users, or store any data that could
identify you.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Analytics</h2>
<p>
We use{" "}
<a
href="https://www.databuddy.cc"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
Databuddy
</a>{" "}
for completely anonymized and non-invasive analytics to understand how
people use OpenCut.
</p>
<p>
This helps us improve the editor, but we never collect personal
information, track individual users, or store any data that could
identify you.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Local Storage & Cookies</h2>
<p>We use browser local storage and IndexedDB to:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Save your projects locally on your device</li>
<li>Remember your editor preferences and settings</li>
<li>Keep you logged in across browser sessions</li>
</ul>
<p>
All data stays on your device and can be cleared at any time through
your browser settings.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Local Storage & Cookies</h2>
<p>We use browser local storage and IndexedDB to:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Save your projects locally on your device</li>
<li>Remember your editor preferences and settings</li>
<li>Keep you logged in across browser sessions</li>
</ul>
<p>
All data stays on your device and can be cleared at any time through
your browser settings.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Third-Party Services</h2>
<p>OpenCut integrates with these services:</p>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>Google OAuth:</strong> For optional Google sign-in (governed
by Google's privacy policy)
</li>
<li>
<strong>Vercel:</strong> For hosting and content delivery
</li>
<li>
<strong>Databuddy:</strong> For anonymized analytics
</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Third-Party Services</h2>
<p>OpenCut integrates with these services:</p>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>Google OAuth:</strong> For optional Google sign-in (governed
by Google's privacy policy)
</li>
<li>
<strong>Vercel:</strong> For hosting and content delivery
</li>
<li>
<strong>Databuddy:</strong> For anonymized analytics
</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Your Rights</h2>
<p>You have complete control over your data:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Delete your account and all associated data at any time</li>
<li>Export your project data</li>
<li>Clear local storage to remove all saved projects</li>
<li>Contact us with any privacy concerns</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Your Rights</h2>
<p>You have complete control over your data:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Delete your account and all associated data at any time</li>
<li>Export your project data</li>
<li>Clear local storage to remove all saved projects</li>
<li>Contact us with any privacy concerns</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Open Source Transparency</h2>
<p>
OpenCut is completely open source. You can review our code, see
exactly how we handle data, and even self-host the application if you
prefer.
</p>
<p>
View our source code on{" "}
<a
href={SOCIAL_LINKS.github}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub
</a>
.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Open Source Transparency</h2>
<p>
OpenCut is completely open source. You can review our code, see
exactly how we handle data, and even self-host the application if you
prefer.
</p>
<p>
View our source code on{" "}
<a
href={SOCIAL_LINKS.github}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub
</a>
.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Contact Us</h2>
<p>Questions about this privacy policy or how we handle your data?</p>
<p>
Open an issue on our{" "}
<a
href={`${SOCIAL_LINKS.github}/issues`}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub repository
</a>
, email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
oss@opencut.app
</a>
, or reach out on{" "}
<a
href={SOCIAL_LINKS.x}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
X (Twitter)
</a>
.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Contact Us</h2>
<p>Questions about this privacy policy or how we handle your data?</p>
<p>
Open an issue on our{" "}
<a
href={`${SOCIAL_LINKS.github}/issues`}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub repository
</a>
, email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
oss@opencut.app
</a>
, or reach out on{" "}
<a
href={SOCIAL_LINKS.x}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
X (Twitter)
</a>
.
</p>
</section>
<Separator />
<Separator />
<p className="text-muted-foreground text-sm">
Last updated: July 14, 2025
</p>
</BasePage>
);
<p className="text-muted-foreground text-sm">
Last updated: July 14, 2025
</p>
</BasePage>
);
}
+631
View File
@@ -0,0 +1,631 @@
"use client";
import {
ArrowDown01,
Calendar,
ChevronLeft,
MoreHorizontal,
Plus,
Search,
Trash2,
Video,
X,
} from "lucide-react";
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 { DeleteProjectDialog } from "@/components/editor/delete-project-dialog";
import { MigrationDialog } from "@/components/editor/migration-dialog";
import { RenameProjectDialog } from "@/components/rename-project-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import type { TProjectMetadata } from "@/types/project";
import { formatDate } from "@/utils/date";
export default function ProjectsPage() {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
new Set(),
);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortOption, setSortOption] = useState("createdAt-desc");
const router = useRouter();
const editor = useEditor();
useEffect(() => {
if (!editor.project.getIsInitialized()) {
editor.project.loadAllProjects();
}
}, [editor.project]);
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",
});
}
};
const toggleSortOption = ({
sortField,
}: {
sortField: "createdAt" | "name";
}) => {
const isSameField = sortOption.startsWith(sortField);
const nextSortOption = isSameField
? `${sortField}-${sortOption.endsWith("asc") ? "desc" : "asc"}`
: `${sortField}-asc`;
setSortOption(nextSortOption);
};
const handleSelectProject = ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => {
const newSelected = new Set(selectedProjects);
if (checked) {
newSelected.add(projectId);
} else {
newSelected.delete(projectId);
}
setSelectedProjects(newSelected);
};
const handleSelectAll = ({ checked }: { checked: boolean }) => {
if (checked) {
setSelectedProjects(
new Set(projectsToDisplay.map((project) => project.id)),
);
} else {
setSelectedProjects(new Set());
}
};
const handleCancelSelection = () => {
setIsSelectionMode(false);
setSelectedProjects(new Set());
};
const handleBulkDelete = async () => {
try {
await Promise.all(
Array.from(selectedProjects).map((projectId) =>
editor.project.deleteProject({ id: projectId }),
),
);
} catch (error) {
toast.error("Failed to delete projects", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setSelectedProjects(new Set());
setIsSelectionMode(false);
setIsBulkDeleteDialogOpen(false);
}
};
const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
searchQuery,
sortOption,
});
const isAllSelected =
projectsToDisplay.length > 0 &&
selectedProjects.size === projectsToDisplay.length;
const hasSomeSelected =
selectedProjects.size > 0 &&
selectedProjects.size < projectsToDisplay.length;
const isLoading = editor.project.getIsLoading();
const isInitialized = editor.project.getIsInitialized();
return (
<div className="bg-background min-h-screen">
<MigrationDialog />
<div className="flex h-16 w-full items-center justify-between px-6 pt-2">
<Link
href="/"
className="hover:text-muted-foreground flex items-center gap-1 transition-colors"
>
<ChevronLeft className="size-5! shrink-0" />
<span className="text-sm font-medium">Back</span>
</Link>
<div className="block md:hidden">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelSelection}
>
<X className="size-4!" />
Cancel
</Button>
{selectedProjects.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="size-4!" />
Delete ({selectedProjects.size})
</Button>
)}
</div>
) : (
<CreateButton onClick={handleCreateProject} />
)}
</div>
</div>
<main className="mx-auto max-w-6xl px-6 pt-6 pb-6">
<div className="mb-8 flex items-center justify-between">
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
Your projects
</h1>
<p className="text-muted-foreground">
{projectsToDisplay.length}{" "}
{projectsToDisplay.length === 1 ? "project" : "projects"}
{isSelectionMode && selectedProjects.size > 0 && (
<span className="text-primary ml-2">
{selectedProjects.size} selected
</span>
)}
</p>
</div>
<div className="hidden md:block">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancelSelection}>
<X className="size-4!" />
Cancel
</Button>
<Button
variant="destructive"
disabled={selectedProjects.size === 0}
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="size-4!" />
Delete {selectedProjects.size} projects
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setIsSelectionMode(true)}
disabled={projectsToDisplay.length === 0}
>
Select projects
</Button>
<CreateButton onClick={handleCreateProject} />
</div>
)}
</div>
</div>
<div className="mb-4 flex items-center justify-between gap-4">
<div className="max-w-72 flex-1">
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</div>
<div className="flex items-center gap-0">
<TooltipProvider>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
aria-label="sort projects"
size="icon"
variant="outline"
className="size-9 items-center justify-center"
>
<ArrowDown01
strokeWidth={1.5}
className="!size-[1.05rem]"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
toggleSortOption({ sortField: "createdAt" })
}
>
Created{" "}
{sortOption.startsWith("createdAt") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toggleSortOption({ sortField: "name" })}
>
Name{" "}
{sortOption.startsWith("name") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by{" "}
{sortOption.startsWith("createdAt") ? "date" : "name"} (
{sortOption.endsWith("asc") ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{isLoading || !isInitialized ? (
<ProjectsLoader />
) : projectsToDisplay.length === 0 ? (
<EmptyState
search={{
query: searchQuery,
onClearSearch: () => setSearchQuery(""),
}}
onCreateProject={handleCreateProject}
/>
) : (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{projectsToDisplay.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
/>
))}
</div>
)}
</main>
<DeleteProjectDialog
isOpen={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onConfirm={handleBulkDelete}
/>
</div>
);
}
interface ProjectCardProps {
project: TProjectMetadata;
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => void;
}
function ProjectCard({
project,
isSelectionMode = false,
isSelected = false,
onSelect,
}: ProjectCardProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const editor = useEditor();
const handleDeleteProject = async () => {
await editor.project.deleteProject({ id: project.id });
setIsDropdownOpen(false);
};
const handleRenameProject = async ({ name }: { name: string }) => {
await editor.project.renameProject({ id: project.id, name });
setIsRenameDialogOpen(false);
};
const handleDuplicateProject = async () => {
setIsDropdownOpen(false);
await editor.project.duplicateProject({ id: project.id });
};
const handleCardClick = ({
event,
}: {
event: MouseEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const handleCardKeyDown = ({
event,
}: {
event: KeyboardEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const cardContent = (
<Card
className={`bg-background overflow-hidden border-none p-0 transition-all ${
isSelectionMode && isSelected ? "ring-primary ring-2" : ""
}`}
>
<div
className={`bg-muted relative aspect-square transition-opacity ${
isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
}`}
>
{isSelectionMode && (
<div className="absolute top-3 left-3 z-10">
<div className="bg-background/80 flex size-5 items-center justify-center rounded-full border backdrop-blur-xs">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
onSelect?.({
projectId: project.id,
checked: checked === true,
})
}
onClick={(event) => event.stopPropagation()}
className="size-4"
/>
</div>
</div>
)}
<div className="absolute inset-0">
{project.thumbnail ? (
<Image
src={project.thumbnail}
alt="Project thumbnail"
fill
className="object-cover"
/>
) : (
<div className="bg-muted/50 flex h-full w-full items-center justify-center">
<Video className="text-muted-foreground size-12 shrink-0" />
</div>
)}
</div>
</div>
<CardContent className="flex flex-col gap-1 px-0 pt-5">
<div className="flex items-start justify-between">
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium transition-colors">
{project.name}
</h3>
{!isSelectionMode && (
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
aria-label="project options"
variant="text"
size="sm"
className={`ml-2 size-6 shrink-0 p-0 transition-all ${
isDropdownOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
onClick={(event) => event.preventDefault()}
>
<MoreHorizontal aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsRenameDialogOpen(true);
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDuplicateProject();
}}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsDeleteDialogOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="space-y-1">
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
<Calendar className="size-4!" />
<span>Created {formatDate({ date: project.createdAt })}</span>
</div>
</div>
</CardContent>
</Card>
);
return (
<>
{isSelectionMode ? (
<button
type="button"
onClick={(event) => handleCardClick({ event })}
onKeyDown={(event) => handleCardKeyDown({ event })}
className="group block w-full cursor-pointer text-left"
>
{cardContent}
</button>
) : (
<Link href={`/editor/${project.id}`} className="group block">
{cardContent}
</Link>
)}
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDeleteProject}
/>
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onConfirm={(name) => handleRenameProject({ name })}
projectName={project.name}
/>
</>
);
}
function ProjectsLoader() {
const skeletonIds = Array.from(
{ length: 8 },
(_, index) => `skeleton-${index}`,
);
return (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{skeletonIds.map((skeletonId) => (
<div
key={skeletonId}
className="bg-background overflow-hidden border-none p-0"
>
<Skeleton className="bg-muted/50 aspect-square w-full" />
<div className="flex flex-col gap-1.5 px-0 pt-5">
<Skeleton className="bg-muted/50 h-4 w-3/4" />
<div className="flex items-center gap-1.5">
<Skeleton className="bg-muted/50 h-4 w-4" />
<Skeleton className="bg-muted/50 h-4 w-24" />
</div>
</div>
</div>
))}
</div>
);
}
function CreateButton({ onClick }: { onClick?: () => void }) {
return (
<Button className="flex" onClick={onClick}>
<Plus className="size-4!" />
<span className="text-sm font-medium">New project</span>
</Button>
);
}
function EmptyState({
search,
onCreateProject,
}: {
search: { query: string; onClearSearch: () => void };
onCreateProject: () => void;
}) {
const editor = useEditor();
const savedProjects = editor.project.getSavedProjects();
if (savedProjects.length > 0) {
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">
<Search className="text-muted-foreground size-8" />
</div>
<h3 className="text-lg font-medium">No results found</h3>
<p className="text-muted-foreground max-w-md">
Your search for "{search.query}" did not return any results.
</p>
</div>
<Button onClick={search.onClearSearch} variant="outline">
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">
<Video 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 video project. Import media, edit, and
export professional videos.
</p>
</div>
<Button size="lg" className="gap-2" onClick={onCreateProject}>
<Plus />
Create your first project
</Button>
</div>
);
}
+311 -624
View File
@@ -1,649 +1,336 @@
"use client";
import {
Calendar,
ChevronLeft,
MoreHorizontal,
ArrowDown01,
Plus,
Search,
Trash2,
Video,
X,
ArrowDown,
Clock3,
Folder,
LayoutGrid,
List,
MoreHorizontal,
Plus,
Search,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { KeyboardEvent, MouseEvent } from "react";
import { useState, useEffect } from "react";
import { DeleteProjectDialog } from "@/components/editor/delete-project-dialog";
import { RenameProjectDialog } from "@/components/rename-project-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { MigrationDialog } from "@/components/editor/migration-dialog";
import type { TProjectMetadata } from "@/types/project";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { formatDate } from "@/utils/date";
import { useEffect, useMemo, useRef, useState } from "react";
const projects = [
{
id: "project-1",
name: "Summer Vlog 2025",
createdAtLabel: "Created Jan 20, 2026",
duration: "02:14",
},
{
id: "project-2",
name: "Product Launch Teaser",
createdAtLabel: "Created Jan 20, 2026",
duration: "00:45",
},
{
id: "project-3",
name: "Podcast Ep. 4",
createdAtLabel: "Created Jan 18, 2026",
duration: "03:30",
},
];
const thumbnailSrc = "/open-graph/default.jpg";
export default function ProjectsPage() {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
new Set(),
);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortOption, setSortOption] = useState("createdAt-desc");
const router = useRouter();
const editor = useEditor();
const [selectedProjectIds, setSelectedProjectIds] = useState<Array<string>>(
[],
);
const selectAllRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!editor.project.getIsInitialized()) {
editor.project.loadAllProjects();
}
}, [editor.project]);
const selectedProjectIdSet = useMemo(() => {
return new Set(selectedProjectIds);
}, [selectedProjectIds]);
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",
});
}
};
const selectedCount = selectedProjectIds.length;
const totalCount = projects.length;
const isAllSelected = selectedCount > 0 && selectedCount === totalCount;
const isIndeterminate = selectedCount > 0 && selectedCount < totalCount;
const toggleSortOption = ({
sortField,
}: {
sortField: "createdAt" | "name";
}) => {
const isSameField = sortOption.startsWith(sortField);
const nextSortOption = isSameField
? `${sortField}-${sortOption.endsWith("asc") ? "desc" : "asc"}`
: `${sortField}-asc`;
useEffect(() => {
if (!selectAllRef.current) {
return;
}
selectAllRef.current.indeterminate = isIndeterminate;
}, [isIndeterminate]);
setSortOption(nextSortOption);
};
const toggleProjectSelection = ({
projectId,
isSelected,
}: {
projectId: string;
isSelected: boolean;
}) => {
setSelectedProjectIds((previousIds) => {
if (isSelected) {
if (previousIds.includes(projectId)) {
return previousIds;
}
return [...previousIds, projectId];
}
return previousIds.filter((id) => id !== projectId);
});
};
const handleSelectProject = ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => {
const newSelected = new Set(selectedProjects);
if (checked) {
newSelected.add(projectId);
} else {
newSelected.delete(projectId);
}
setSelectedProjects(newSelected);
};
const toggleSelectAll = ({ isSelected }: { isSelected: boolean }) => {
if (!isSelected) {
setSelectedProjectIds([]);
return;
}
setSelectedProjectIds(projects.map(({ id }) => id));
};
const handleSelectAll = ({ checked }: { checked: boolean }) => {
if (checked) {
setSelectedProjects(
new Set(projectsToDisplay.map((project) => project.id)),
);
} else {
setSelectedProjects(new Set());
}
};
return (
<div
className={`flex h-screen overflow-hidden bg-[#ffffff] text-slate-900 ${
selectedCount > 1 ? "multi-select" : ""
}`}
style={{ "--brand-blue": "#00A3FF" } as React.CSSProperties}
>
<aside className="flex w-64 flex-shrink-0 flex-col border-r border-slate-100 bg-white">
<div className="flex items-center gap-3 p-6 pl-8">
<Image
src="/logos/opencut/1k/logo-black.png"
alt="OpenCut Logo"
width={24}
height={24}
className="size-6"
/>
<span className="text-lg font-semibold tracking-tight text-slate-900">
OpenCut
</span>
</div>
const handleCancelSelection = () => {
setIsSelectionMode(false);
setSelectedProjects(new Set());
};
<nav className="mt-2 flex-1 space-y-2 px-4">
<a
href="/projects"
className="flex items-center gap-3 rounded-2xl bg-blue-50/50 px-4 py-3 text-sm font-medium text-[#00A3FF]"
>
<LayoutGrid className="h-5 w-5" />
Projects
</a>
<div className="px-4 pt-6 pb-2">
<p className="text-xs font-bold tracking-wider text-slate-400 uppercase">
Folders
</p>
</div>
<a
href="/projects"
className="flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium text-slate-500 hover:bg-slate-50 hover:text-slate-900"
>
<Folder className="h-5 w-5 text-slate-400" />
Marketing
</a>
<a
href="/projects"
className="flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium text-slate-500 hover:bg-slate-50 hover:text-slate-900"
>
<Folder className="h-5 w-5 text-slate-400" />
Social media
</a>
</nav>
</aside>
const handleBulkDelete = async () => {
try {
await Promise.all(
Array.from(selectedProjects).map((projectId) =>
editor.project.deleteProject({ id: projectId }),
),
);
} catch (error) {
toast.error("Failed to delete projects", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setSelectedProjects(new Set());
setIsSelectionMode(false);
setIsBulkDeleteDialogOpen(false);
}
};
<main className="flex min-w-0 flex-1 flex-col">
<header className="flex h-20 flex-shrink-0 items-center justify-between px-8">
<div className="flex items-center gap-4">
<h1 className="text-xl font-bold text-slate-900">All projects</h1>
</div>
const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
searchQuery,
sortOption,
});
<div className="flex items-center gap-4">
<div className="group relative">
<Search className="absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Search..."
className="w-64 rounded-full border border-slate-200 bg-white py-2.5 pr-4 pl-10 text-sm text-slate-900 placeholder:text-slate-400 focus:border-[#00A3FF] focus:ring-4 focus:ring-blue-500/10 focus:outline-none"
/>
</div>
const isAllSelected =
projectsToDisplay.length > 0 &&
selectedProjects.size === projectsToDisplay.length;
<div className="flex rounded-full border border-slate-200 bg-white p-1">
<button
type="button"
className="rounded-full bg-slate-100 p-2 text-slate-900"
aria-label="Grid view"
>
<LayoutGrid className="h-4 w-4" aria-hidden="true">
<title>Grid view</title>
</LayoutGrid>
</button>
<button
type="button"
className="rounded-full p-2 text-slate-400 hover:text-slate-600"
aria-label="List view"
>
<List className="h-4 w-4" aria-hidden="true">
<title>List view</title>
</List>
</button>
</div>
const hasSomeSelected =
selectedProjects.size > 0 &&
selectedProjects.size < projectsToDisplay.length;
<button
type="button"
className="flex items-center gap-2 rounded-full bg-[#00A3FF] px-5 py-2.5 text-sm font-semibold text-white hover:bg-[#008BE0] active:scale-95"
>
<Plus className="h-4 w-4" />
New project
</button>
</div>
</header>
const isLoading = editor.project.getIsLoading();
const isInitialized = editor.project.getIsInitialized();
<div className="flex-1 overflow-y-auto px-8 pb-8">
<div
id="controls-row"
className="controls-row group mb-4 flex items-center gap-3 text-slate-700"
>
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-3 rounded-lg p-1 text-sm font-medium select-none">
<input
ref={selectAllRef}
id="select-all"
type="checkbox"
className="custom-checkbox controls-select size-5"
checked={isAllSelected}
onChange={({ currentTarget }) => {
toggleSelectAll({ isSelected: currentTarget.checked });
}}
/>
<span className="text-slate-500 group-hover:text-slate-700">
Select all
</span>
</label>
</div>
<div className="mx-2 h-4 w-px bg-slate-200"></div>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg p-1 text-sm font-medium text-slate-500 hover:text-slate-900"
>
Name
<ArrowDown className="size-3.5" />
</button>
</div>
return (
<div className="bg-background min-h-screen">
<MigrationDialog />
<div className="flex h-16 w-full items-center justify-between px-6 pt-2">
<Link
href="/"
className="hover:text-muted-foreground flex items-center gap-1 transition-colors"
>
<ChevronLeft className="size-5! shrink-0" />
<span className="text-sm font-medium">Back</span>
</Link>
<div className="block md:hidden">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelSelection}
>
<X className="size-4!" />
Cancel
</Button>
{selectedProjects.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="size-4!" />
Delete ({selectedProjects.size})
</Button>
)}
</div>
) : (
<CreateButton onClick={handleCreateProject} />
)}
</div>
</div>
<main className="mx-auto max-w-6xl px-6 pt-6 pb-6">
<div className="mb-8 flex items-center justify-between">
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
Your projects
</h1>
<p className="text-muted-foreground">
{projectsToDisplay.length}{" "}
{projectsToDisplay.length === 1 ? "project" : "projects"}
{isSelectionMode && selectedProjects.size > 0 && (
<span className="text-primary ml-2">
{selectedProjects.size} selected
</span>
)}
</p>
</div>
<div className="hidden md:block">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancelSelection}>
<X className="size-4!" />
Cancel
</Button>
{selectedProjects.size > 0 && (
<Button
variant="destructive"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="size-4!" />
Delete selected ({selectedProjects.size})
</Button>
)}
</div>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setIsSelectionMode(true)}
disabled={projectsToDisplay.length === 0}
>
Select projects
</Button>
<CreateButton onClick={handleCreateProject} />
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{projects.map(({ createdAtLabel, duration, id, name }) => {
const isSelected = selectedProjectIdSet.has(id);
return (
<div
key={id}
className="project-card group relative flex h-auto flex-col overflow-hidden rounded-[24px]"
data-project-id={id}
>
<div className="project-select-wrapper absolute top-4 left-4 z-20">
<input
type="checkbox"
className="custom-checkbox project-select h-6 w-6 cursor-pointer shadow-md"
checked={isSelected}
onChange={({ currentTarget }) => {
toggleProjectSelection({
projectId: id,
isSelected: currentTarget.checked,
});
}}
/>
</div>
<div className="mb-4 flex items-center justify-between gap-4">
<div className="max-w-72 flex-1">
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</div>
<div className="flex items-center gap-0">
<TooltipProvider>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
aria-label="sort projects"
size="icon"
variant="outline"
className="size-9 items-center justify-center"
>
<ArrowDown01
strokeWidth={1.5}
className="!size-[1.05rem]"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
toggleSortOption({ sortField: "createdAt" })
}
>
Created{" "}
{sortOption.startsWith("createdAt") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toggleSortOption({ sortField: "name" })}
>
Name{" "}
{sortOption.startsWith("name") &&
(sortOption.endsWith("asc") ? "↑" : "↓")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by{" "}
{sortOption.startsWith("createdAt") ? "date" : "name"} (
{sortOption.endsWith("asc") ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="project-menu absolute top-4 right-4 z-20 opacity-0 group-hover:opacity-100">
<button
type="button"
className="rounded-full bg-white/90 p-2 text-slate-600 backdrop-blur-sm hover:bg-white hover:text-[#00A3FF]"
aria-label="Project menu"
>
<MoreHorizontal className="h-4 w-4" aria-hidden="true">
<title>Project menu</title>
</MoreHorizontal>
</button>
</div>
{isSelectionMode && projectsToDisplay.length > 0 && (
<button
type="button"
onClick={() => handleSelectAll({ checked: !isAllSelected })}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleSelectAll({ checked: !isAllSelected });
}
}}
className="bg-muted/30 mb-6 flex w-full items-center gap-2 rounded-lg border p-4 hover:cursor-pointer"
tabIndex={0}
>
<Checkbox
checked={hasSomeSelected ? "indeterminate" : isAllSelected}
/>
<span className="text-sm font-medium">
{isAllSelected ? "Deselect all" : "Select all"}
</span>
<span className="text-muted-foreground text-sm">
({selectedProjects.size} of {projectsToDisplay.length} selected)
</span>
</button>
)}
<div className="relative m-1 aspect-video overflow-hidden rounded-lg bg-slate-50">
<Image
src={thumbnailSrc}
alt={`Thumbnail for ${name}`}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover"
/>
<div className="absolute right-2 bottom-2 rounded-lg bg-black/60 px-2 py-1 text-[11px] font-semibold text-white">
{duration}
</div>
</div>
{isLoading || !isInitialized ? (
<ProjectsLoader />
) : projectsToDisplay.length === 0 ? (
<EmptyState
search={{
query: searchQuery,
onClearSearch: () => setSearchQuery(""),
}}
onCreateProject={handleCreateProject}
/>
) : (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{projectsToDisplay.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
/>
))}
</div>
)}
</main>
<div className="flex flex-1 flex-col justify-between px-2 pt-3 pb-5">
<div>
<h3 className="truncate text-[15px] font-semibold text-slate-900">
{name}
</h3>
<div className="mt-2 flex items-center gap-2 text-slate-400">
<Clock3 className="size-4" aria-hidden="true" />
<p className="text-xs font-medium">{createdAtLabel}</p>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</main>
<DeleteProjectDialog
isOpen={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onConfirm={handleBulkDelete}
/>
</div>
);
}
interface ProjectCardProps {
project: TProjectMetadata;
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: ({
projectId,
checked,
}: {
projectId: string;
checked: boolean;
}) => void;
}
function ProjectCard({
project,
isSelectionMode = false,
isSelected = false,
onSelect,
}: ProjectCardProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const editor = useEditor();
const handleDeleteProject = async () => {
await editor.project.deleteProject({ id: project.id });
setIsDropdownOpen(false);
};
const handleRenameProject = async ({ name }: { name: string }) => {
await editor.project.renameProject({ id: project.id, name });
setIsRenameDialogOpen(false);
};
const handleDuplicateProject = async () => {
setIsDropdownOpen(false);
await editor.project.duplicateProject({ id: project.id });
};
const handleCardClick = ({
event,
}: {
event: MouseEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const handleCardKeyDown = ({
event,
}: {
event: KeyboardEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected });
}
};
const cardContent = (
<Card
className={`bg-background overflow-hidden border-none p-0 transition-all ${isSelectionMode && isSelected ? "ring-primary ring-2" : ""
}`}
>
<div
className={`bg-muted relative aspect-square transition-opacity ${isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
}`}
>
{isSelectionMode && (
<div className="absolute top-3 left-3 z-10">
<div className="bg-background/80 flex size-5 items-center justify-center rounded-full border backdrop-blur-xs">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
onSelect?.({
projectId: project.id,
checked: checked === true,
})
}
onClick={(event) => event.stopPropagation()}
className="size-4"
/>
</div>
</div>
)}
<div className="absolute inset-0">
{project.thumbnail ? (
<Image
src={project.thumbnail}
alt="Project thumbnail"
fill
className="object-cover"
/>
) : (
<div className="bg-muted/50 flex h-full w-full items-center justify-center">
<Video className="text-muted-foreground size-12 shrink-0" />
</div>
)}
</div>
</div>
<CardContent className="flex flex-col gap-1 px-0 pt-5">
<div className="flex items-start justify-between">
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium transition-colors">
{project.name}
</h3>
{!isSelectionMode && (
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
aria-label="project options"
variant="text"
size="sm"
className={`ml-2 size-6 shrink-0 p-0 transition-all ${isDropdownOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
onClick={(event) => event.preventDefault()}
>
<MoreHorizontal aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsRenameDialogOpen(true);
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDuplicateProject();
}}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDropdownOpen(false);
setIsDeleteDialogOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="space-y-1">
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
<Calendar className="size-4!" />
<span>Created {formatDate({ date: project.createdAt })}</span>
</div>
</div>
</CardContent>
</Card>
);
return (
<>
{isSelectionMode ? (
<button
type="button"
onClick={(event) => handleCardClick({ event })}
onKeyDown={(event) => handleCardKeyDown({ event })}
className="group block w-full cursor-pointer text-left"
>
{cardContent}
</button>
) : (
<Link href={`/editor/${project.id}`} className="group block">
{cardContent}
</Link>
)}
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDeleteProject}
/>
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onConfirm={(name) => handleRenameProject({ name })}
projectName={project.name}
/>
</>
);
}
function ProjectsLoader() {
return (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }, (_, index) => (
<div
key={`skeleton-${index}`}
className="bg-background overflow-hidden border-none p-0"
>
<Skeleton className="bg-muted/50 aspect-square w-full" />
<div className="flex flex-col gap-1.5 px-0 pt-5">
<Skeleton className="bg-muted/50 h-4 w-3/4" />
<div className="flex items-center gap-1.5">
<Skeleton className="bg-muted/50 h-4 w-4" />
<Skeleton className="bg-muted/50 h-4 w-24" />
</div>
</div>
</div>
))}
</div>
);
}
function CreateButton({ onClick }: { onClick?: () => void }) {
return (
<Button className="flex" onClick={onClick}>
<Plus className="size-4!" />
<span className="text-sm font-medium">New project</span>
</Button>
);
}
function EmptyState({
search,
onCreateProject,
}: {
search: { query: string; onClearSearch: () => void };
onCreateProject: () => void;
}) {
const editor = useEditor();
const savedProjects = editor.project.getSavedProjects();
if (savedProjects.length > 0) {
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">
<Search className="text-muted-foreground size-8" />
</div>
<h3 className="text-lg font-medium">No results found</h3>
<p className="text-muted-foreground max-w-md">
Your search for "{search.query}" did not return any results.
</p>
</div>
<Button onClick={search.onClearSearch} variant="outline">
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">
<Video 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 video project. Import media, edit, and
export professional videos.
</p>
</div>
<Button size="lg" className="gap-2" onClick={onCreateProject}>
<Plus />
Create your first project
</Button>
</div>
);
<style jsx global>{`
.project-card:hover .card-actions {
opacity: 1;
}
.checkbox-wrapper:checked + div {
border-color: var(--brand-blue);
background-color: #eff6ff;
}
.controls-row.has-selection .controls-select {
opacity: 1;
}
.project-select-wrapper {
opacity: 0;
}
.project-card:hover .project-select-wrapper {
opacity: 1;
}
.project-card:has(.project-select:checked) .project-select-wrapper {
opacity: 1;
}
.multi-select .project-menu {
opacity: 0 !important;
pointer-events: none;
}
.custom-checkbox {
appearance: none;
background-color: #fff;
border: 2px solid #e2e8f0;
border-radius: 8px;
display: inline-grid;
place-content: center;
}
.custom-checkbox:checked {
background-color: var(--brand-blue);
border-color: var(--brand-blue);
}
.custom-checkbox:checked::after {
content: "";
width: 10px;
height: 10px;
background-color: #fff;
clip-path: polygon(
14% 44%,
0 58%,
40% 100%,
100% 24%,
86% 10%,
40% 70%
);
}
`}</style>
</div>
);
}
+159 -159
View File
@@ -1,189 +1,189 @@
import { Metadata } from "next";
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";
import { BasePage } from "@/app/base-page";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
type StatusType = "complete" | "pending" | "default" | "info";
interface Status {
text: string;
type: StatusType;
text: string;
type: StatusType;
}
interface RoadmapItem {
title: string;
description: string;
status: Status;
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:
"Built 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: "Basic Functionality",
description:
"The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Export/preview logic",
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
status: {
text: "Completed",
type: "complete",
},
},
{
title: "Desktop/mobile app",
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Text",
description:
"After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Effects",
description:
"Adding visual effects to both text and media. Blur, brightness, contrast, saturation, filters, and all the creative tools that make videos pop. This is where the magic happens.",
status: {
text: "Not started",
type: "default",
},
},
{
title: "Transitions",
description:
"Smooth transitions between clips. Fade in/out, slide, zoom, dissolve, and custom transition effects.",
status: {
text: "Not started",
type: "default",
},
},
{
title: "Refine from here",
description:
"Once we nail the above, we have a **solid foundation** to build anything. Advanced features, performance optimizations, mobile support, desktop app.",
status: {
text: "Future",
type: "info",
},
},
{
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:
"Built 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: "Basic Functionality",
description:
"The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Export/preview logic",
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
status: {
text: "Completed",
type: "complete",
},
},
{
title: "Desktop/mobile app",
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Text",
description:
"After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.",
status: {
text: "In progress",
type: "pending",
},
},
{
title: "Effects",
description:
"Adding visual effects to both text and media. Blur, brightness, contrast, saturation, filters, and all the creative tools that make videos pop. This is where the magic happens.",
status: {
text: "Not started",
type: "default",
},
},
{
title: "Transitions",
description:
"Smooth transitions between clips. Fade in/out, slide, zoom, dissolve, and custom transition effects.",
status: {
text: "Not started",
type: "default",
},
},
{
title: "Refine from here",
description:
"Once we nail the above, we have a **solid foundation** to build anything. Advanced features, performance optimizations, mobile support, desktop app.",
status: {
text: "Future",
type: "info",
},
},
];
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"],
},
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: July 14, 2025)"
>
<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={index} item={item} index={index} />
))}
</div>
<GitHubContributeSection
title="Want to help?"
description="OpenCut is open source and built by the community. Every contribution,
return (
<BasePage
title="Roadmap"
description="What's coming next for OpenCut (last updated: July 14, 2025)"
>
<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>
);
/>
</div>
</BasePage>
);
}
function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 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>
);
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 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,
className,
}: {
status: Status;
className?: string;
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>
);
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>
);
}
+8 -8
View File
@@ -2,12 +2,12 @@ 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`,
};
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/_next/", "/projects/", "/editor/"],
},
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
+37 -37
View File
@@ -3,44 +3,44 @@ import { getPosts } from "@/lib/blog/query";
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
export async function GET() {
try {
const { posts } = await getPosts();
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
}`,
});
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,
});
}
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 });
}
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 });
}
}
+53 -53
View File
@@ -3,59 +3,59 @@ import { getPosts } from "@/lib/blog/query";
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const data = await getPosts();
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,
})) ?? [];
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,
];
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,
];
}
+63 -63
View File
@@ -1,78 +1,78 @@
import { Metadata } from "next";
import { ExternalLink } from "lucide-react";
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Sponsor, SPONSORS } from "@/constants/site-constants";
import { BasePage } from "@/app/base-page";
import { Card, CardContent } from "@/components/ui/card";
import { SPONSORS, type Sponsor } from "@/constants/site-constants";
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",
},
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">
Support OpenCut and help us build the future of free and open-source
video editing.
</p>
</div>
<SponsorsGrid />
</BasePage>
);
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">
Support OpenCut and help us build the future of free and open-source
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>
);
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="h-full w-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="object-contain"
/>
<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>
<ExternalLink className="text-muted-foreground h-4 w-4" />
</div>
<p className="text-muted-foreground">{sponsor.description}</p>
</div>
</CardContent>
</Card>
</Link>
);
return (
<Link
href={sponsor.url}
target="_blank"
rel="noopener noreferrer"
className="h-full w-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="object-contain"
/>
<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>
<ExternalLink className="text-muted-foreground h-4 w-4" />
</div>
<p className="text-muted-foreground">{sponsor.description}</p>
</div>
</CardContent>
</Card>
</Link>
);
}
+276 -276
View File
@@ -1,295 +1,295 @@
import { Metadata } from "next";
import type { Metadata } from "next";
import { BasePage } from "@/app/base-page";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { Separator } from "@/components/ui/separator";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/constants/site-constants";
export const metadata: Metadata = {
title: "Terms of Service - OpenCut",
description:
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
openGraph: {
title: "Terms of Service - OpenCut",
description:
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
type: "website",
},
title: "Terms of Service - OpenCut",
description:
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
openGraph: {
title: "Terms of Service - OpenCut",
description:
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
type: "website",
},
};
export default function TermsPage() {
return (
<BasePage
title="Terms of service"
description="Fair and transparent terms for our free, open-source video editor. Contact us if you have any questions."
>
<Accordion type="single" collapsible className="w-full">
<AccordionItem
value="quick-summary"
className="rounded-2xl border px-5"
>
<AccordionTrigger className="no-underline!">
Quick summary
</AccordionTrigger>
<AccordionContent>
<h3 className="mb-3 text-lg font-medium">
You own your content, we own nothing.
</h3>
<ol className="list-decimal space-y-2 pl-6">
<li>
Your content stays private - basic editing is local, AI features
use encrypted uploads
</li>
<li>
We never claim ownership of your content, even when processing
AI features
</li>
<li>
Free for personal and commercial use with no watermarks or
restrictions
</li>
<li>Don't use OpenCut for illegal activities or harassment</li>
<li>
Service provided "as is" - we can't guarantee perfect uptime
</li>
<li>
Open source means you can review our code and self-host if
needed
</li>
<li>
You can delete your account anytime and keep using your exported
videos
</li>
</ol>
<p className="mt-4">
Questions? Email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
oss@opencut.app
</a>
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
return (
<BasePage
title="Terms of service"
description="Fair and transparent terms for our free, open-source video editor. Contact us if you have any questions."
>
<Accordion type="single" collapsible className="w-full">
<AccordionItem
value="quick-summary"
className="rounded-2xl border px-5"
>
<AccordionTrigger className="no-underline!">
Quick summary
</AccordionTrigger>
<AccordionContent>
<h3 className="mb-3 text-lg font-medium">
You own your content, we own nothing.
</h3>
<ol className="list-decimal space-y-2 pl-6">
<li>
Your content stays private - basic editing is local, AI features
use encrypted uploads
</li>
<li>
We never claim ownership of your content, even when processing
AI features
</li>
<li>
Free for personal and commercial use with no watermarks or
restrictions
</li>
<li>Don't use OpenCut for illegal activities or harassment</li>
<li>
Service provided "as is" - we can't guarantee perfect uptime
</li>
<li>
Open source means you can review our code and self-host if
needed
</li>
<li>
You can delete your account anytime and keep using your exported
videos
</li>
</ol>
<p className="mt-4">
Questions? Email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
oss@opencut.app
</a>
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Your Content, Your Rights</h2>
<p>
<strong>You own everything you create.</strong> OpenCut processes
basic editing locally on your device. For AI features, content is
encrypted before upload and we cannot access your original files. We
make no claims to ownership, licensing, or rights over your videos,
projects, or any content you create using OpenCut.
</p>
<ul className="list-disc space-y-2 pl-6">
<li>
Your content remains private and under your control at all times
</li>
<li>You retain all intellectual property rights to your content</li>
<li>
Even when using AI features, we cannot access your unencrypted
content
</li>
<li>You can export and use your content however you choose</li>
<li>No watermarks, no licensing restrictions from OpenCut</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Your Content, Your Rights</h2>
<p>
<strong>You own everything you create.</strong> OpenCut processes
basic editing locally on your device. For AI features, content is
encrypted before upload and we cannot access your original files. We
make no claims to ownership, licensing, or rights over your videos,
projects, or any content you create using OpenCut.
</p>
<ul className="list-disc space-y-2 pl-6">
<li>
Your content remains private and under your control at all times
</li>
<li>You retain all intellectual property rights to your content</li>
<li>
Even when using AI features, we cannot access your unencrypted
content
</li>
<li>You can export and use your content however you choose</li>
<li>No watermarks, no licensing restrictions from OpenCut</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">How You Can Use OpenCut</h2>
<p>OpenCut is free for personal and commercial use. You can:</p>
<ul className="list-disc space-y-2 pl-6">
<li>
Create videos for personal, educational, or commercial purposes
</li>
<li>Use OpenCut for client work and paid projects</li>
<li>Share and distribute videos created with OpenCut</li>
<li>
Modify and distribute the OpenCut software (under MIT license)
</li>
</ul>
<p>
<strong>What we ask:</strong> Don't use OpenCut for illegal
activities, harassment, or creating harmful content. Be respectful of
others and follow applicable laws.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">How You Can Use OpenCut</h2>
<p>OpenCut is free for personal and commercial use. You can:</p>
<ul className="list-disc space-y-2 pl-6">
<li>
Create videos for personal, educational, or commercial purposes
</li>
<li>Use OpenCut for client work and paid projects</li>
<li>Share and distribute videos created with OpenCut</li>
<li>
Modify and distribute the OpenCut software (under MIT license)
</li>
</ul>
<p>
<strong>What we ask:</strong> Don't use OpenCut for illegal
activities, harassment, or creating harmful content. Be respectful of
others and follow applicable laws.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">
AI Features and Data Processing
</h2>
<p>
OpenCut offers optional AI-powered features that require server
processing:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>
AI features (auto captions, content analysis, etc.) are completely
optional
</li>
<li>Your content is encrypted on your device before any upload</li>
<li>
We use zero-knowledge encryption - we cannot decrypt your content
</li>
<li>Encrypted content is deleted immediately after processing</li>
<li>
You maintain full ownership and control of your content throughout
</li>
</ul>
<p>
By using AI features, you consent to the temporary, encrypted
processing of your content as described in our Privacy Policy. You can
always choose to use only local editing features.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">
AI Features and Data Processing
</h2>
<p>
OpenCut offers optional AI-powered features that require server
processing:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>
AI features (auto captions, content analysis, etc.) are completely
optional
</li>
<li>Your content is encrypted on your device before any upload</li>
<li>
We use zero-knowledge encryption - we cannot decrypt your content
</li>
<li>Encrypted content is deleted immediately after processing</li>
<li>
You maintain full ownership and control of your content throughout
</li>
</ul>
<p>
By using AI features, you consent to the temporary, encrypted
processing of your content as described in our Privacy Policy. You can
always choose to use only local editing features.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Account and Service</h2>
<p>To use certain features, you may create an account:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Provide accurate information when signing up</li>
<li>Keep your account secure and don't share credentials</li>
<li>You're responsible for activity under your account</li>
<li>You can delete your account at any time</li>
</ul>
<p>
OpenCut is provided "as is" without warranties. While we strive for
reliability, we can't guarantee uninterrupted service.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Account and Service</h2>
<p>To use certain features, you may create an account:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Provide accurate information when signing up</li>
<li>Keep your account secure and don't share credentials</li>
<li>You're responsible for activity under your account</li>
<li>You can delete your account at any time</li>
</ul>
<p>
OpenCut is provided "as is" without warranties. While we strive for
reliability, we can't guarantee uninterrupted service.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Open Source Benefits</h2>
<p>Because OpenCut is open source, you have additional rights:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Review our code to see exactly how we handle your data</li>
<li>Self-host OpenCut on your own servers</li>
<li>Modify the software to suit your needs</li>
<li>Contribute improvements back to the community</li>
</ul>
<p>
View our source code and license on{" "}
<a
href={SOCIAL_LINKS.github}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub
</a>
.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Open Source Benefits</h2>
<p>Because OpenCut is open source, you have additional rights:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Review our code to see exactly how we handle your data</li>
<li>Self-host OpenCut on your own servers</li>
<li>Modify the software to suit your needs</li>
<li>Contribute improvements back to the community</li>
</ul>
<p>
View our source code and license on{" "}
<a
href={SOCIAL_LINKS.github}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub
</a>
.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Third-Party Content</h2>
<p>
When using OpenCut, make sure you have the right to use any content
you import:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>Only upload content you own or have permission to use</li>
<li>
Respect copyright, trademarks, and other intellectual property
</li>
<li>
Don't use copyrighted music, images, or videos without permission
</li>
<li>You're responsible for any claims related to your content</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Third-Party Content</h2>
<p>
When using OpenCut, make sure you have the right to use any content
you import:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>Only upload content you own or have permission to use</li>
<li>
Respect copyright, trademarks, and other intellectual property
</li>
<li>
Don't use copyrighted music, images, or videos without permission
</li>
<li>You're responsible for any claims related to your content</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Limitations and Liability</h2>
<p>
OpenCut is provided free of charge. To the extent permitted by law:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>We're not liable for any loss of data or content</li>
<li>
Projects are stored in your browser and may be lost if you clear
browser data
</li>
<li>We're not responsible for how you use the service</li>
<li>Our liability is limited to the maximum extent allowed by law</li>
</ul>
<p>
Since your content stays on your device, we have no way to recover
lost projects. Consider exporting important videos when finished
editing.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Limitations and Liability</h2>
<p>
OpenCut is provided free of charge. To the extent permitted by law:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>We're not liable for any loss of data or content</li>
<li>
Projects are stored in your browser and may be lost if you clear
browser data
</li>
<li>We're not responsible for how you use the service</li>
<li>Our liability is limited to the maximum extent allowed by law</li>
</ul>
<p>
Since your content stays on your device, we have no way to recover
lost projects. Consider exporting important videos when finished
editing.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Service Changes</h2>
<p>We may update OpenCut and these terms:</p>
<ul className="list-disc space-y-2 pl-6">
<li>We'll notify you of significant changes to these terms</li>
<li>Continued use means you accept any updates</li>
<li>You can always self-host an older version if you prefer</li>
<li>Major changes will be discussed with the community on GitHub</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Service Changes</h2>
<p>We may update OpenCut and these terms:</p>
<ul className="list-disc space-y-2 pl-6">
<li>We'll notify you of significant changes to these terms</li>
<li>Continued use means you accept any updates</li>
<li>You can always self-host an older version if you prefer</li>
<li>Major changes will be discussed with the community on GitHub</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Termination</h2>
<p>You can stop using OpenCut at any time:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Delete your account through your profile settings</li>
<li>Clear your browser data to remove local projects</li>
<li>Your content remains yours even if you stop using OpenCut</li>
<li>We may suspend accounts for violations of these terms</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Termination</h2>
<p>You can stop using OpenCut at any time:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Delete your account through your profile settings</li>
<li>Clear your browser data to remove local projects</li>
<li>Your content remains yours even if you stop using OpenCut</li>
<li>We may suspend accounts for violations of these terms</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Contact Us</h2>
<p>Questions about these terms or need to report an issue?</p>
<p>
Contact us through our{" "}
<a
href={`${SOCIAL_LINKS.github}/issues`}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub repository
</a>
, email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
oss@opencut.app
</a>
, or reach out on{" "}
<a
href={SOCIAL_LINKS.x}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
X (Twitter)
</a>
.
</p>
<p>
These terms are governed by applicable law in your jurisdiction. We
prefer to resolve disputes through friendly discussion in our
open-source community.
</p>
</section>
<Separator />
<p className="text-muted-foreground text-sm">
Last updated: July 14, 2025
</p>
</BasePage>
);
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Contact Us</h2>
<p>Questions about these terms or need to report an issue?</p>
<p>
Contact us through our{" "}
<a
href={`${SOCIAL_LINKS.github}/issues`}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub repository
</a>
, email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
oss@opencut.app
</a>
, or reach out on{" "}
<a
href={SOCIAL_LINKS.x}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
X (Twitter)
</a>
.
</p>
<p>
These terms are governed by applicable law in your jurisdiction. We
prefer to resolve disputes through friendly discussion in our
open-source community.
</p>
</section>
<Separator />
<p className="text-muted-foreground text-sm">
Last updated: July 14, 2025
</p>
</BasePage>
);
}