mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
lots of stuff
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user